Saltar al contenido
Volver al archivo
Ingeniería4 min de lectura

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.

· Gabriel Dias
backendwebhookspostgres

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.

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.

TablaSirve para¿Se puede reconstruir?
webhook_eventsauditoría, replay, depuraciónno, es la fuente
salesinforme, dashboard, consultasí, 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.

Háblame

¿Dudas sobre el artículo? Escríbeme por WhatsApp

Sin formulario y sin lista de correo. Si no estás de acuerdo con algo que escribí, o quieres contarme cómo lo resolviste, la conversación es directa conmigo.

Abrir conversación