Factuarea API
Conceptes clau

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.

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
  • t — timestamp UNIX de l'entrega (segons).
  • v1 — HMAC SHA256 de la cadena {t}.{body} amb el secret de l'endpoint, en hex.

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",
  "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).

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.

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