Todo gran proveedor de webhooks —Stripe, GitHub, Shopify, Twilio, Slack— entrega el mismo evento más de una vez a propósito. Lo llaman entrega al-menos-una-vez. Si tu handler no está diseñado para ello, tendrás clientes cobrados dos veces, etiquetas de PR de GitHub duplicadas y notificaciones de Slack disparadas dos veces. El arreglo es pequeño y poco valorado: diseña cada handler para ser seguro ante entregas repetidas.
Por qué ocurren los duplicados
Los proveedores reintentan cuando no reciben un 2xx limpio a tiempo. La ventana de reintento varía: Stripe, hasta 3 días con backoff exponencial; GitHub, 8 intentos en ~8 horas; Shopify, 48 horas. La mayoría de las veces la entrega original sí tuvo éxito; la red simplemente perdió la respuesta de vuelta. El proveedor no lo sabe. Así que reintenta. Así que tu handler se ejecuta dos veces.
Esto no es un bug que arreglar; es una propiedad del sistema. Constrúyelo pensando en ello.
El patrón de idempotencia que de verdad funciona
Cada evento de un gran proveedor incluye un ID de evento único. Stripe: evt_xxx. GitHub: cabecera X-GitHub-Delivery (un UUID). Shopify: X-Shopify-Webhook-Id. Guarda el ID la primera vez que procesas el evento. En el reintento, búscalo: si ya lo has visto, devuelve 200 sin volver a ejecutar el handler.
async function handleWebhook(event) {
const eventId = event.id;
// Atomic insert-if-not-exists
const inserted = await db.processedEvents.insertIfNew({
eventId,
receivedAt: new Date(),
});
if (!inserted) {
// We've handled this one. Be polite about it.
return { status: 200, body: 'already processed' };
}
// First time we've seen it. Do the work.
await actuallyHandleEvent(event);
return { status: 200 };
}
La forma que importa: el insert y la lógica del handler deben ser atómicos juntos, o el insert debe ir primero. Si llamas a handleEvent primero y luego insertas el ID, un crash a mitad del handler no deja registro y el reintento reprocesará.
Dónde guardar la clave de dedup
- La misma transacción de BD que la escritura de negocio. Lo mejor. La clave de dedup y el efecto secundario se confirman ambos o ninguno. Usa una restricción única en la columna del ID de evento.
- Redis con un TTL largo. Bien para eventos de bajo riesgo. Ajusta el TTL a la ventana de reintento más larga de cualquier proveedor con el que integres: empieza en 7 días.
- Caché en memoria. No. Tu proceso se reinicia, la caché desaparece y el siguiente reintento logra volver a ejecutar un handler que ya se ejecutó.
Cómo se ve "no idempotente" en la práctica
Un ejemplo real que vivimos: un handler de Stripe invoice.payment_succeeded que llamaba a account.credit(amount) directamente. Cada pago exitoso sumaba saldo. La mayoría de las veces exactamente una vez. Ocasionalmente dos, cuando un reintento atrapaba el mismo evento. Lo descubrimos porque un cliente preguntó amablemente por correo por qué su factura de 50 $ había acreditado su cuenta con 100 $.
El arreglo no fue cambiar la política de reintentos de Stripe ni pelear con el proveedor. Fue un índice único en events_processed.stripe_event_id y una guarda al inicio del handler. Todo el parche fueron once líneas.
El timing del handler también importa
Los proveedores tienen timeouts estrictos. GitHub: 10 segundos. Endpoints de interacción de Discord: 3 segundos. Stripe y Shopify son más indulgentes (~30 segundos), pero igual tendrás reintentos si eres lento. El patrón es el mismo: responde rápido, haz el trabajo async.
async function handleWebhook(event) {
if (await alreadyProcessed(event.id)) {
return { status: 200 };
}
// Don't await the heavy work — queue it.
await queue.enqueue('process-stripe-event', event);
// 200 immediately. Worker picks up the event.
return { status: 200 };
}
El worker de la cola maneja lo lento. El handler de webhook es básicamente un deduper + un productor de cola. Este patrón sigue siendo correcto ante reintentos porque el productor de cola es idempotente (insert-if-new sobre el ID de evento), y cualquier doble enqueue lo atrapa la misma guarda en el worker.
Pruébalo en local, por favor
Probar la idempotencia es justo para lo que se hizo el replay de túnel. Captura una entrega de evento en el historial de peticiones de tu túnel y reprodúcela diez veces contra tu handler local. La primera llamada debe escribir en tu base de datos. Las siguientes nueve deben devolver 200 sin tocarla. Observa tus logs de BD para confirmar.
Esta es la prueba que la mayoría de los equipos se salta y que la mayoría de los equipos no debería saltarse.
Políticas de reintento por proveedor
- Stripe: reintenta 3 días, backoff exponencial. Deja de intentar tras 16 intentos.
- GitHub: 8 intentos, terminados en ~8 horas. Después marca la entrega como fallida.
- Shopify: 48 horas, backoff exponencial. Desactiva la suscripción al webhook tras 19 fallos consecutivos en 48 horas.
- Twilio: un reintento por defecto, configurable hasta 3.
- Slack: 3 reintentos con backoff (1 s, 30 s, 5 min).
Ajusta tu TTL de dedup a la ventana más larga que te importe, más un margen.
En resumen
La entrega duplicada es un contrato, no un defecto. Si has desplegado un handler de webhook que llama a increment, send_email o charge_card sin clave de dedup, has desplegado un bug. Parchearlo es pequeño. Descubrirlo tras una queja de cliente es caro.
Para más sobre probar esto de extremo a extremo, la guía de depuración con replay cubre el flujo. La verificación de firma va antes de esto: lo cubre la guía de firma. Únete a la lista de espera de PortPreview para túnel + replay construidos en torno a este flujo exacto.