Facturiadocs
Webhooks

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=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

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.

En esta página