Webhooks explicados: payloads firmados y reintentos fiables
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:
- Tu endpoint es público. Cualquiera en internet puede hacer POST a él, así que cada solicitud debe autenticarse.
- La entrega es al menos una vez. Las redes fallan, los servidores se reinician y los timeouts son ambiguos — el emisor no puede saber si tu manejador se ejecutó. Así que reintenta, y puedes ver el mismo evento más de una vez.
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:
- 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á.
- Usa una comparación de tiempo constante. Un
==ingenuo filtra información de temporización. Usa la comparación segura integrada del lenguaje. - Incluye una marca de tiempo en la cadena firmada. Firmar
timestamp + "." + bodyte 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 respuesta | Comportamiento del emisor | Qué hacer en su lugar |
|---|---|---|
200 / 204 | Marcar como entregado | Correcto — confirma después de encolar |
400 / 401 | Normalmente sin reintento | Corrige el manejo de firma o payload |
429 / 503 | Reintentar con backoff | Devuélvelo cuando estés sobrecargado, no un 500 genérico |
500 / timeout | Reintentar | Mueve el trabajo lento a una cola |
Dos reglas mantienen esto saludable:
- Confirma rápido. Verifica, persiste el evento en una cola o tabla, responde
200. Haz el trabajo real de forma asíncrona. Un manejador que llama a tres APIs antes de responder se agotará por timeout y provocará entregas duplicadas. - Usa backoff exponencial con jitter. Si un emisor reintenta inmediatamente, una breve interrupción se convierte en una estampida. Un calendario como 10s, 30s, 2m, 10m, 1h con jitter aleatorio distribuye la carga.
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
- Verifica el HMAC sobre los bytes crudos con una comparación de tiempo constante.
- Rechaza marcas de tiempo más antiguas que unos minutos para detener los replays.
- Persiste el evento, luego devuelve
2xxen uno o dos segundos. - Deduplica por el ID del evento antes de hacer cualquier trabajo.
- Registra el ID del evento, el tipo y el resultado — nunca el secreto.
- Expón una forma de reenviar eventos manualmente desde tu panel o herramienta de administración.
- 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.