# Verify the signature (/en/docs/webhooks/firma)

## The header [#the-header]

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

`Facturia-Signature` carries a timestamp and one or more HMAC-SHA256 signatures over
`"{timestamp}.{raw_request_body}"`, keyed with the endpoint's `whsec_` secret, in hexadecimal.

During a rotation two `v1=` values appear; accept the payload if **either** one matches.

## The code [#the-code]

```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),
      ),
    );
}
```

> **Trap — Verify against the raw bytes, before any parsing**
>
> The signature is computed over the exact body we sent. If your framework parses the JSON and your
> code then re-serialises it in order to verify, one different space, key order or escape breaks the
> HMAC — and the symptom is "verification always fails", not a clear error.
>
> In Express: `express.raw({ type: 'application/json' })` on that route. In Next.js: read the `Request`
> with `await req.text()`. In Rails: `request.raw_post`.
>
> And reject anything older than 5 minutes: the timestamp is what stops someone from replaying a
> validly signed payload months later.

## A complete handler [#a-complete-handler]

```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));
  },
);
```

Answering `2xx` within **10 seconds** is the contract. Anything else — a non-2xx status, a timeout, a
TLS failure — counts as a failure and starts the
[retry schedule](/en/docs/webhooks/entrega).

## Rotate the secret [#rotate-the-secret]

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

During the 24-hour overlap, every delivery carries two `v1=` values in the header. The code above
already handles it: it checks every signature and accepts if any one matches. If your implementation
takes only the first, rotation will break half your deliveries.

## Deduplication [#deduplication]

`Facturia-Event-Id` (and the `id` in the body) identify the event. Delivery is **at least once and
without a guaranteed order**: the same event can reach you twice, and `document.accepted` can arrive
before `document.sent`. Keep the ids you have already processed, and ignore any `data.object` older
than the one you already hold.
