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.
Descarga abierta · sin registro · para Node.js, Express, Next.js
/**
- 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
// 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 €.