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 sí 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-3c5d6e7f8a0dt— timestamp UNIX de l'entrega (segons).v1— HMAC SHA256 de la cadena{t}.{body}amb elsecretde l'endpoint, en hex.Idempotency-Key— el header estàndard de la indústria, amb el mateix valor queFactuarea-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:
- Extreu
tiv1deFactuarea-Signature. - Calcula
signed_payload = t + "." + raw_body(bytes crus del body, sense reformatar el JSON). - Calcula
expected = hmac_sha256(secret, signed_payload)en hex. - Compara
v1 == expectedfent servir comparació en temps constant. - 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 '', 200Verificar 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:
| Intent | Espera després de l'anterior |
|---|---|
| 1 | immediat |
| 2 | 1 minut |
| 3 | 5 minuts |
| 4 | 30 minuts |
| 5 | 2 hores |
| 6 | 12 hores |
| 7 | 1 dia |
| 8 | 3 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,202o204→ entregadelivered. - Qualsevol altre status → entrega
failed, es programa el següent reintent. - El body és irrellevant. No el processem — només es desen
response_statusiduration_msal 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}/pingenvia un payload sintèticwebhook.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_eventdispara una entrega de prova d'un tipus d'esdeveniment real del catàleg, marcada amb"test": truea l'envelope. Registra unEventreal (visible aGET /v1/events) i encua unWebhookDeliverysignat 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:
- Crida
/rotate_secret→ obtén el nousecret. - Desplega el nou secret al teu entorn.
- El teu handler accepta qualsevol dels
v1durant el període de gràcia (els helpers de verificació de dalt ja recorren tots elsv1). - 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 '', 200Llista 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.annulledquote.created,quote.approved,quote.rejected,quote.convertedproforma.accepted,proforma.converted_to_invoicedelivery_note.signedfacturae.face_submitted,facturae.face_status_changed,facturae.face_cancellation_requestedclient.created,client.updated