← Volver al catálogo
AutomatizacionesReferenciaIntermedioGratis

Webhooks de SendGrid

Guía de referencia con código para recibir, verificar y procesar webhooks de SendGrid, incluyendo verificación de firma ECDSA y manejo de eventos de email como rebotes, aperturas y clics.

Descargar skill (.zip)

Descarga abierta · sin registro · para Node.js, Express, Next.js

// resultado_de_ejemplo

/**

  • NutriMail — Handler de Webhooks SendGrid
  • ==========================================
  • Recibe, verifica (ECDSA) y procesa eventos de entrega de email.
  • Stack: Node.js 18 + Express 4 + Prisma (PostgreSQL)
  • Evento -> Accion:
  • bounce -> Marcar contacto como invalid + log
  • spam_report -> Alerta Slack si tasa > 0.1%
  • unsubscribe -> Actualizar estado GDPR inmediatamente
  • open / click -> Registrar en analytics_events
  • delivered -> Confirmar entrega en campaigns_log */

'use strict';

const express = require('express'); const crypto = require('crypto'); const { PrismaClient } = require('@prisma/client');

const router = express.Router(); const prisma = new PrismaClient();

// ─── 1. Verificacion de firma ECDSA ────────────────────────────────────────

/**

  • Verifica que el webhook proviene realmente de SendGrid.

  • SendGrid firma con ECDSA (P-256 / SHA-256) la concatenacion:

  • timestamp + rawBody

  • y pasa la firma en base64 via cabecera X-Twilio-Email-Event-Webhook-Signature.

  • @param {string} publicKey - Clave publica SendGrid (env SENDGRID_WEBHOOK_VERIFICATION_KEY)

  • @param {Buffer} rawBody - Cuerpo crudo de la peticion (sin parsear)

  • @param {string} signature - Cabecera X-Twilio-Email-Event-Webhook-Signature

  • @param {string} timestamp - Cabecera X-Twilio-Email-Event-Webhook-Timestamp

  • @returns {boolean} */ function verifySignature(publicKey, rawBody, signature, timestamp) { try { const decodedSig = Buffer.from(signature, 'base64'); const signedContent = timestamp + rawBody.toString('utf8');

    // Envolver en cabeceras PEM si la clave llega sin ellas const pemKey = publicKey.includes('BEGIN PUBLIC KEY') ? publicKey : -----BEGIN PUBLIC KEY-----\n${publicKey}\n-----END PUBLIC KEY-----;

    const verifier = crypto.createVerify('SHA256'); verifier.update(signedContent); return verifier.verify(pemKey, decodedSig); } catch (err) { console.error('[sendgrid-webhook] Error verificando firma:', err.message); return false; } }

// ─── 2. Procesadores por tipo de evento ────────────────────────────────────

const EVENT_PROCESSORS = {

/** Bounce duro: invalida el contacto en la BD */ async bounce(event) { const { email, type, reason, sg_event_id } = event; console.log([bounce] ${email} | tipo: ${type} | razon: ${reason});

await prisma.contact.updateMany({
  where: { email },
  data: {
    status:        'invalid',
    bounce_type:   type,          // 'bounce' | 'blocked'
    bounce_reason: reason,
    updated_at:    new Date(),
  },
});

// Registrar en log de auditoría
await prisma.email_event_log.create({
  data: {
    sg_event_id,
    event_type: 'bounce',
    email,
    metadata:   { type, reason },
    created_at: new Date(event.timestamp * 1000),
  },
});

},

/** Spam report: incrementar contador y alertar si supera umbral */ async spam_report(event) { const { email, sg_event_id } = event; console.warn([spam_report] ${email});

// Dar de baja inmediatamente (GDPR)
await prisma.contact.updateMany({
  where: { email },
  data: { status: 'spam', updated_at: new Date() },
});

// Calcular tasa de spam de las ultimas 24h
const since   = new Date(Date.now() - 86_400_000);
const [spams, sends] = await Promise.all([
  prisma.email_event_log.count({ where: { event_type: 'spam_report',  created_at: { gte: since } } }),
  prisma.email_event_log.count({ where: { event_type: 'delivered',    created_at: { gte: since } } }),
]);

const rate = sends > 0 ? spams / sends : 0;
if (rate > 0.001) {
  // Alerta Slack (llamada real en produccion)
  console.error(`[ALERTA] Tasa de spam: ${(rate * 100).toFixed(3)}% — supera umbral 0.1%`);
  // await slackNotify(`Tasa de spam NutriMail: ${(rate * 100).toFixed(3)}%`);
}

await prisma.email_event_log.create({
  data: { sg_event_id, event_type: 'spam_report', email, metadata: {}, created_at: new Date(event.timestamp * 1000) },
});

},

/** Unsubscribe: actualizar estado GDPR de inmediato */ async unsubscribe(event) { const { email, sg_event_id } = event; console.log([unsubscribe] ${email});

await prisma.contact.updateMany({
  where: { email },
  data: { subscribed: false, unsubscribed_at: new Date(), updated_at: new Date() },
});

await prisma.email_event_log.create({
  data: { sg_event_id, event_type: 'unsubscribe', email, metadata: {}, created_at: new Date(event.timestamp * 1000) },
});

},

/** Apertura: registrar en analytics */ async open(event) { const { email, sg_message_id, useragent, ip, sg_event_id } = event; console.log([open] ${email} | msg: ${sg_message_id});

await prisma.analytics_event.create({
  data: {
    sg_event_id,
    event_type:    'open',
    email,
    campaign_id:   sg_message_id,
    metadata:      { useragent, ip },
    created_at:    new Date(event.timestamp * 1000),
  },
});

},

/** Clic en enlace: registrar URL y posicion */ async click(event) { const { email, sg_message_id, url, url_offset, sg_event_id } = event; console.log([click] ${email} | url: ${url});

await prisma.analytics_event.create({
  data: {
    sg_event_id,
    event_type:  'click',
    email,
    campaign_id: sg_message_id,
    metadata:    { url, url_offset },
    created_at:  new Date(event.timestamp * 1000),
  },
});

},

/** Entrega exitosa: confirmar en campaigns_log */ async delivered(event) { const { email, sg_message_id, response, sg_event_id } = event;

await prisma.email_event_log.create({
  data: {
    sg_event_id,
    event_type: 'delivered',
    email,
    metadata:   { sg_message_id, response },
    created_at: new Date(event.timestamp * 1000),
  },
});

},

// Tipos sin accion especifica -> solo log async processed(event) { await logGeneric('processed', event); }, async deferred(event) { await logGeneric('deferred', event); }, async group_unsubscribe(event) { await logGeneric('group_unsubscribe', event); }, async group_resubscribe(event) { await logGeneric('group_resubscribe', event); }, };

async function logGeneric(type, event) { const { email, sg_event_id } = event; await prisma.email_event_log.create({ data: { sg_event_id, event_type: type, email, metadata: {}, created_at: new Date(event.timestamp * 1000), }, }); }

// ─── 3. Endpoint principal ──────────────────────────────────────────────────

/**

  • POST /webhooks/sendgrid

  • Cabeceras requeridas:

  • X-Twilio-Email-Event-Webhook-Signature — firma ECDSA en base64

  • X-Twilio-Email-Event-Webhook-Timestamp — timestamp UNIX string

  • Body: JSON array de eventos SendGrid */ router.post( '/webhooks/sendgrid', // IMPORTANTE: raw body necesario para verificar firma intacta express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.get('X-Twilio-Email-Event-Webhook-Signature'); const timestamp = req.get('X-Twilio-Email-Event-Webhook-Timestamp');

    // ── 3a. Validar cabeceras ── if (!signature || !timestamp) { console.warn('[sendgrid-webhook] Cabeceras de firma ausentes'); return res.status(400).json({ error: 'Missing signature headers' }); }

    // ── 3b. Verificar firma ECDSA ── const publicKey = process.env.SENDGRID_WEBHOOK_VERIFICATION_KEY; if (!verifySignature(publicKey, req.body, signature, timestamp)) { console.warn('[sendgrid-webhook] Firma invalida — peticion rechazada'); return res.status(401).json({ error: 'Invalid signature' }); }

    // ── 3c. Parsear eventos ── let events; try { events = JSON.parse(req.body.toString('utf8')); } catch (parseErr) { return res.status(400).json({ error: 'Invalid JSON body' }); }

    console.log([sendgrid-webhook] Recibidos ${events.length} evento(s));

    // ── 3d. Procesar en paralelo con idempotencia ── const results = await Promise.allSettled( events.map(async (event) => { const eventType = (event.event || '').replace(' ', '_'); // 'spam report' -> 'spam_report' // Idempotencia: ignorar si ya procesamos este sg_event_id const exists = await prisma.email_event_log.findUnique({ where: { sg_event_id: event.sg_event_id }, }); if (exists) { console.log([sendgrid-webhook] Evento duplicado ignorado: ${event.sg_event_id}); return { status: 'skipped', sg_event_id: event.sg_event_id }; } const processor = EVENT_PROCESSORS[eventType]; if (processor) { await processor(event); return { status: 'processed', event: eventType }; } else { console.log([sendgrid-webhook] Evento sin procesador especifico: ${eventType}); return { status: 'ignored', event: eventType }; } }) );

    // Loguear cualquier fallo sin que afecte la respuesta 200 results.forEach((r) => { if (r.status === 'rejected') { console.error('[sendgrid-webhook] Error procesando evento:', r.reason); } });

    // SendGrid requiere 200 para no reintentar return res.sendStatus(200); } );

// ─── 4. Exportar router ───────────────────────────────────────────────────── module.exports = router;

/*

  • Uso en app.js:
  • ──────────────
  • const webhookRouter = require('./routes/sendgrid-webhook');
  • app.use('/', webhookRouter);
  • Variables de entorno requeridas (.env):
  • ────────────────────────────────────────
  • SENDGRID_WEBHOOK_VERIFICATION_KEY="MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."
  • DATABASE_URL="postgresql://nutrimail:secret@localhost:5432/nutrimail_prod"
  • Desarrollo local con Hookdeck CLI:
  • ───────────────────────────────────
  • npx hookdeck-cli listen 3000 sendgrid --path /webhooks/sendgrid
  • Schema Prisma necesario:
  • ─────────────────────────
  • model email_event_log {
  • id Int @id @default(autoincrement())
  • sg_event_id String @unique
  • event_type String
  • email String
  • metadata Json
  • created_at DateTime
  • }
  • model analytics_event {
  • id Int @id @default(autoincrement())
  • sg_event_id String @unique
  • event_type String
  • email String
  • campaign_id String
  • metadata Json
  • created_at DateTime
  • } */

// qué_hace

Implementa un handler de webhooks de SendGrid con verificación de firma ECDSA y procesamiento de eventos de entrega de email.

// cómo_lo_hace

Proporciona código listo para usar en Express/Node.js que valida la firma criptográfica del webhook y procesa eventos como bounces, aperturas, clics y cancelaciones de suscripción.

// ejemplo_de_uso

Cuando usas SendGrid para transaccionales y necesitas reaccionar en tiempo real a rebotes o cancelaciones. Ej.: procesar el evento de bounce de SendGrid para marcar automáticamente el email como inválido en tu base de datos.

// plataformas

Node.jsExpressNext.jsFastAPISendGridHookdeck
Categoría
Automatizaciones
Tipo
Referencia
Nivel
Intermedio
Licencia
MIT
Seguridad
seguro · riesgo bajo
Versión
1.0.0

// opiniones_de_la_comunidad

Opiniones

Cargando opiniones…

// pase_cultiva_ia

Llévate todo el arsenal con el Pase

Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.

Pago único · IVA incluido · pago seguro con Stripe.

Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.