Webhooks explicados: cargas firmadas y reintentos
Acabas de integrar un proveedor de pagos externo que promete notificar a tu aplicación cuando un pago se complete mediante un webhook. Pero, ¿cómo sabes que la solicitud realmente proviene de ellos y no de un atacante? ¿Y qué sucede si tu servidor está caído brevemente cuando se dispara el webhook? Sin las protecciones adecuadas, corres el riesgo de procesar eventos falsos o perder actualizaciones críticas. Este artículo explica cómo asegurar webhooks con cargas firmadas e implementar mecanismos de reintentos robustos.
¿Qué es un webhook?
Un webhook es una devolución de llamada HTTP definida por el usuario. Cuando ocurre un evento en un sistema de origen (por ejemplo, se completa un pago), envía una solicitud HTTP POST a una URL que proporciones. A diferencia del sondeo, donde preguntas repetidamente por actualizaciones, los webhooks te envían datos casi en tiempo real. Se usan ampliamente para notificaciones de eventos, como disparadores de CI/CD, mensajes de chat o cambios de estado de pago.
¿Por qué asegurar los webhooks con firmas?
Los endpoints de webhook son de acceso público, por lo que cualquiera puede enviarles una solicitud POST. Sin verificación, un atacante podría falsificar eventos, lo que llevaría a acciones no autorizadas como marcar un pedido como pagado. Firmar las cargas con un secreto compartido te permite verificar la autenticidad e integridad.
Cómo funcionan las cargas firmadas
El emisor y el receptor comparten una clave secreta. Para cada webhook, el emisor calcula una firma criptográfica (generalmente un HMAC) sobre el cuerpo de la solicitud y la incluye en un encabezado. El receptor recalcula la firma usando el mismo secreto y la compara. Si coinciden, la solicitud es genuina.
Elegir el algoritmo correcto
HMAC-SHA256 es el estándar de facto. Es rápido, seguro y está disponible en todos los lenguajes principales. Evita MD5 o SHA1, que se consideran débiles.
Paso a paso: verificar una carga firmada
- Extrae la firma del encabezado (por ejemplo,
X-Signature). - Obtén el cuerpo raw de la solicitud como cadena o arreglo de bytes. No lo parsees primero; cualquier transformación cambiará la firma.
- Calcula el HMAC usando tu secreto y el cuerpo raw.
- Compara la firma calculada con la recibida usando una comparación de tiempo constante para prevenir ataques de temporización.
Aquí tienes un ejemplo en Node.js usando Express:
const crypto = require('crypto');
const express = require('express');
const app = express();
// Usa el parser de cuerpo raw para la ruta del webhook
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
const digest = hmac.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
return res.status(401).send('Invalid signature');
}
// Procesa el evento
const event = JSON.parse(req.body);
// ... manejar evento
res.status(200).send('OK');
});
Error común: Usar un parser de cuerpo JSON antes de la verificación. El express.json() de Express parsea el cuerpo, por lo que los bytes raw se pierden. Siempre usa express.raw() para las rutas de webhook o captura el cuerpo raw por separado.
Diseñar una estrategia de reintentos
Incluso con firmas, los webhooks pueden fallar: tu servidor podría estar caído o la solicitud podría agotar el tiempo de espera. Un emisor confiable reintenta las entregas fallidas. Como receptor, también debes manejar los reintentos de manera adecuada.
Reintentos del lado del emisor
Si estás construyendo el emisor, implementa retroceso exponencial con jitter. Por ejemplo, reintenta después de 1 minuto, luego 5 minutos, luego 30 minutos, 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.
Idempotencia del lado del receptor
Debido a que los reintentos pueden causar entregas duplicadas, tu endpoint debe ser idempotente. Usa el ID del evento para rastrear los eventos procesados. Guárdalo en una base de datos con una restricción única, o usa una caché como Redis con un TTL. Si el ID del evento ya se ha visto, devuelve un 200 OK inmediatamente sin reprocesar.
Ejemplo usando un índice único de base de datos:
async function handleWebhook(event) {
try {
await db.query('INSERT INTO processed_events (event_id) VALUES ($1)', [event.id]);
} catch (err) {
if (err.code === '23505') { // violación de unicidad
return; // ya procesado
}
throw err;
}
// procesar evento
}
Mejores prácticas para endpoints de webhook
- Responde rápidamente: Devuelve un estado 2xx en unos pocos segundos. Descarga el procesamiento pesado a un trabajo en segundo plano.
- Valida la marca de tiempo: Incluye una marca de tiempo en la firma para prevenir ataques de repetición. Rechaza solicitudes con más de unos minutos de antigüedad.
- Usa HTTPS: Siempre usa TLS para proteger la carga en tránsito.
- Registra todo: Registra los webhooks entrantes, incluidos encabezados y cuerpo, para depuración.
- Monitorea fallos: Configura alertas para fallos repetidos o discrepancias de firma.
Comparación: webhooks firmados vs no firmados
| Aspecto | Webhooks firmados | Webhooks no firmados |
|---|---|---|
| Seguridad | Alta – verifica emisor e integridad | Baja – cualquiera puede falsificar solicitudes |
| Complejidad de implementación | Moderada – requiere HMAC y gestión de secretos | Baja – solo acepta POST |
| Casos de uso | Pagos, datos sensibles | Notificaciones no críticas |
Preguntas frecuentes
¿Cómo verifico una firma de webhook en Python?
Usa el módulo hmac con SHA256. Compara el digest calculado con el encabezado usando hmac.compare_digest para una comparación de tiempo constante.
¿Qué debo hacer si mi endpoint de webhook recibe un evento duplicado?
Verifica el ID del evento contra un almacén de IDs procesados. Si es un duplicado, devuelve un 200 OK y omite el procesamiento. Esto asegura la idempotencia.
¿Cuántos reintentos debe realizar un emisor de webhooks?
No hay un número fijo, pero 3–5 reintentos con retroceso exponencial (por ejemplo, 1min, 5min, 30min) es común. Después de eso, marca la entrega como fallida y alerta.
¿Listo para probar tu endpoint de webhook? Usa nuestro JSON Formatter para formatear y validar cargas durante el desarrollo.