Webhooks explicados: firmas y reintentos
Por qué los webhooks son complicados
Los webhooks son una idea simple: en lugar de hacer polling a una API para detectar cambios, el proveedor envía eventos a tu endpoint mediante HTTP POST. Pero en la práctica, los webhooks introducen una serie de desafíos que complican a muchos desarrolladores: verificar que la solicitud realmente provino del proveedor, manejar entregas duplicadas y lidiar con fallos de red. Si ignoras esto, te arriesgas a brechas de seguridad, corrupción de datos y usuarios enojados.
Este artículo recorre los dos aspectos más críticos de los webhooks de nivel producción: payloads firmados y reintentos. Veremos cómo verificar firmas, cómo implementar reintentos con retroceso exponencial y cómo hacer que tu procesamiento sea idempotente.
Payloads firmados: demostrar autenticidad
Cualquiera puede enviar un HTTP POST a tu endpoint. Para asegurar que la solicitud provino de tu proveedor, este firma el payload con una clave secreta y envía la firma en un header. Tu tarea es recalcular la firma y compararla.
La mayoría de los proveedores usan HMAC-SHA256. La firma normalmente se calcula sobre el cuerpo crudo de la solicitud (a veces con un timestamp) y se codifica en hexadecimal. Por ejemplo, Stripe usa un header como Stripe-Signature que contiene un timestamp y una firma.
Cómo funciona HMAC
HMAC (Hash-based Message Authentication Code) combina una clave secreta con una función hash. Dada la misma clave y el mismo mensaje, la salida es determinista. Sin la clave, un atacante no puede falsificar una firma válida.
Aquí hay un ejemplo mínimo en Node.js:
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
En Python es igualmente sencillo:
import hmac
import hashlib
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Buenas prácticas para la verificación de firmas
- Usa comparación de tiempo constante para prevenir ataques de temporización. Tanto
crypto.timingSafeEqualcomohmac.compare_digestson seguros. - Verifica el cuerpo crudo, no un objeto JSON parseado. Parsear y volver a serializar puede cambiar espacios en blanco y romper la firma.
- Incluye un timestamp en los datos firmados para prevenir ataques de repetición. Rechaza solicitudes con más de unos minutos de antigüedad.
- Almacena los secretos de forma segura: usa variables de entorno o un gestor de secretos, nunca los codifiques directamente.
Manejo de timestamps y ataques de repetición
Muchos proveedores incluyen un timestamp en el header de firma. Por ejemplo, el header podría ser t=1614556800,v1=abc123.... Deberías:
- Extraer el timestamp y la firma.
- Verificar que el timestamp esté dentro de una ventana aceptable (por ejemplo, 5 minutos).
- Recalcular la firma usando el timestamp y el payload.
- Comparar las firmas de forma segura.
Esto evita que un atacante reenvíe una solicitud válida antigua.
Reintentos: manejar fallos con elegancia
Los webhooks se entregan a través de internet público, así que los fallos son inevitables. Tu endpoint podría estar caído, lento o devolver un error 500. Un proveedor robusto reintentará las entregas fallidas, pero también necesitas manejar reintentos de tu lado si eres el proveedor.
Retroceso exponencial con jitter
Al reintentar, evita golpear el endpoint con reintentos inmediatos. Usa retroceso exponencial: espera más después de cada fallo. Agrega jitter (aleatoriedad) para evitar problemas de estampida cuando muchos webhooks fallan simultáneamente.
Un calendario de reintentos típico podría verse así:
| Intento | Retraso (aprox.) |
|---|---|
| 1 | Inmediato |
| 2 | 30 segundos |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
Después de un número máximo de intentos, marca la entrega como fallida y alerta.
Idempotencia: la clave para reintentos seguros
Debido a que los reintentos pueden causar entregas duplicadas, tu manejador de webhooks debe ser idempotente. Esto significa que procesar el mismo evento varias veces debe tener el mismo efecto que procesarlo una vez.
Cómo lograr idempotencia:
- Usa un ID de evento único proporcionado en el payload (por ejemplo, el campo
id). Almacena los IDs procesados en una base de datos con una restricción única. - Diseña operaciones idempotentes, por ejemplo, usa upserts en lugar de inserts, o verifica si un recurso ya existe antes de crearlo.
- Devuelve 2xx rápidamente y procesa de forma asíncrona si el trabajo es pesado. Esto reduce la probabilidad de timeouts que disparen reintentos.
Implementar reintentos en el lado del proveedor
Si estás construyendo un sistema de webhooks, necesitas un mecanismo de entrega fiable. Aquí hay un enfoque simplificado:
- Cuando ocurre un evento, encola un trabajo de entrega con el payload y la URL de destino.
- Un worker toma el trabajo y envía el HTTP POST con un timeout (por ejemplo, 10 segundos).
- Si el estado de la respuesta no es 2xx, programa un reintento con retroceso exponencial.
- Registra los intentos y abandona después de un máximo (por ejemplo, 5 intentos).
- Registra los fallos y ofrece una forma de que los usuarios puedan reenviar manualmente.
Usa una cola persistente (por ejemplo, Redis, RabbitMQ o una tabla de base de datos) para que los reintentos sobrevivan a los reinicios.
Poniéndolo todo junto: checklist para un receptor de webhooks
Al construir un receptor de webhooks, sigue esta checklist:
- Verifica la firma usando el secreto del proveedor y comparación de tiempo constante.
- Comprueba el timestamp para prevenir ataques de repetición.
- Responde con 2xx rápidamente: acusa recibo antes de hacer procesamiento pesado.
- Procesa de forma asíncrona si es necesario, usando una cola.
- Asegura la idempotencia rastreando los IDs de evento.
- Registra todo para depuración y auditoría.
- Monitorea los fallos y configura alertas para reintentos repetidos.
Preguntas frecuentes
¿Qué es un payload de webhook firmado?
Un payload firmado incluye una firma criptográfica (normalmente HMAC-SHA256) generada con un secreto compartido. El receptor recalcula la firma para verificar que la solicitud provino del remitente esperado y no fue manipulada.
¿Por qué los webhooks necesitan reintentos?
Problemas de red, caídas del servidor o errores temporales pueden hacer que una entrega de webhook falle. Los reintentos con retroceso exponencial aseguran que el evento se entregue eventualmente sin sobrecargar al receptor.
¿Cómo evito el procesamiento duplicado de webhooks?
Usa idempotencia: incluye un ID de evento único en el payload, almacena los IDs procesados y diseña tus operaciones para que sean idempotentes (por ejemplo, upserts). De esta forma, incluso si un webhook se entrega varias veces, el efecto es el mismo.
Si necesitas inspeccionar o depurar payloads de webhooks, el JSON Formatter puede ayudarte a formatear y validar datos JSON rápidamente.