Webhooks explicados: payloads firmados y reintentos fiables

Backend2026-10-23TryQuickToolBox

Integras un sistema de pagos, el proveedor dispara un webhook y tu endpoint devuelve 200, pero tres horas después soporte reporta pedidos perdidos. O peor: el mismo evento llega cuatro veces y al cliente se le cobra dos veces. Los webhooks parecen simples (un POST HTTP a tu URL), pero lo difícil es la confianza y las garantías de entrega.

Esta guía cubre ambos aspectos: cómo verificar que un payload realmente proviene del emisor usando payloads firmados, y cómo construir un modelo de reintentos que sobreviva a caídas, timeouts y entregas duplicadas.

Qué es realmente un webhook

Un webhook es una llamada API inversa. En lugar de que tu servidor consulte GET /events cada minuto, el proveedor envía una petición HTTP a una URL que registraste cuando ocurre algo. El payload suele ser JSON, el método casi siempre es POST, y la respuesta que devuelves es una señal: 2xx significa "recibido", cualquier otra cosa significa "inténtalo de nuevo más tarde".

Esa última frase es todo el contrato. Todo lo demás — firmas, reintentos, orden — existe para hacer ese contrato seguro sobre una red no confiable.

Por qué los webhooks sin firmar son peligrosos

Tu endpoint de webhook es una URL pública. Cualquiera que la descubra puede enviar un POST con un payload falso afirmando que un pedido fue pagado. Si confías en el cuerpo sin verificación, has construido una puerta abierta a tu lógica de facturación.

Las listas de IP permitidas ayudan un poco, pero fallan detrás de CDNs, salidas serverless y cambios de IP del proveedor. La respuesta robusta es un secreto compartido más una firma HMAC sobre el cuerpo crudo de la petición.

Cómo funcionan los payloads firmados

El proveedor y tú compartís una clave secreta. En cada entrega, el proveedor calcula un HMAC (normalmente SHA-256) sobre el cuerpo crudo — a veces sobre un timestamp más el cuerpo — y envía el digest hexadecimal en una cabecera como X-Signature. Tú recalculas el mismo digest con tu copia del secreto y comparas.

Dos detalles importan más de lo que la gente espera:

Un ejemplo mínimo de verificación (Node.js)

const crypto = require('crypto');

function verifySignature(rawBody, headerSig, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // Buffer o string crudo, no JSON parseado
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(headerSig || '', 'utf8');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

El mismo patrón funciona en Python con hmac.compare_digest, en Go con hmac.Equal y en Java con MessageDigest.isEqual. El principio es idéntico en todas partes: HMAC sobre bytes crudos, comparación en tiempo constante.

Protección contra replay con timestamps

Una firma válida prueba que el payload proviene de alguien que posee el secreto, pero una petición capturada puede reproducirse indefinidamente. Los proveedores que se preocupan por esto incluyen un timestamp en la cadena firmada, por ejemplo t=1712345678,v1=<hex>. De tu lado:

  1. Extrae el timestamp de la cabecera.
  2. Rechaza la petición si es más antigua que una ventana de tolerancia (a menudo 5 minutos).
  3. Recalcula el HMAC sobre timestamp + "." + rawBody.

Esto limita el daño de una petición filtrada a unos minutos en lugar de para siempre.

Diseñar reintentos que realmente entreguen

Las redes fallan, tu servicio se reinicia, los despliegues reinician pods. Un proveedor serio reintenta las entregas fallidas según un calendario. Un receptor serio debe asumir que cada evento puede llegar más de una vez.

Reglas de reintento del lado del emisor

Reglas del lado del receptor

  1. Verifica primero, parsea después. Rechaza firmas inválidas con 401 antes de tocar tu base de datos.
  2. Persiste el evento y luego devuelve 200. Escribe el payload crudo y el ID del evento en una cola o tabla, responde de inmediato y procesa en un worker. Si el procesamiento falla, reintenta internamente.
  3. Haz los manejadores idempotentes. Usa el ID de evento del proveedor como clave única. Si ya existe, devuelve 200 sin volver a ejecutar efectos secundarios.
  4. Devuelve errores honestamente. Si tu base de datos está caída, devuelve 500 para que el proveedor reintente; no devuelvas 200 y descartes el evento silenciosamente.
RespuestaSignificado para el emisorQué ocurre después
200 / 204EntregadoSin reintento
400Malformado o rechazadoNormalmente sin reintento; registra para depurar
401Firma inválidaSin reintento; investiga el secreto
429Límite de tasaReintento con backoff
5xx / timeoutFallo temporalReintento con backoff

Idempotencia en la práctica

La idempotencia no es una palabra de moda aquí: es lo único que se interpone entre un reintento y un doble cobro. El patrón:

INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;

Si la inserción afecta a cero filas, ya has visto este evento; acusa recibo y continúa. Esto funciona incluso con entregas concurrentes porque la restricción única las serializa en la base de datos.

Para efectos secundarios que deben ocurrir exactamente una vez (enviar un correo, cobrar una tarjeta), pasa tu propia clave de idempotencia aguas abajo — la mayoría de las APIs de pago y correo aceptan una.

Orden, depuración y observabilidad

No asumas que los eventos llegan en orden. Los reintentos pueden reordenar las entregas, y los workers paralelos también. Si el orden importa, incluye un número de secuencia o timestamp en el payload y reconcilia el estado en lugar de aplicar deltas a ciegas.

Registra cada intento de entrega con el ID del evento, el resultado de la firma, el código de respuesta y el tiempo de procesamiento. Cuando un cliente dice "mi pedido nunca llegó", quieres responder en segundos: ¿se recibió el webhook?, ¿fue válida la firma?, ¿tuvo éxito el procesamiento? Una línea de log estructurada por evento es suficiente.

Para endpoints que reciben mucho tráfico, vigila las tormentas de reintentos después de un despliegue. Una forma rápida de detectarlas es observar los patrones de peticiones en tus logs de acceso — un pico de rutas idénticas desde una IP con respuestas 5xx es un bucle de reintentos clásico. Herramientas como el analizador de logs de Nginx hacen ese patrón obvio sin escribir una consulta a mano.

Errores comunes que evitar

FAQ

¿Cómo verifico la firma de un webhook si el cuerpo es JSON?

Lee el cuerpo crudo de la petición como bytes antes de cualquier parseo JSON, calcula el HMAC sobre esos bytes y compara con la cabecera usando una función de tiempo constante. Un JSON re-serializado producirá un digest diferente.

¿Cuántas veces se debe reintentar un webhook?

No hay un número universal, pero 5–10 intentos durante varias horas con backoff exponencial y jitter es común. Lo importante es que los receptores sean idempotentes, para que los reintentos sean seguros incluso cuando son frecuentes.

¿Qué código de estado debe devolver mi endpoint de webhook?

Devuelve 2xx solo después de haber almacenado el evento de forma duradera. Devuelve 401 para firmas inválidas, 400 para payloads malformados, y 5xx o 429 cuando quieras que el emisor reintente.

Si estás probando payloads de webhooks o registrando el JSON que recibes, el formateador JSON es una forma rápida de embellecer y validar un evento capturado antes de construir lógica de parseo alrededor de él.