Webhook idempotente: el bug que solo aparece en producción
Todo gateway reenvía webhooks. Si tu handler no es idempotente, lo vas a descubrir el día en que la misma venta se cuente tres veces.
Integras el gateway, disparas el webhook de prueba, la venta aparece en la base, todo en verde. Dos semanas después el informe de facturación no cuadra con el extracto, y la diferencia siempre es de más.
El motivo casi nunca es un bug de cálculo. Es la misma venta grabada dos veces.
Por qué el webhook llega repetido
Todo gateway serio (Stripe, Kiwify, Hotmart, Pagar.me) promete entrega at-least-once, nunca exactly-once. Eso no es pereza suya, es física de red: si su servidor manda el POST y el tuyo tarda 11 segundos en responder, no tienen forma de saber si procesaste y la respuesta se perdió, o si nunca recibiste nada. La única opción segura es reenviar.
En la práctica recibes duplicados cuando:
- tu handler pasó del timeout (normalmente 10s) pero terminó el trabajo
- devolvió un 500 después de haber escrito ya en la base
- el gateway reprocesó una cola interna
- alguien pulsó "reenviar" en el panel para depurar
La solución no es if not exists
El primer intento de todo el mundo es este:
const existing = await db.query.sales.findFirst({
where: eq(sales.externalId, event.id),
})
if (!existing) {
await db.insert(sales).values(mapEvent(event))
}Eso reduce el problema, no lo resuelve. Entre el findFirst y el insert existe una ventana de unos
milisegundos. Dos entregas simultáneas del mismo evento, que es exactamente lo que pasa cuando el
gateway hace retry agresivo, pasan las dos por el if, y las dos insertan.
Es una race condition clásica. Es lo bastante rara como para no aparecer nunca en pruebas y lo bastante común como para aparecer en producción.
La base lo resuelve mejor que tú
La garantía tiene que estar donde la concurrencia se resuelve de verdad: en una constraint.
create unique index sales_provider_external_id_idx
on sales (provider, external_id);Y el insert se convierte en un upsert:
await db
.insert(sales)
.values(mapEvent(event))
.onConflictDoUpdate({
target: [sales.provider, sales.externalId],
set: { status: event.status, raw: event },
})Ahora, si dos peticiones llegan en el mismo microsegundo, una gana el insert y la otra cae en el update. El resultado final es el mismo en los dos casos, que es literalmente la definición de idempotencia.
Fíjate en que el índice es compuesto: (provider, external_id). El id 12345 de Kiwify y el 12345
de Stripe son eventos distintos. Sin el provider en la clave, añadir un segundo gateway en el futuro
rompe el pasado.
Buscar antes de insertar
- Ventana de milisegundos entre el SELECT y el INSERT
- Dos entregas simultáneas pasan las dos por el if
- Rara en pruebas, común en producción
Unique index y upsert
- La base decide quién gana, sin ventana
- Uno inserta, el otro cae en el update
- Mismo resultado final: idempotencia
Guarda el evento crudo, siempre
Separada de la tabla de negocio, ten una tabla tonta que solo registre lo que llegó:
await db.insert(webhookEvents).values({
provider: "kiwify",
externalId: event.id,
signatureOk: true,
payload: event,
})Parece redundante. No lo es. Cuando la facturación no cuadre (y en algún momento no va a cuadrar), esa tabla es la única fuente que te dice qué mandó realmente el gateway, en vez de lo que entendió tu parser. Ya me ha salvado de discusiones con soporte más de una vez.
| Tabla | Sirve para | ¿Se puede reconstruir? |
|---|---|---|
webhook_events | auditoría, replay, depuración | no, es la fuente |
sales | informe, dashboard, consulta | sí, a partir de la otra |
Esa asimetría es el punto. Si te equivocas en el mapeo de un campo, puedes reprocesar webhook_events
y regenerar sales. Lo contrario no existe.
Verifica la firma antes de parsear
Un detalle fácil de equivocar: el orden importa.
export async function POST(request: Request) {
const raw = await request.text() // texto crudo, no .json()
if (!verifySignature(raw, request.headers)) {
return new Response("invalid signature", { status: 401 })
}
const event = eventSchema.parse(JSON.parse(raw))
// ...
}La firma es un HMAC del cuerpo exacto que fue enviado. Si haces await request.json() y después
JSON.stringify de vuelta para comprobar, el orden de las claves y el espaciado pueden cambiar, el
HMAC no coincide, y te vas a pasar una tarde pensando que el secreto está mal.
Y request.text() solo puede llamarse una vez por petición. Lee el crudo, valida, y solo entonces
parsea.
Responde rápido, procesa después
Si el procesamiento es pesado (mandar un email, llamar a otra API, generar un PDF), no lo hagas dentro del handler. Graba el evento, devuelve 200, y deja el trabajo para una cola o un cron.
El gateway te está cronometrando. Un handler que tarda 12 segundos se convierte en un retry, que se convierte en un duplicado, que se convierte en aquel informe que no cuadra.
Cinco líneas de checklist que separan un webhook que funciona en la demo de uno que funciona en diciembre, en Black Friday, con el gateway haciendo retry encima de ti.
Lee esto después
- IngenieríaPaso 8Migración de esquema sin downtimePor qué renombrar una columna rompe producción, y el patrón de cuatro pasos que convierte una migración en una tarea de martes por la tarde.Leer artículo
- IngenieríaPaso 7Los cinco patrones de resiliencia que evitan la caída en cascadaCómo una dependencia secundaria lenta derriba el sistema entero en noventa segundos, y los cinco patrones que lo impiden.Leer artículo
- IngenieríaPaso 6Saga y outbox: transacción sin transacción distribuidaNecesitas debitar en una cuenta y acreditar en otra, y están en bases de datos distintas. Los dos patrones que lo resuelven en la práctica, y qué evitar.Leer artículo