Verificar la firma
El formato del header Facturia-Signature, cómo se calcula el HMAC y el error de implementación que anula toda la verificación.
El header
Facturia-Signature: t=1755180415,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdFacturia-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
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
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.
Rotar el secreto
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
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.