Webhooks explicados: cargas firmadas y reintentos
Acabas de integrar un proveedor de pagos externo. La documentación dice: "Enviaremos un webhook cuando un pago se realice correctamente". Sencillo, ¿verdad? Pero luego te das cuenta: ¿cómo sabes que la solicitud realmente vino de ellos? ¿Qué pasa si tu servidor está caído cuando la envían? ¿Y si envían el mismo evento dos veces?
Los webhooks son potentes pero requieren un manejo cuidadoso. Esta guía cubre lo esencial: firmar cargas para verificar autenticidad, implementar reintentos para manejar fallos y garantizar idempotencia para evitar procesamiento duplicado.
¿Qué son los webhooks?
Un webhook es una devolución de llamada HTTP: en lugar de que tu aplicación consulte una API en busca de cambios, el proveedor envía un POST HTTP a una URL que especificas cuando ocurre un evento. Esto hace que las integraciones sean eficientes y casi en tiempo real.
Ejemplos comunes incluyen notificaciones de pago (Stripe, PayPal), disparadores de CI/CD (GitHub, GitLab) y plataformas de mensajería (Slack, Discord).
¿Por qué proteger los webhooks con firmas?
Tu endpoint de webhook es accesible públicamente. Cualquiera puede enviarle una solicitud POST. Sin verificación, un atacante podría falsificar eventos, por ejemplo, marcando un pedido no pagado como pagado.
Firmar las cargas con un secreto compartido te permite verificar que la solicitud se originó en el remitente esperado y que la carga no ha sido manipulada.
Cómo funcionan las firmas HMAC
El proveedor y tú comparten una clave secreta. Para cada webhook, el proveedor calcula un HMAC (código de autenticación de mensaje basado en hash) de la carga usando ese secreto e incluye la firma en una cabecera (por ejemplo, X-Signature). Tú recalculas el HMAC de tu lado y comparas.
HMAC garantiza integridad y autenticidad porque solo alguien con el secreto puede producir una firma válida.
Implementación de cargas firmadas
Veamos una implementación típica en Node.js (Express) y Python (Flask). Los principios se aplican a cualquier lenguaje.
Paso 1: Obtener y almacenar el secreto
Cuando registras el webhook, el proveedor te da un secreto. Guárdalo de forma segura, en variables de entorno o un gestor de secretos, nunca en el código.
Paso 2: Capturar el cuerpo sin procesar
La verificación de firma requiere los bytes exactos del cuerpo sin procesar. Si tu framework analiza el JSON antes de verificar, la reserialización puede cambiar los bytes y romper la verificación.
En Express, usa express.raw({ type: 'application/json' }) para la ruta del webhook. En Flask, accede a request.get_data() antes de analizar.
Paso 3: Calcular y comparar firmas
Usa una función de comparación de tiempo constante para prevenir ataques de temporización.
// Node.js (Express)
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Process event
const event = JSON.parse(req.body);
// ... handle event
res.status(200).send('OK');
});
En Python, usa hmac.compare_digest:
# Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Signature')
secret = os.environ['WEBHOOK_SECRET'].encode()
expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# ... handle event
return 'OK', 200
Paso 4: Manejar marcas de tiempo (opcional pero recomendado)
Algunos proveedores incluyen una marca de tiempo en la firma para prevenir ataques de repetición. Por ejemplo, el esquema de Stripe firma timestamp.payload. Deberías rechazar solicitudes con marcas de tiempo demasiado antiguas (por ejemplo, más de 5 minutos).
Implementación de reintentos
Las redes fallan. Tu servidor podría estar caído o la solicitud podría agotar el tiempo de espera. Los sistemas de webhook confiables reintentan las entregas fallidas.
Del lado del proveedor
Si estás construyendo el emisor de webhooks, implementa reintentos con retroceso exponencial. Un patrón común: reintentar después de 1 minuto, luego 5 minutos, luego 30 minutos, etc., hasta un número máximo de intentos (por ejemplo, 5). Incluye un ID de evento único en la carga para que el receptor pueda deduplicar.
Del lado del receptor
Como receptor, deberías:
- Responder rápidamente (en unos segundos) con un estado 2xx para acusar recibo. Realiza el procesamiento pesado de forma asíncrona.
- Devolver un estado no 2xx para errores que justifiquen un reintento (por ejemplo, 500). Pero ten cuidado: si devuelves 4xx, el proveedor podría no reintentar.
- Hacer tu endpoint idempotente para que las entregas duplicadas no causen problemas.
Idempotencia: manejo de eventos duplicados
Los reintentos pueden provocar eventos duplicados. Usa el ID de evento (incluido por la mayoría de los proveedores) para rastrear los eventos procesados. Antes de procesar, verifica si el ID ya se ha visto; si es así, omítelo.
Almacena los IDs procesados en una base de datos con un TTL, o usa una clave de idempotencia en tu lógica de negocio.
Pruebas de webhooks localmente
Durante el desarrollo, tu servidor local no es accesible públicamente. Herramientas como ngrok o localtunnel crean una URL pública que tuneliza a tu máquina. Alternativamente, usa un servicio de pruebas de webhooks que capture solicitudes y te permita reproducirlas.
Errores comunes y mejores prácticas
- No confíes en la carga antes de la verificación. Siempre verifica la firma primero.
- Usa HTTPS. Los webhooks transportan datos sensibles; cifra en tránsito.
- Registra todo. Registra la solicitud sin procesar (cabeceras y cuerpo) para depuración, pero redacta la información sensible.
- Monitorea fallos. Configura alertas para fallos repetidos de webhooks.
- Versiona tus eventos. Incluye una versión en la carga para que puedas evolucionar sin romper a los receptores.
Comparación: webhooks firmados vs. no firmados
| Aspecto | Firmado | No firmado |
|---|---|---|
| Seguridad | Alta: verifica origen e integridad | Baja: cualquiera puede falsificar |
| Esfuerzo de implementación | Moderado: calcular y comparar HMAC | Mínimo |
| Recomendado para | Todos los sistemas en producción | Solo redes internas y de confianza |
Preguntas frecuentes
¿Cómo verifico la firma de un webhook?
Calcula un HMAC del cuerpo sin procesar de la solicitud usando el secreto compartido, luego compáralo con la cabecera de firma usando una función de tiempo constante. Siempre usa el cuerpo sin procesar, no el JSON analizado.
¿Qué debería devolver mi endpoint de webhook?
Devuelve un código de estado 2xx rápidamente para acusar recibo. Si el procesamiento falla, devuelve un 5xx para activar un reintento, pero asegúrate de que tu endpoint sea idempotente para manejar duplicados.
¿Cuántas veces se reintentará un webhook?
Depende del proveedor. La mayoría reintenta varias veces con retroceso exponencial durante un período de horas o días. Consulta la documentación del proveedor para detalles específicos.
Al depurar entregas de webhooks, a menudo es necesario inspeccionar los registros del servidor. Si usas Nginx, el Analizador de registros de Nginx puede ayudarte a analizar los registros de acceso para detectar solicitudes fallidas o patrones inusuales.