Factuarea API

Webhooks

Notificacions POST signades amb HMAC SHA256. Verificació, reintents exponencials i rotació de secret.

Els webhooks notifiquen al teu servidor quan passa un esdeveniment a Factuarea (factura pagada, pressupost acceptat, client creat, etc.) sense que hagis de fer polling. Cada esdeveniment s'entrega a la teva URL mitjançant un POST HTTPS signat.

No s'entreguen en mode de prova. Els esdeveniments generats amb una clau fact_test_ (sandbox) es registren però mai s'entreguen als teus endpoints externs. Per exercitar la verificació de signatura del teu receptor en sandbox, fes servir l'endpoint dedicat POST /v1/webhook_endpoints/{id}/ping, que que s'entrega. Consulta Mode de prova i sandbox. Per validar el teu handler real end-to-end, fes servir test_event en el seu lloc.

Crear un endpoint

curl -X POST https://api.factuarea.com/v1/webhook_endpoints \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.mycompany.com/factuarea/webhook",
    "description": "Sync with internal CRM",
    "enabled_events": [
      "invoice.created",
      "invoice.paid",
      "quote.approved"
    ]
  }'

Resposta (el secret es retorna només una vegada):

{
  "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b",
  "object": "webhook_endpoint",
  "url": "https://app.mycompany.com/factuarea/webhook",
  "description": "Sync with internal CRM",
  "enabled_events": ["invoice.created", "invoice.paid", "quote.approved"],
  "status": "enabled",
  "secret": "whsec_01HKQS5N8VR7QXJ9K3T6BWPMZA9876543210ABCDEF",
  "created_at": "2026-05-15T10:23:18Z"
}

Per subscriure't a tots els esdeveniments, passa "enabled_events": ["*"]. El catàleg complet és a Esdeveniments.

Signatura HMAC SHA256

Cada entrega inclou aquestes headers:

Factuarea-Signature: t=1747314060,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Factuarea-Event-Id: 01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d
Factuarea-Event-Type: invoice.paid
Factuarea-Delivery-Id: 01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0c
Idempotency-Key: 01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d
  • t — timestamp UNIX de l'entrega (segons).
  • v1 — HMAC SHA256 de la cadena {t}.{body} amb el secret de l'endpoint, en hex.
  • Idempotency-Key — el header estàndard de la indústria, amb el mateix valor que Factuarea-Event-Id (l'UUID v7 estable de l'esdeveniment). Fes-lo servir per deduplicar reentregues amb el header que el teu stack potser ja entén. Consulta Idempotència per la teva banda.

Fas servir un SDK oficial? Salta't l'HMAC manual de sota — tant el SDK de TypeScript com el de PHP inclouen un verificador de webhooks que fa per tu la comparació en temps constant, la tolerància del timestamp i la gestió del període de gràcia de rotació. Consulta Verificar webhooks amb el SDK. La recepta manual de sota és per a qualsevol altre llenguatge.

Per validar:

  1. Extreu t i v1 de Factuarea-Signature.
  2. Calcula signed_payload = t + "." + raw_body (bytes crus del body, sense reformatar el JSON).
  3. Calcula expected = hmac_sha256(secret, signed_payload) en hex.
  4. Compara v1 == expected fent servir comparació en temps constant.
  5. Comprova que |now - t| <= 300 (tolerància de ±5 minuts contra atacs de replay).

Durant el període de gràcia d'una rotació de secret la header porta dos valors v1 — un per cada secret actiu (t=...,v1=<current>,v1=<previous>). Accepta la petició si coincideix qualsevol dels v1. Consulta Rotació de secret.

function verifyFactuareaSignature(
    string $payload,
    string $signatureHeader,
    string $secret,
    int $toleranceSeconds = 300,
): bool {
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $signatureHeader) as $kv) {
        [$k, $v] = explode('=', $kv, 2);
        if ($k === 't') {
            $timestamp = (int) $v;
        } elseif ($k === 'v1') {
            $signatures[] = $v;
        }
    }
    if ($timestamp === null || $signatures === []) {
        return false;
    }
    if (abs(time() - $timestamp) > $toleranceSeconds) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);

    foreach ($signatures as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }

    return false;
}

// In the webhook handler:
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_FACTUAREA_SIGNATURE'] ?? '';
$secret = getenv('FACTUAREA_WEBHOOK_SECRET');

if (! verifyFactuareaSignature($payload, $header, $secret)) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true);
handleEvent($event);

http_response_code(200);
const crypto = require('crypto');

function verifySignature(payload, header, secret, tolerance = 300) {
  let timestamp = null;
  const signatures = [];
  for (const kv of header.split(',')) {
    const [k, v] = kv.split('=');
    if (k === 't') timestamp = Number(v);
    else if (k === 'v1') signatures.push(v);
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');

  return signatures.some((candidate) =>
    crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(candidate, 'hex')
    )
  );
}

// Express:
app.post('/factuarea/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString('utf8');
  if (!verifySignature(payload, req.header('Factuarea-Signature'), process.env.WHSEC)) {
    return res.status(401).end();
  }
  const event = JSON.parse(payload);
  handleEvent(event);
  res.status(200).end();
});
import hmac, hashlib, time
from flask import request, abort

def verify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    timestamp = None
    signatures = []
    for kv in header.split(','):
        k, v = kv.split('=', 1)
        if k == 't':
            timestamp = int(v)
        elif k == 'v1':
            signatures.append(v)
    if timestamp is None or not signatures:
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    signed = f"{timestamp}.".encode() + payload
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, candidate) for candidate in signatures)

@app.post('/factuarea/webhook')
def webhook():
    if not verify(request.get_data(), request.headers.get('Factuarea-Signature', ''), WHSEC):
        abort(401)
    event = request.get_json()
    handle_event(event)
    return '', 200

Verificar amb el SDK oficial

Els SDK de TypeScript i PHP embolcallen els cinc passos de dalt — comparació en temps constant, tolerància de ±5 minuts i període de gràcia de rotació — en una sola crida. Passa el body cru de la petició, la header Factuarea-Signature i el secret de l'endpoint:

import { Factuarea, WebhookSignatureError, SIGNATURE_HEADER } from "@factuarea/sdk";

const factuarea = new Factuarea({ apiKey: process.env.FACTUAREA_API_KEY! });

// Express, with express.raw({ type: "application/json" }) on the route:
app.post("/webhooks/factuarea", (req, res) => {
  try {
    const event = factuarea.webhooks.verify(
      req.body.toString("utf8"),
      req.headers[SIGNATURE_HEADER.toLowerCase()] as string,
      process.env.FACTUAREA_WEBHOOK_SECRET!,
    );
    if (event.type === "invoice.paid") { /* … */ }
    res.sendStatus(200);
  } catch (e) {
    if (e instanceof WebhookSignatureError) return res.sendStatus(400);
    throw e;
  }
});

Una tolerància personalitzada (en segons) és el quart argument opcional: factuarea.webhooks.verify(body, header, secret, { toleranceSeconds: 600 }).

use Factuarea\Sdk\Custom\Webhooks\WebhookVerifier;
use Factuarea\Sdk\Custom\Webhooks\WebhookSignatureException;

$verifier = new WebhookVerifier();
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_FACTUAREA_SIGNATURE'] ?? '';

try {
    $event = $verifier->verify($rawBody, $signature, getenv('FACTUAREA_WEBHOOK_SECRET'));
    // $event is the decoded, authenticated payload
    if (($event['type'] ?? null) === 'invoice.paid') { /* … */ }
    http_response_code(200);
} catch (WebhookSignatureException $e) {
    http_response_code(400);
}

Tots dos verificadors accepten les dues signatures v1 durant el període de gràcia d'una rotació de secret (consulta Rotació de secret), de manera que una rotació mai descarta una entrega.

Reintents

Si el teu endpoint respon amb un status que no és 2xx o no respon dins del timeout_seconds de l'endpoint (per defecte 10 s), Factuarea reintenta amb back-off exponencial:

IntentEspera després de l'anterior
1immediat
21 minut
35 minuts
430 minuts
52 hores
612 hores
71 dia
83 dies

Després de l'intent final l'entrega passa a failed_permanently i deixa de reintentar-se. Roman visible a GET /v1/webhook_endpoints/{id}/deliveries durant 30 dies, i la pots reintentar manualment mitjançant POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/replay o des del dashboard.

Body de l'entrega

El body de l'entrega és el mateix objecte d'esdeveniment:

{
  "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d",
  "object": "event",
  "type": "invoice.paid",
  "api_version": "2026-05-22",
  "livemode": true,
  "test": false,
  "data": {
    "invoice": { "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03" }
  }
}

El camp data conté una referència lleugera al recurs afectat — recupera'l des del seu propi endpoint per obtenir la representació completa. L'api_version és sempre present als esdeveniments entregats (null només per a esdeveniments antics emesos abans de segellar les versions).

El camp test és sempre present: true només per a una entrega de prova disparada des del dashboard (vegeu a sota), false per a esdeveniments reals. És ortogonal a livemode: test indica si aquesta entrega és de prova, mentre que livemode reflecteix l'entorn de la key (producció vs sandbox). Una entrega de prova es pot emetre en qualsevol dels dos, així que livemode: true, test: true és vàlid.

Resposta esperada

  • Status 200, 201, 202 o 204 → entrega delivered.
  • Qualsevol altre status → entrega failed, es programa el següent reintent.
  • El body és irrellevant. No el processem — només es desen response_status i duration_ms al log d'entregues.

Replay des del dashboard

Developers > Webhooks > Deliveries et permet reintentar manualment qualsevol entrega, fins i tot les failed_permanently. Un reintent manual reinicia el comptador i deixa una entrada al log d'auditoria.

Entregues de prova

Dos endpoints et permeten exercitar el teu receptor sense esperar un esdeveniment real — resolen problemes diferents:

  • POST /v1/webhook_endpoints/{id}/ping envia un payload sintètic webhook.ping. Mai entra al log d'esdeveniments ni és un tipus d'esdeveniment real. Fes-lo servir per confirmar l'accessibilitat i la verificació de signatura (és l'única entrega que es dispara en sandbox).
  • POST /v1/webhook_endpoints/{id}/test_event dispara una entrega de prova d'un tipus d'esdeveniment real del catàleg, marcada amb "test": true a l'envelope. Registra un Event real (visible a GET /v1/events) i encua un WebhookDelivery signat i reintentat exactament igual que una entrega de producció — així valides el teu handler real end-to-end.
curl -X POST https://api.factuarea.com/v1/webhook_endpoints/{id}/test_event \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "invoice.paid" }'

type és opcional: omet-lo per fer servir el primer esdeveniment subscrit de l'endpoint. Si l'indiques, ha de ser un dels enabled_events de l'endpoint (si no, 422 event_not_subscribed). L'entrega arriba només a aquest endpoint, mai als altres endpoints subscrits al mateix tipus.

Rotació de secret (dual-signing)

curl -X POST https://api.factuarea.com/v1/webhook_endpoints/{id}/rotate_secret \
  -H "Authorization: Bearer $FACTUAREA_API_KEY"

Retorna el nou secret. Durant 24 hores (l'instant previous_secret_valid_until de la resposta) tots dos secrets són vàlids: cada entrega es signa dues vegades a la mateixa header Factuarea-Signature — un v1 per secret (t=...,v1=<current>,v1=<previous>). Després de la finestra, el secret antic queda invalidat.

Et permet desplegar el nou secret amb zero temps d'inactivitat:

  1. Crida /rotate_secret → obtén el nou secret.
  2. Desplega el nou secret al teu entorn.
  3. El teu handler accepta qualsevol dels v1 durant el període de gràcia (els helpers de verificació de dalt ja recorren tots els v1).
  4. Després de la finestra, només el nou secret està en ús.

Idempotència de la teva banda

Cada esdeveniment inclou un camp id (UUID v7, únic). Els reintents del mateix esdeveniment sempre porten el mateix id — i el header Idempotency-Key porta aquest mateix valor exacte, així que pots deduplicar des del header sense parsejar el body. Persisteix els ids que hagis processat (taula webhook_events_processed) i retorna 200 sense actuar si ja l'has processat.

event_id = event['id']
if db.exists('webhook_events_processed', id=event_id):
    return '', 200
process(event)
db.insert('webhook_events_processed', id=event_id, processed_at=now())
return '', 200

Llista d'accés d'IP (opcional)

Si el teu endpoint corre darrere d'un firewall que filtra per IP, pots restringir les IPs d'origen mitjançant ip_allowlist en crear l'endpoint. Factuarea entrega des d'un pool d'IPs estables documentades al dashboard.

Valida la signatura HMAC, no la IP — les IPs poden canviar amb 30 dies d'avís, les signatures no.

Esdeveniments disponibles

El catàleg complet el retorna GET /v1/event-catalog i està documentat a Esdeveniments. Exemples clau:

  • invoice.created, invoice.updated, invoice.sent, invoice.paid, invoice.annulled
  • quote.created, quote.approved, quote.rejected, quote.converted
  • proforma.accepted, proforma.converted_to_invoice
  • delivery_note.signed
  • facturae.face_submitted, facturae.face_status_changed, facturae.face_cancellation_requested
  • client.created, client.updated

En aquesta pàgina