Facturiadocs
Webhooks

Verify the signature

The format of the Facturia-Signature header, how the HMAC is computed, and the implementation mistake that voids the whole check.

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

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

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.

Rotate the secret

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

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.

On this page