Webhooks Confiables en Node.js: Idempotencia y DLQ con BullMQ
Tu webhook que "a veces falla" no falla a veces. Falla siempre que importa. Cuando el evento es una confirmación de pago de Stripe que el usuario ya vio en pantalla, cuando es un acuse del SAT que te notifica que el CFDI pasó a timbrado, cuando es un cambio de status de un envío que el cliente está esperando. En esos momentos tu receptor de webhooks está ocupado, la base de datos está lenta, o un deploy reciente rompió una ruta, y pierdes el evento. El emisor reintenta dos o tres veces con suerte, después te marca como "endpoint no confiable" y abandona.
Recibir webhooks bien no es tener un POST /webhook que responde 200. Es aceptar el evento en menos de 5 segundos sin importar qué, validar la firma, evitar procesar el mismo evento dos veces si llega duplicado, y procesarlo en background con reintentos exponenciales cuando falla. Todo eso se puede hacer con Node.js, Redis y BullMQ en menos de 300 líneas de TypeScript.
En este artículo aprenderás:
- Por qué los webhooks fallan en producción y qué contrato implícito tiene tu endpoint con cada emisor
- Patrón receive-fast-process-async con BullMQ para aceptar el evento en menos de 1 segundo
- Firma HMAC SHA-256 implementada bien, con comparación resistente a timing attacks
- Idempotencia por
idempotency-keycon Redis para no procesar el mismo evento dos veces - Retries con backoff exponencial y Dead Letter Queue para los eventos que fallan persistentemente
- Ejemplo real con SAT PAC y Stripe para aterrizar los patrones en casos conocidos
Por qué los webhooks fallan en producción
El contrato implícito entre un emisor de webhooks (Stripe, SAT PAC, Shopify, GitHub, tu propio proveedor de SMS) y tu receptor tiene tres reglas que casi nadie documenta claramente.
Regla 1: tienes menos de 5 segundos para responder. Stripe corta a 10s, la mayoría cortan a 5s. Si tu endpoint procesa el evento sincronamente — guarda en base de datos, invoca un LLM, manda un email — y alguna de esas operaciones se pone lenta, tu respuesta llega tarde y el emisor asume que fallaste. Reintentará entre 3 y 10 veces, pero cada reintento llega con minutos u horas de retraso, lo que es inaceptable para eventos time-sensitive.
Regla 2: los eventos pueden llegar duplicados, pueden llegar fuera de orden, y pueden llegar después de que procesaste otros más nuevos. Si tu lógica asume "este evento siempre es el último estado", tarde o temprano vas a marcar un pedido como "cancelado" después de que ya lo marcaste como "enviado" porque el evento de cancelación se retrasó en la red del emisor.
Regla 3: tu endpoint está en internet público y cualquiera puede enviarle POSTs. Sin validación de firma, un actor malicioso que conoce tu URL puede enviarte eventos falsos. Firmar con HMAC y validar la firma antes de procesar es no negociable.
La arquitectura correcta que resuelve las tres reglas es idéntica para cualquier emisor: recibir rápido, encolar, procesar en background con reintentos. El receptor sincrónico solo hace tres cosas: valida la firma, guarda el evento crudo en Redis, y devuelve 200. El procesamiento real — guardar en PostgreSQL, notificar al usuario, disparar emails — corre en un worker separado que lee de la cola y puede tomar minutos si hace falta.
Un error típico: recibes el webhook, corres await db.save(event) en el mismo handler, y si la DB está lenta, el timeout del emisor se dispara antes de que respondas. Lo correcto es await queue.add(rawEvent) (agregar a una cola Redis toma ~2ms) y luego res.status(200).end(). El procesamiento real ocurre después.
El stack: Express + BullMQ + Redis
BullMQ es una librería open source (MIT) para colas de trabajo en Node.js construida sobre Redis. Es el sucesor de Bull (mismo autor, reescrito con TypeScript nativo) y es el estándar de facto para colas en Node.js. No requiere servicios managed, no tiene tier de pago y no depende de proveedor.
npm install express bullmq ioredis zod
npm install -D typescript @types/express @types/node
Arrancar Redis localmente para desarrollo:
docker run --rm -p 6379:6379 redis:7-alpine
La estructura del proyecto:
src/
infra/
redis.ts
webhooks/
receiver.ts # endpoint Express que acepta el webhook
hmac.ts # validación de firma HMAC
queue.ts # definición de la cola BullMQ
worker.ts # procesa eventos en background
index.ts
Vamos de afuera hacia adentro: primero la validación de firma, después el receiver rápido, después la cola, y por último el worker con idempotencia y DLQ.
Validación HMAC SHA-256 sin timing attacks
La mayoría de emisores serios firman el payload con HMAC usando un secret compartido. El emisor calcula HMAC_SHA256(secret, body_crudo) y lo envía en un header (típicamente X-Signature o Stripe-Signature). Tu receptor recalcula lo mismo y compara.
Hay dos errores clásicos en esta comparación. El primero: usar === o Buffer.equals directo, que comparan byte por byte y terminan tan pronto encuentran diferencia. Eso abre la puerta a timing attacks donde un atacante envía firmas progresivamente más correctas y mide el tiempo de respuesta para adivinar el hash. Node.js resuelve esto con crypto.timingSafeEqual, que toma tiempo constante independientemente de dónde esté la diferencia.
El segundo error: firmar sobre el body ya parseado como JSON en lugar del body crudo. JSON.parse y JSON.stringify no garantizan el mismo resultado byte por byte (orden de llaves, espacios, escape de caracteres), así que tu HMAC recalculado no coincidirá con el del emisor. Express con express.json() por defecto te tira esa oportunidad a la basura: el body crudo ya no existe para cuando llega tu handler.
La solución es capturar el body crudo antes del parser JSON:
// src/webhooks/receiver.ts
import express from 'express';
const app = express();
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body as Buffer; // Buffer crudo, sin parsear
const signature = req.header('Stripe-Signature') ?? '';
if (!verifyHmac(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'invalid_signature' });
}
// JSON.parse despues de verificar firma
const event = JSON.parse(rawBody.toString('utf8'));
// Encolar y responder rapido
await webhooksQueue.add('process', {
provider: 'stripe',
idempotencyKey: event.id,
event,
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
});
res.status(200).json({ received: true });
},
);
La validación HMAC en sí:
// src/webhooks/hmac.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyHmac(
rawBody: Buffer,
signatureHeader: string,
secret: string,
): boolean {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const received = signatureHeader.trim();
// Ambos buffers deben tener el mismo tamanio para timingSafeEqual
if (expected.length !== received.length) return false;
return timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(received, 'utf8'),
);
}
Stripe usa un header más elaborado (t=timestamp,v1=signature) que también valida que el evento no tenga más de 5 minutos de antigüedad (mitigación contra replay attacks). Si integras con Stripe directamente, usa su SDK oficial que maneja el parsing de ese header. La lógica general es la misma.
¿Tu integración con SAT PAC, Stripe o Shopify pierde eventos?
Agendá 30 minutos por Meet y revisamos tu receptor de webhooks en vivo. Salís con el boilerplate BullMQ (firma HMAC, DLQ, retries) adaptado a tu stack. Sin pitch, dos devs revisando.
Agendar revisión →Cola BullMQ con retries exponenciales
Una vez que encolaste el evento, el receptor ya cumplió su contrato con el emisor. El worker es donde pasa todo el trabajo real y también todo el riesgo. Queremos que reintente cuando falla, pero no infinitamente, y que los eventos irreparables acaben en una DLQ para inspección manual.
Definición de la cola:
// src/webhooks/queue.ts
import { Queue } from 'bullmq';
import { redis } from '../infra/redis';
export const webhooksQueue = new Queue('webhooks', {
connection: redis,
defaultJobOptions: {
attempts: 5,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 1000, age: 24 * 3600 },
removeOnFail: false, // mantener los fallidos para inspeccion
},
});
export const webhooksDLQ = new Queue('webhooks:dlq', {
connection: redis,
});
El backoff exponencial con delay: 1000 calcula los reintentos como 1s, 2s, 4s, 8s, 16s. Con 5 intentos cubres 31 segundos de ventana de recuperación, suficiente para sobrevivir un reinicio de PostgreSQL o un blip de red. Si después de 5 intentos sigue fallando, el job se marca como failed y no se reintenta automáticamente.
El removeOnComplete mantiene los últimos 1,000 jobs exitosos por 24 horas para debugging, después los borra. El removeOnFail: false es clave: los fallidos se mantienen para siempre hasta que los reprocesas o los archivas manualmente. Esa es tu DLQ implícita.
El worker que procesa:
// src/webhooks/worker.ts
import { Worker } from 'bullmq';
import { redis } from '../infra/redis';
import { webhooksDLQ } from './queue';
new Worker(
'webhooks',
async (job) => {
const { provider, idempotencyKey, event } = job.data;
// Idempotencia: ya procesado este evento?
const alreadyProcessed = await redis.set(
`webhook:processed:${provider}:${idempotencyKey}`,
'1',
'EX', 7 * 24 * 3600, // 7 dias de ventana
'NX',
);
if (alreadyProcessed === null) {
console.log(`[webhook] skip duplicate ${provider}:${idempotencyKey}`);
return;
}
try {
await processEvent(provider, event);
} catch (err) {
// Si ya agotamos todos los attempts, al DLQ
if (job.attemptsMade >= (job.opts.attempts ?? 5)) {
await webhooksDLQ.add('dead', {
provider, idempotencyKey, event,
error: (err as Error).message,
failedAt: new Date().toISOString(),
});
}
throw err; // BullMQ reintenta si aun hay attempts
}
},
{
connection: redis,
concurrency: 10,
},
);
async function processEvent(provider: string, event: any) {
// tu logica real aqui: actualizar DB, notificar usuario, etc
}
Esta es la pieza que más errores técnicos resuelve. Tres cosas pasan aquí que vale la pena subrayar.
Idempotencia con Redis SET NX EX. La operación SET key value NX EX seconds es atómica en Redis: pone la llave solo si no existe y le aplica un TTL. Si devuelve null, la llave ya existía, lo que significa que este evento ya se procesó. Salimos temprano sin volver a correr la lógica. El TTL de 7 días cubre el caso realista: ningún emisor serio reintenta más de una semana después.
Detección del último intento. Cuando job.attemptsMade alcanza el máximo configurado, agregamos a la DLQ antes de re-lanzar el error. Si tirás el error antes de encolar la DLQ, BullMQ marca el job como failed pero nadie lo procesa después. Es la secuencia más fácil de equivocar.
Concurrency controlada. concurrency: 10 significa que el worker procesa hasta 10 eventos en paralelo. Ajustá según la capacidad de tu base de datos: 10 es razonable con Postgres en una instancia pequeña; 50-100 para workloads más intensivos con una DB dimensionada.
¿Tus webhooks del SAT llegan pero tu sistema no los registra?
Revisamos tu integración CFDI en 30 minutos por Meet. Te decimos qué está mal y te dejamos el código que debería estar corriendo. Sin pitch de ventas.
Agendar revisión técnica →Ejemplo real: webhooks del SAT PAC y Stripe
Los proveedores de CFDI (PACs) como Facturama, Solución Factible y Finkok envían webhooks cuando el status de un comprobante cambia: timbrado, cancelado, rechazado. La estructura del evento es propietaria por PAC pero la arquitectura de recepción es idéntica a Stripe.
Un caso típico en PyMEs: el equipo contable emite una factura desde tu ERP propio, el PAC la timbra y envía el webhook 2-10 segundos después con el XML firmado y el UUID del SAT. Si tu receptor está caído, el PAC reintenta 3 veces en la primera hora y después se rinde. El UUID queda solo en la base de datos del PAC y tu sistema muestra el comprobante como "pendiente" para siempre. El equipo contable llama al soporte, el soporte sugiere reenviar, y el ciclo se repite.
Con la arquitectura que cubrimos, el receptor queda así:
app.post('/webhooks/pac/facturama',
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body as Buffer;
const sig = req.header('X-Facturama-Signature') ?? '';
if (!verifyHmac(rawBody, sig, process.env.FACTURAMA_SECRET!)) {
return res.status(401).json({ error: 'invalid_signature' });
}
const event = JSON.parse(rawBody.toString());
await webhooksQueue.add('process', {
provider: 'facturama',
idempotencyKey: event.uuid ?? event.id,
event,
});
res.status(200).json({ received: true });
},
);
Y el processEvent específico:
async function processFacturamaEvent(event: any) {
const uuid = event.uuid;
const status = event.status; // 'Timbrado' | 'Cancelado' | 'Rechazado'
await db.cfdi.update({
where: { uuid },
data: {
status,
xmlUrl: event.xmlUrl,
updatedAt: new Date(),
},
});
if (status === 'Timbrado') {
await emailQueue.add('send-cfdi', { uuid });
}
}
Este patrón cubre el 95% de integraciones de CFDI en producción en México. Es la misma arquitectura que cubrimos en Cómo Automatizar Facturación CFDI 4.0 con Software Propio pero entrando en el detalle del receptor.
Inspección de la DLQ y reproceso manual
Una DLQ sin una herramienta para inspeccionarla es un agujero negro. BullMQ provee Bull Board (MIT), un dashboard OSS que se monta como middleware de Express. Te permite ver los jobs en cada estado, reintentar manualmente desde la UI, e inspeccionar el payload y el stack trace.
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/admin/queues');
createBullBoard({
queues: [new BullMQAdapter(webhooksQueue), new BullMQAdapter(webhooksDLQ)],
serverAdapter,
});
app.use('/admin/queues', adminAuth, serverAdapter.getRouter());
El middleware adminAuth debe proteger la ruta con autenticación real — nunca dejes Bull Board expuesto en internet público. En producción lo típico es usar JWT o basic auth con credenciales rotadas. Para el detalle de JWT, ya cubrimos esa pieza en Autenticación JWT en Node.js: Guía Paso a Paso.
El flujo de operación después de un incidente queda claro: entrás a /admin/queues, ves los jobs en la DLQ, inspeccionás el error (típicamente un bug en processEvent o un registro que ya no existe en DB), arreglás el código, reintentás manualmente, y los eventos se procesan con la idempotencia protegiendo contra duplicación.
Checklist de webhooks en producción
- Body crudo preservado (
express.raw) antes del parser JSON para poder validar HMAC - HMAC verificado con
timingSafeEqualen lugar de comparación directa - Response 200 en menos de 1 segundo devolviendo inmediatamente después de encolar
- Idempotencia con Redis SET NX EX sobre el identificador único del evento del emisor
- Retries exponenciales con 5 intentos como mínimo antes de declarar el evento muerto
- DLQ separada con los jobs fallidos mantenidos indefinidamente
- Dashboard de inspección (Bull Board) protegido con auth real
- Métricas exportadas a Prometheus: tasa de 401s, tasa de DLQ, latencia del worker
- Alerta automática cuando la DLQ crece más de X eventos en Y minutos
- Test de replay attacks: el mismo evento enviado dos veces debe procesarse solo una
Conclusión
Los webhooks confiables no son un microservicio "simple que recibe POSTs". Son una pieza crítica de integración que maneja tres problemas distintos al mismo tiempo: timeouts del emisor, duplicación de eventos, y autenticación de la fuente. Resolver los tres con Node.js, BullMQ y Redis es viable, es barato, y es mantenible a largo plazo.
Lo que cubrimos:
- ✅ Por qué el patrón receive-fast-process-async es obligatorio
- ✅ Validación HMAC SHA-256 resistente a timing attacks
- ✅ Cola BullMQ con backoff exponencial y 5 reintentos
- ✅ Idempotencia con Redis SET NX EX
- ✅ Dead Letter Queue para eventos irreparables
- ✅ Ejemplo real con PAC del SAT y Stripe
Tu Siguiente Paso
Si tu integración con SAT, Stripe, Shopify o tu propia red de proveedores pierde eventos cada semana, una revisión técnica de 45 minutos puede identificar exactamente qué pieza del pipeline falla. Dos devs revisando tu código en vivo. Gratis, sin pitch.
Agendar revisión de webhooks →