<!-- Source: https://docs.infraio.xyz/es/webhooks/signature-verification -->
<!-- Last updated: 2026-10-04 -->

# Verificación de firma

Cada entrega de webhook incluye dos cabeceras usadas juntas:

```http
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 pueden filtrarse en logs de proxy, capturas
de pantalla, historial del navegador y tickets de soporte. 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 que la
petición vino de InfraIO Pay.

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).

> **Warning:**
>
> 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**

```ts filename="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);
}
```

**Go**

```go
package infraio

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "strconv"
    "time"
)

const toleranceSeconds = 5 * 60

// VerifyWebhook devuelve true si y solo si sig coincide con
// HMAC-SHA256(secret, timestamp + "." + body) Y el timestamp
// está dentro de la ventana de tolerancia.
func VerifyWebhook(body []byte, sig, timestamp, secret string) bool {
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    skew := time.Now().Unix() - ts
    if skew < 0 {
        skew = -skew
    }
    if skew > toleranceSeconds {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    return subtle.ConstantTimeCompare([]byte(sig), []byte(expected)) == 1
}
```

**Python**

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    """body: bytes crudos. signature: 'sha256=<hex>'. timestamp: unix seconds."""
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
        return False

    signed = timestamp.encode() + b"." + body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)
```

**Ruby**

```ruby
require 'openssl'

TOLERANCE_SECONDS = 5 * 60

def verify_infraio(body, signature, timestamp, secret)
  ts = Integer(timestamp) rescue (return false)
  return false if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS

  signed   = "#{timestamp}.#{body}"
  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed)
  Rack::Utils.secure_compare(signature.to_s, expected)
end
```

## 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:

```http
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

```ts
// 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:

```json
{
  "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 no se
reintentan. Para 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`. Firma `timestamp + "." + body` en su lugar. |
| Cabecera completamente ausente | El endpoint está registrado para un entorno distinto. Los endpoints de modo de prueba solo reciben eventos `environment=test`. |
