# Verificar la firma (/docs/webhooks/firma)

## El header [#el-header]

```
Facturia-Signature: t=1755180415,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`Facturia-Signature` lleva un timestamp y una o más firmas HMAC-SHA256 sobre
`"{timestamp}.{cuerpo_crudo_del_request}"`, con la llave del secreto `whsec_` del endpoint, en
hexadecimal.

Durante una rotación aparecen dos valores `v1=`; acepta el payload si **cualquiera** coincide.

## El código [#el-código]

```js
import crypto from 'node:crypto';

export function verify(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=').map((s) => s.trim())),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false; // guardia de replay

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  return header
    .split(',')
    .filter((kv) => kv.trim().startsWith('v1='))
    .some((kv) =>
      crypto.timingSafeEqual(
        Buffer.from(kv.trim().slice(3)),
        Buffer.from(expected),
      ),
    );
}
```

> **Trampa — Verifica contra los bytes crudos, antes de cualquier parseo**
>
> La firma se calcula sobre el cuerpo exacto que enviamos. Si tu framework parsea el JSON y después tu
> código lo vuelve a serializar para verificar, un espacio, un orden de llaves o un escape distinto
> rompen el HMAC — y el síntoma es «la verificación siempre falla», no un error claro.
>
> En Express: `express.raw({ type: 'application/json' })` en esa ruta. En Next.js: lee el `Request`
> con `await req.text()`. En Rails: `request.raw_post`.
>
> Y rechaza cualquier cosa más vieja que 5 minutos: el timestamp es lo que impide que alguien reproduzca
> un payload firmado válido meses después.

## Un handler completo [#un-handler-completo]

```js
import express from 'express';

const app = express();

app.post(
  '/hooks/facturia',
  express.raw({ type: 'application/json' }), // los BYTES, no el objeto
  (req, res) => {
    const firma = req.header('Facturia-Signature');
    if (!verify(req.body, firma, process.env.FACTURIA_WEBHOOK_SECRET)) {
      return res.status(400).send('firma inválida');
    }

    const evento = JSON.parse(req.body.toString('utf8'));

    // Responde primero. El trabajo va después, fuera de este request.
    res.status(200).end();

    encolar(evento).catch((err) => console.error(evento.id, err));
  },
);
```

Responder `2xx` dentro de **10 segundos** es el contrato. Cualquier otra cosa — un código distinto de
2xx, un timeout, un fallo de TLS — cuenta como falla y activa el
[calendario de reintentos](/docs/webhooks/entrega).

## Rotar el secreto [#rotar-el-secreto]

```bash
curl -X POST https://api.facturia.cl/v1/webhook_endpoints/whe_01K2RF5N8Q2W/rotate_secret \
  -H "Authorization: Bearer $FACTURIA_KEY"
```

Durante las 24 horas de solapamiento, cada entrega lleva dos valores `v1=` en el header. El código de
arriba ya lo maneja: revisa todas las firmas y acepta si alguna coincide. Si tu implementación toma
solo la primera, la rotación te va a romper la mitad de las entregas.

## Deduplicación [#deduplicación]

`Facturia-Event-Id` (y el `id` del cuerpo) identifican el evento. La entrega es **al menos una vez y
sin orden garantizado**: el mismo evento puede llegarte dos veces, y `document.accepted` puede llegar
antes que `document.sent`. Guarda los ids que ya procesaste e ignora el `data.object` que sea más
viejo que el que ya tienes.
