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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdFacturia-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.