Verificación de firma
Cada entrega de webhook incluye dos cabeceras usadas juntas:
X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b
X-Timestamp: 1729536000La cadena hex después de sha256= es
HMAC-SHA256(secret, timestamp + "." + raw_body).
El punto es un byte literal; el timestamp es unix-segundos como
ASCII.
Por qué verificar
Las URLs de webhook se filtran. Aparecen en logs de proxy, capturas
de pantalla, historial del navegador, tickets de soporte de
partners. Sin una comprobación de firma, cualquiera que aprenda tu
URL puede POSTear un evento payment.settled falso y engañarte
para cumplir órdenes no pagadas. La verificación prueba
criptográficamente que la petición vino de InfraIO.
Incluir el timestamp dentro del payload firmado también te da protección contra replay: un atacante que capture una entrega no puede reenviarla más tarde sin que la firma se vuelva detectablemente obsoleta.
El algoritmo
signed_payload = timestamp + "." + raw_body
expected_header = "sha256=" + lowercase_hex( HMAC_SHA256(secret, signed_payload) )
constant_time_compare(expected_header, x_signature_header)Luego comprueba que el timestamp sea reciente (tolerancia típica: ±5 minutos).
Pasa siempre los bytes crudos del request body. Los frameworks
a menudo parsean JSON antes de que tu handler corra; la versión
re-serializada puede diferir de lo que enviamos (orden de claves,
espacios en blanco, formato de números) y el HMAC no coincidirá.
En Next.js App Router usa await req.text() antes de
JSON.parse. En Express, monta
express.raw({ type: 'application/json' }) solo en la ruta del
webhook.
Implementaciones
Node / TS
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyInfraIo({
body,
signature,
timestamp,
secret,
}: {
body: string; // texto crudo — NO JSON parseado
signature: string; // valor de la cabecera X-Signature
timestamp: string; // valor de la cabecera X-Timestamp (unix seconds)
secret: string; // whsec_…
}): boolean {
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) {
return false; // demasiado viejo o demasiado lejos en el futuro
}
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Protección contra replay
El timestamp dentro del payload firmado es la primera línea de defensa: un atacante que capture una entrega no puede reenviarla tras la expiración de tu ventana de tolerancia.
Por si acaso (recomendado para eventos de alto valor como
payment.settled):
- Deduplica por
X-Deliveryen una tabla con restricción única. Los replays dentro de la ventana de tolerancia se vuelven no-ops: tu handler devuelve 200 sin hacer trabajo dos veces. Esta es la misma idempotencia que quieres para reintentos legítimos. (X-Deliveryes estable a través de cada reintento de una entrega; el payload no lleva un campoevent_id.) - Usa la tolerancia más pequeña que tu desviación de reloj permita. ±5 minutos es el default recomendado y coincide con lo que la mayoría de las flotas sincronizadas por NTP pueden sostener. Más estricto está bien; por debajo de ±30 segundos empezarás a rechazar entregas legítimas en redes con NTP aguas arriba lento.
Rotando un secreto
- Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
- Se genera un nuevo secreto y se muestra exactamente una vez. Cópialo antes de cerrar el diálogo.
- Actualiza tu variable de entorno y redespliega tu verificador dentro de 24 horas.
Ventana de gracia (doble firma)
Durante las 24 horas tras una rotación, cada entrega lleva dos firmas:
X-Signature: sha256=<hmac(new_secret, ts + "." + body)>
X-Signature-Prev: sha256=<hmac(prev_secret, ts + "." + body)>
X-Timestamp: 1729536000Un verificador corriendo el secreto anterior coincide con
X-Signature-Prev; un verificador corriendo el secreto nuevo
coincide con X-Signature. Que pase cualquiera de las cabeceras es
suficiente: tu handler puede aceptar la entrega durante la
migración sin retener el deploy.
Tras cerrar la ventana de gracia, solo se envía X-Signature. El
secreto anterior deja de ser aceptado y cualquier verificador
todavía configurado con él empezará a rechazar entregas: así que
termina tu rollout dentro del presupuesto de 24 horas.
Patrón de receptor sugerido
// Acepta cualquier firma durante una ventana de gracia de rotación.
const sig = req.headers["x-signature"] ?? "";
const sigPrev = req.headers["x-signature-prev"] ?? "";
const ok = verify(body, sig, ts, CURRENT_SECRET)
|| (PREV_SECRET && verify(body, sigPrev, ts, PREV_SECRET));Puedes eliminar la rama X-Signature-Prev tan pronto como la
ventana de gracia en tu endpoint haya expirado y hayas quitado
PREV_SECRET de tu env.
Revocación de emergencia
Si un secreto se filtró públicamente y necesitas invalidar el secreto anterior inmediatamente, es decir, no quieres que el solapamiento de 24 horas mantenga viva una clave conocidamente mala: rota dos veces. La primera rotación mueve el secreto filtrado al slot prev; la segunda rotación lo empuja fuera del slot prev (reemplazándolo con la clave aún-nueva) para que el valor filtrado ya no sea aceptado.
Probando tu cableado
En el dashboard, abre Developers → Webhooks y haz clic en Send Test en el endpoint que quieras verificar. Firmamos y POSTeamos un envelope sintético a la URL síncronamente, luego mostramos el status HTTP, latencia y un fragmento de 512 bytes de tu respuesta. Forma del payload:
{
"event_id": "<uuid>",
"event_type": "webhook.test.ping",
"created_at": "2026-05-17T12:00:00Z",
"test": true,
"data": {
"merchant_id": "<your-merchant-id>",
"webhook_id": "<endpoint-id>",
"message": "Test ping from the merchant dashboard..."
}
}El test ping usa el mismo esquema de firma que las entregas de producción, así que un check verde de este botón confirma que tu verificador acepta también eventos reales. Los test pings saltan el pipeline de reintentos de RMQ: si quieres ejercitar reintentos, dispara un evento real a través del flujo relevante de la API.
Fallos comunes
| Síntoma | Causa probable |
|---|---|
| Siempre devuelve false en dev | El body se parseó JSON antes del HMAC. Lee bytes crudos primero. |
| Funcionó ayer, falla hoy | Rotaste el secreto pero la variable de entorno en este servidor aún tiene el antiguo. Redespliega con el nuevo secreto. |
| Falla para eventos antiguos, funciona para nuevos | Una entrega se encoló antes de la rotación; la firma usa el secreto antiguo y tu verificador ya no lo acepta. Espera a que el reintento la suelte o repróducela vía dashboard. |
| Off-by-one en comparación de timestamp | Asegúrate de comparar unix-segundos contra unix-segundos. Date.now() en JS es milisegundos: divide entre 1000. |
| Funciona localmente, falla en prod | Un proxy (Cloudflare, nginx) está descomprimiendo, re-codificando o quitando un newline final. Inspecciona los bytes que ve tu handler. |
| El test ping dice 401 / discrepancia de firma | Tu verificador está firmando solo body (esquema pre-2026). Actualiza para firmar timestamp + "." + body. |
| Cabecera completamente ausente | El endpoint está registrado para un entorno distinto. Los endpoints de modo de prueba solo reciben eventos environment=test. |