Skip to Content
WebhooksVerificación de firma

Verificación de firma

Cada entrega de webhook incluye dos cabeceras usadas juntas:

X-Signature: sha256=9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b X-Timestamp: 1729536000

La 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

lib/verify-infraio.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):

  1. Deduplica por X-Delivery en 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-Delivery es estable a través de cada reintento de una entrega; el payload no lleva un campo event_id.)
  2. 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

  1. Dashboard → Developers → Webhooks → [endpoint] → ⋮ → Rotate Secret.
  2. Se genera un nuevo secreto y se muestra exactamente una vez. Cópialo antes de cerrar el diálogo.
  3. 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: 1729536000

Un 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íntomaCausa probable
Siempre devuelve false en devEl body se parseó JSON antes del HMAC. Lee bytes crudos primero.
Funcionó ayer, falla hoyRotaste 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 nuevosUna 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 timestampAsegúrate de comparar unix-segundos contra unix-segundos. Date.now() en JS es milisegundos: divide entre 1000.
Funciona localmente, falla en prodUn 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 firmaTu verificador está firmando solo body (esquema pre-2026). Actualiza para firmar timestamp + "." + body.
Cabecera completamente ausenteEl endpoint está registrado para un entorno distinto. Los endpoints de modo de prueba solo reciben eventos environment=test.