Webhooks explicados: cargas firmadas y reintentos
Has creado un endpoint de webhook para recibir eventos de un servicio de terceros. Todo funciona en las pruebas, pero en producción notas eventos duplicados, notificaciones perdidas y ocasionalmente preocupaciones de seguridad sobre solicitudes falsificadas. Estos son errores comunes al implementar webhooks sin las protecciones adecuadas.
Los webhooks son callbacks HTTP que permiten a los servicios notificar a tu aplicación cuando ocurre algo. A diferencia del polling, proporcionan actualizaciones en tiempo real. Pero para usarlos de manera confiable, debes abordar dos aspectos críticos: autenticidad (asegurar que la solicitud provino del remitente esperado) y entrega (manejar fallos y reintentos). Este artículo explica ambos con ejemplos de código prácticos.
Cómo funcionan los webhooks
Un webhook es un endpoint HTTP POST definido por el usuario. Cuando ocurre un evento, el servicio de origen envía una solicitud a esa URL con una carga útil que describe el evento. Por ejemplo, un procesador de pagos podría enviar un webhook cuando un cargo se completa con éxito.
El flujo es simple:
- Registras una URL de webhook con el servicio.
- El servicio envía un HTTP POST a esa URL cuando se dispara un evento.
- Tu endpoint procesa la carga útil y devuelve un estado 2xx para confirmar la recepción.
Sin una verificación adecuada, cualquiera puede enviar solicitudes a tu endpoint. Sin manejo de reintentos, los fallos transitorios pueden causar pérdida de eventos. Abordemos cada uno.
Asegurando webhooks con cargas firmadas
Para asegurar que un webhook se originó del remitente esperado, los servicios firman la carga útil usando un secreto compartido. La firma es típicamente un HMAC (código de autenticación de mensajes basado en hash) del cuerpo de la solicitud, enviado en un encabezado como X-Signature.
Cómo funciona la firma HMAC
El remitente calcula un HMAC usando una clave secreta y el cuerpo crudo de la solicitud, luego incluye la firma codificada en hexadecimal en un encabezado. Tu endpoint recalcula el HMAC con el mismo secreto y lo compara con la firma recibida. Si coinciden, la solicitud es auténtica.
Aquí hay un ejemplo mínimo en Node.js usando Express:
const crypto = require('crypto');
const express = require('express');
const app = express();
// Usar cuerpo crudo para verificación de firma
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Procesar el evento
const event = JSON.parse(payload);
// ... manejar evento
res.status(200).send('OK');
});
Puntos clave:
- Siempre usa el cuerpo crudo de la solicitud para la verificación, no un objeto analizado. El análisis puede alterar la carga útil (por ejemplo, espacios en blanco) y romper la firma.
- Usa una comparación segura en tiempo para prevenir ataques de temporización.
- Almacena los secretos de forma segura (variables de entorno, gestores de secretos) y rótalos periódicamente.
Medidas de seguridad adicionales
- Validación de marca de tiempo: Incluye una marca de tiempo en la firma (por ejemplo,
t=timestamp,v1=signature) y rechaza solicitudes con más de unos minutos de antigüedad para prevenir ataques de repetición. - Lista blanca de IP: Si el proveedor publica rangos de IP, restringe tu endpoint a esas IPs.
- HTTPS: Siempre usa HTTPS para proteger la carga útil en tránsito.
Implementando reintentos confiables
La entrega de webhooks no está garantizada. Las redes fallan, tu servidor podría estar caído o podrías devolver un estado no 2xx. Para manejar esto, los remitentes típicamente reintentan con retroceso exponencial. Como receptor, debes diseñar tu endpoint para ser idempotente y responder rápidamente.
Del lado del remitente (si envías webhooks)
Si estás construyendo un servicio que envía webhooks, implementa reintentos con retroceso. Una estrategia común:
- Reintentar en errores de red o respuestas 5xx.
- No reintentar en respuestas 4xx (excepto 429 Too Many Requests).
- Usar retroceso exponencial con jitter: esperar 1s, 2s, 4s, 8s, etc., hasta un máximo.
- Establecer un número máximo de reintentos (por ejemplo, 5) y considerar una cola de mensajes muertos para fallos.
Ejemplo de lógica de reintentos en Python:
import time
import requests
from requests.exceptions import RequestException
def send_webhook(url, payload, max_retries=5):
for attempt in range(max_retries):
try:
response = requests.post(url, json=payload, timeout=5)
if 200 <= response.status_code < 300:
return True
elif 400 <= response.status_code < 500 and response.status_code != 429:
# Error del cliente, no reintentar
return False
except RequestException:
pass
# Retroceso exponencial con jitter
delay = (2 ** attempt) + random.uniform(0, 1)
time.sleep(delay)
return False
Del lado del receptor
Tu endpoint debería:
- Confirmar rápidamente: Devolver un 2xx en unos segundos. Si el procesamiento tarda más, encola el evento y procésalo de forma asíncrona.
- Ser idempotente: Usa un ID de evento de la carga útil para deduplicar. Almacena los IDs procesados e ignora las repeticiones.
- Manejar concurrencia: Usa transacciones de base de datos o bloqueos para evitar condiciones de carrera al procesar el mismo evento concurrentemente.
Ejemplo de idempotencia usando una restricción única de base de datos:
CREATE TABLE processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Antes de procesar, intenta insertar el ID del evento. Si viola la clave primaria, omite el procesamiento.
Probando webhooks localmente
Durante el desarrollo, necesitas una URL pública para que el servicio alcance tu servidor local. Herramientas como ngrok o localtunnel exponen tu localhost. Siempre usa secretos de prueba y verifica firmas en desarrollo para detectar problemas temprano.
Comparación: Polling vs Webhooks
| Aspecto | Polling | Webhooks |
|---|---|---|
| Latencia | Depende del intervalo | Casi en tiempo real |
| Carga del servidor | Alta (solicitudes frecuentes) | Baja (solo en eventos) |
| Complejidad | Simple de implementar | Requiere verificación de firma y manejo de reintentos |
| Confiabilidad | Tú controlas los reintentos | Depende de la política de reintentos del remitente |
Preguntas frecuentes
¿Qué es una carga útil de webhook firmada?
Una carga útil firmada incluye una firma criptográfica (generalmente HMAC) generada con un secreto compartido. Permite al receptor verificar que la solicitud provino del remitente esperado y no fue manipulada.
¿Cómo manejo las entregas duplicadas de webhooks?
Haz tu endpoint idempotente usando un ID de evento único de la carga útil. Almacena los IDs procesados e ignora los duplicados. Esto asegura que los reintentos o problemas de red no causen doble procesamiento.
¿Debería reintentar entregas fallidas de webhooks?
Sí, pero solo para errores transitorios (problemas de red, 5xx). Usa retroceso exponencial con jitter y establece un límite máximo de reintentos. Evita reintentar en errores 4xx (excepto 429) ya que indican un problema con la solicitud en sí.
Al depurar cargas útiles de webhooks, a menudo necesitas inspeccionar datos JSON. El JSON Formatter puede ayudarte a formatear y validar cargas útiles JSON rápidamente.