Webhooks explicados: payloads firmados y reintentos fiables

Backend2026-11-05TryQuickToolBox

Tu integración funciona en staging: el proveedor de pagos dispara un evento, tu endpoint lo recibe, el pedido cambia a paid. Luego, en producción, un cliente paga dos veces, una entrega desaparece silenciosamente durante un despliegue y alguien reenvía una solicitud capturada para marcar un pedido como reembolsado. Los tres fallos comparten la misma causa raíz: el webhook se trató como un simple POST HTTP en lugar de un canal de entrega al menos una vez, crítico para la seguridad.

Esta guía cubre las dos mitades de una implementación correcta de webhooks — verificar que un payload es auténtico y hacer que la entrega sobreviva a los reintentos — con código que puedes adaptar a cualquier stack.

Qué es realmente un webhook

Un webhook es una solicitud HTTP de servidor a servidor que un sistema envía a otro cuando ocurre un evento. Invierte el modelo habitual de polling: en lugar de que tu aplicación pregunte "¿hay algo nuevo?" cada minuto, el productor empuja el evento a una URL que registraste.

De ese diseño se derivan dos propiedades, y ambas importan:

Todo lo que sigue es consecuencia de esos dos hechos.

Firmar payloads con HMAC

El enfoque estándar es un secreto compartido más un HMAC sobre el cuerpo crudo de la solicitud. El emisor calcula una firma y la pone en una cabecera; tú la recalculas y comparas. Si coinciden, el payload provino de alguien que conoce el secreto y no fue modificado en tránsito.

Tres detalles separan una implementación funcional de una rota:

  1. Firma los bytes crudos. Parsea el JSON solo después de la verificación. Volver a serializar el cuerpo cambia los espacios en blanco y el orden de las claves, y la firma nunca coincidirá.
  2. Usa una comparación de tiempo constante. Un == ingenuo filtra información de temporización. Usa la comparación segura integrada del lenguaje.
  3. Incluye una marca de tiempo en la cadena firmada. Firmar timestamp + "." + body te permite rechazar solicitudes antiguas, lo que bloquea ataques de replay.

Verificar una firma en Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const signature = headers['x-signature'];   // e.g. "t=1712345678,v1=abc123..."
  const timestamp = headers['x-timestamp'];
  if (!signature || !timestamp) return false;

  // 1. Reject stale requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  // 2. Recompute over the exact bytes received
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 3. Constant-time compare
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

En Express, esto requiere express.raw({ type: 'application/json' }) en la ruta del webhook para que el cuerpo llegue como un Buffer. Si un middleware global express.json() se ejecuta primero, el cuerpo crudo desaparece y la verificación fallará — un bug muy común.

Verificar en Python

import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Siempre devuelve 401 cuando la comprobación falla y registra el ID de la solicitud — pero nunca registres el secreto ni la firma completa.

Diseñar reintentos que realmente funcionan

Los reintentos solo son útiles si tu endpoint se comporta de forma predecible. El contrato es simple: devuelve 2xx rápidamente una vez que hayas aceptado el evento de forma duradera, y deja que todo lo demás se reintente.

Tu respuestaComportamiento del emisorQué hacer en su lugar
200 / 204Marcar como entregadoCorrecto — confirma después de encolar
400 / 401Normalmente sin reintentoCorrige el manejo de firma o payload
429 / 503Reintentar con backoffDevuélvelo cuando estés sobrecargado, no un 500 genérico
500 / timeoutReintentarMueve el trabajo lento a una cola

Dos reglas mantienen esto saludable:

Haz que los manejadores sean idempotentes

Debido a que la entrega es al menos una vez, el mismo ID de evento puede llegar dos veces — una después de un timeout en el que tu manejador realmente tuvo éxito. Almacena los IDs de eventos procesados y omite los duplicados:

INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- If 0 rows inserted, this is a duplicate: return 200 and stop.

Cuando sea posible, haz que la operación de negocio en sí sea idempotente — establece el estado del pedido a paid en lugar de incrementar un contador, y usa el ID del evento como clave de idempotencia al llamar a servicios downstream.

Una lista de verificación antes de salir a producción

  1. Verifica el HMAC sobre los bytes crudos con una comparación de tiempo constante.
  2. Rechaza marcas de tiempo más antiguas que unos minutos para detener los replays.
  3. Persiste el evento, luego devuelve 2xx en uno o dos segundos.
  4. Deduplica por el ID del evento antes de hacer cualquier trabajo.
  5. Registra el ID del evento, el tipo y el resultado — nunca el secreto.
  6. Expón una forma de reenviar eventos manualmente desde tu panel o herramienta de administración.
  7. Prueba con firmas deliberadamente rotas, entregas duplicadas y manejadores lentos.

Cuando estés depurando una integración fallida, el cuerpo crudo de la solicitud es lo que más mirarás. Pegarlo en un formateador JSON hace legibles los objetos de evento anidados sin cambiar los bytes que ya capturaste.

Preguntas frecuentes

¿Por qué falla mi verificación de firma aunque el payload parezca idéntico?

Casi siempre porque el cuerpo se parseó y se volvió a serializar antes del hash. El orden de las claves JSON, los espacios en blanco y el escape de unicode cambian durante parse/stringify, así que los bytes ya no coinciden con lo que firmó el emisor. Captura el cuerpo crudo y hashea eso.

¿Debo devolver 200 antes o después de procesar el evento?

Después de almacenarlo de forma duradera, antes de hacer el trabajo pesado. Si procesas primero, las llamadas downstream lentas causan timeouts, el emisor reintenta y obtienes duplicados. Encola el evento, responde de inmediato y procesa de forma asíncrona.

¿Es suficiente HTTPS para asegurar un endpoint de webhook?

No. TLS protege los datos en tránsito pero no prueba quién envió la solicitud — cualquiera puede hacer POST a una URL pública. La firma HMAC con un secreto compartido autentica al emisor, y una marca de tiempo firmada evita el replay de solicitudes capturadas.

Conclusión

Trata los webhooks como cualquier entrada no confiable: verifica la firma sobre los bytes crudos, comprueba la marca de tiempo, deduplica por ID de evento y confirma solo después de que el evento esté almacenado de forma segura. Hazlo, y los reintentos dejarán de ser una fuente de cargos duplicados y se convertirán en lo que debían ser — una red de seguridad.