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 es lliura a la teva URL mitjançant un POST
HTTPS signat.
Aquesta guia cobreix els endpoints d'un NIF, sota
/v1/companies/{company}/webhook-endpoints. Per rebre els esdeveniments d'un
compte sencer en un sol endpoint, consulta
Webhooks de compte.
No es lliuren en mode de prova. Els esdeveniments generats amb una clau
fact_test_ (sandbox) es registren però mai es lliuren als teus
endpoints externs, test-event inclòs. Una clau de proves no pot cridar els
endpoints de webhook d'un NIF, ping inclòs, perquè no pot portar
webhooks:write. POST /v1/companies/{company}/webhook-endpoints/{id}/ping,
cridat amb una clau live, només comprova que la teva URL respon: no porta
signatura. Consulta Mode de prova i sandbox. Per exercitar la verificació
de signatura i el teu handler real end-to-end, envia en el seu lloc un
test-event amb una clau live.
Crear un endpoint
curl -X POST https://api.factuarea.com/v1/companies/{company}/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, abreujada (el secret es retorna només una vegada):
{
"data": {
"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": "active",
"api_version": "2026-09-23",
"created_at": "2026-09-28T10:23:18Z",
"secret": "whsec_01HKQS5N8VR7QXJ9K3T6BWPMZA9876543210ABCDEF"
}
}enabled_events enumera noms d'esdeveniment del catàleg, un per un, com a
mínim un. Un comodí com * o invoice.* no és un nom vàlid i respon 422,
així que anomena cada esdeveniment que vulguis. El catàleg complet és a
Esdeveniments.
Signatura HMAC SHA256
Cada lliurament 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 del lliurament (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 lliuraments repetits 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
un lliurament.
Reintents
Si el teu endpoint respon amb un 5xx, un 408, 425 o 429, o no respon en
10 segons, Factuarea reintenta amb back-off exponencial. Qualsevol altre 4xx,
i una URL que Factuarea es nega a cridar per motius de seguretat, acaben el
lliurament com a failed_permanently sense reintent.
| 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 |
Cada espera varia fins a un ±10 %. Després de l'intent final el lliurament passa
a failed_permanently i deixa de reintentar-se. Els lliuraments romanen
visibles a GET /v1/companies/{company}/webhook-endpoints/{id}/deliveries
durant 90 dies des que es van crear, i els esdeveniments que hi ha al
darrere es conserven 30 dies. Pots reintentar manualment un lliurament
failed o failed_permanently mitjançant
POST /v1/companies/{company}/webhook-endpoints/{id}/deliveries/{delivery}/replay
o des del dashboard, mentre el seu esdeveniment continuï emmagatzemat: passats
30 dies el replay respon 422 business_rule_violation amb el subcode
replay_event_expired. Un lliurament en qualsevol altre estat respon el mateix
422 amb el subcode replay_delivery_not_retryable.
Body del lliurament
El body de cada lliurament és el sobre signat de l'esdeveniment:
{
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d",
"type": "invoice.paid",
"api_version": "2026-09-23",
"created": 1774002600,
"livemode": true,
"test": false,
"correlation_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a99",
"data": {
"type": "invoice.paid",
"object": {
"id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a42",
"object": "invoice",
"number": "FAC-2026-00042",
"status": "paid",
"total": "1210.00",
"currency": "EUR"
},
"amount": "1210.00",
"payment_date": "2026-03-20",
"payment_method": "bank_transfer",
"payment_reference": "TRF-2026-0042"
}
}La factura de data.object s'abreuja aquí a sis dels seus camps. Aquests són
els camps del sobre:
id— l'identificador de l'esdeveniment (UUID v7). És també el headerFactuarea-Event-Id.type— el nom de l'esdeveniment, com al catàleg.api_version— la versió de payload per data en què està construïtdata: la que té fixada el teu endpoint, o l'actual. Sempre hi és (nullnomés per a esdeveniments antics emesos abans de segellar les versions).created— quan es va crear l'esdeveniment, com a timestamp Unix en segons.livemodeitest— vegeu a sota.correlation_id— un UUID v7 que enllaça els esdeveniments provocats per una mateixa operació, onullquan no n'hi ha cap. La clau sempre és present.data—{ "type", "object" }.objectés el snapshot complet del recurs afectat tal com era quan va passar l'esdeveniment, idèntic al que retornava unGETd'aquell recurs en aquell moment, i està congelat: els canvis posteriors del recurs no reescriuen els esdeveniments passats. Les claus pròpies de l'esdeveniment van al costat d'object, comamountipayment_dateainvoice.paid.
El body no porta object: "event" ni aggregate_id: són de l'objecte
d'esdeveniment que llegeixes amb
GET /v1/companies/{company}/events, que porta els mateixos id, type,
created i data.
El camp test és sempre present: true només per a un lliurament de prova
enviat amb test-event (vegeu a sota), false per a esdeveniments reals.
És ortogonal a livemode: test indica si aquest lliurament és de prova,
mentre que livemode reflecteix l'entorn de la key (producció vs sandbox).
Un lliurament de prova es pot emetre en qualsevol dels dos, així que
livemode: true, test: true és vàlid.
Versió del payload
Cada endpoint té un api_version que fixa la forma dels snapshots de recurs que
rep a data.object. Les versions de payload admeses són 2026-05-22, 2026-10-01 i
2026-10-05 (la més recent), i l'api_version de cada sobre entregat és la versió d'aquella
entrega.
api_version de l'endpoint | Factures a data.object |
|---|---|
2026-10-05 | El vocabulari de 2026-10-01, amb tots els imports com a cadena decimal i la data de cobrament com a paid_recorded_at i paid_date (paid_at i paid_on desapareixen). La reben els endpoints creats amb la versió REST 2026-10-01 o moguts aquí amb api_version. |
2026-10-01 | status: "issued" per a una factura emesa, amb issued_at, sent_at (primer lliurament), sent_via i is_sent, sobre els imports numèrics i paid_at/paid_on de 2026-05-22. |
2026-05-22 (tot endpoint que existia abans de 2026-10-01, i els creats sense api_version amb una versió REST anterior) | El vocabulari anterior: status: "sent", sent_at igual a l'instant d'emissió, sense issued_at, is_sent ni sent_via. |
Un endpoint creat sense api_version queda fixat en crear-se. Sense capçalera
Factuarea-Version ni versió fixada a la clau, o des de l'aplicació, es queda a
2026-05-22; si la petició que el crea fa servir la versió REST 2026-10-01,
pren la versió de payload més recent (2026-10-05). Per començar amb el
vocabulari nou, crea'l amb Factuarea-Version: 2026-10-01 o amb
"api_version": "2026-10-01".
L'esdeveniment desat no es reescriu mai: la projecció es fa en entregar. Passa
un endpoint al vocabulari nou amb PATCH /v1/companies/{company}/webhook-endpoints/{webhook_endpoint}
i "api_version": "2026-10-01" quan el teu receptor accepti issued. Consulta
Versionat.
Resposta esperada
- Qualsevol status
2xx→ lliuramentdelivered, sigui quin sigui el body. - Un
5xx, un408,425o429, o cap resposta en 10 segons → lliuramentfailed, es programa el següent reintent. - Qualsevol altre
4xx→ lliuramentfailed_permanently, sense reintent. - El body és irrellevant. No el processem — el log de lliuraments desa
response_status,duration_msi una còpia truncada del body (response_body_truncated) perquè puguis depurar.
Replay des del dashboard
Developers > Webhooks > Deliveries et permet reintentar un lliurament a mà,
igual que el replay de l'API: funciona amb els lliuraments failed i
failed_permanently, mentre el seu esdeveniment continuï emmagatzemat, i encua
un lliurament nou del mateix esdeveniment amb el mateix body. El lliurament
original conserva el seu historial.
Lliuraments de prova
Dos endpoints et permeten exercitar el teu receptor sense esperar un esdeveniment real — resolen problemes diferents:
POST /v1/companies/{company}/webhook-endpoints/{id}/pingenvia una petició sintètica{ "type": "webhook.ping" }a la teva URL i retorna el resultat a la seva pròpia resposta (success,http_status,response_body,error_messageiduration_ms). No va signada, mai entra al log d'esdeveniments, no es desa com a lliurament i no és un tipus d'esdeveniment real. Fes-lo servir per confirmar que la teva URL és accessible. Necessita una clau live: una claufact_test_no el pot cridar, perquè no pot portarwebhooks:write.POST /v1/companies/{company}/webhook-endpoints/{id}/test-eventdispara un lliurament de prova d'un tipus d'esdeveniment real del catàleg, marcat amb"test": truea l'envelope. Registra unEventreal (visible aGET /v1/companies/{company}/events) i encua unWebhookDeliverysignat i reintentat exactament igual que un lliurament de producció — així valides el teu handler real end-to-end. El seudata.objectés un marcador de posició amb unidinventat, no un snapshot de les teves dades. Des del sandbox es registra i mai no es lliura.
curl -X POST https://api.factuarea.com/v1/companies/{company}/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 amb el subcode event_not_subscribed). El lliurament 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/companies/{company}/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
lliurament 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) que no canvia mai: els
reintents automàtics d'un lliurament, un replay manual i les còpies que reben
altres endpoints porten tots el mateix id — i el header Idempotency-Key
porta aquest mateix valor exacte, així que pots deduplicar des del header sense
parsejar el body. L'id és també el mateix als dos eixos: un endpoint d'empresa
i un endpoint de compte que admeten el mateix
esdeveniment d'un NIF reben el mateix id als seus dos sobres. Desa els ids que
hagis processat i respon 2xx sense actuar si ja n'has processat algun.
Deduplicar lliuraments de webhooks té la
recepta completa: una clau única sobre l'id, la signatura verificada primer,
quant de temps cal conservar els ids i què torna a enviar el mateix id.
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 lliura 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.issued,invoice.marked_sent,invoice.unsent,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.updatedtask.created,task.status_changed,task.completed,task.assigned,task_comment.created,task_time_entry.invoiced,project.created
invoice.sent és un àlies deprecat d'invoice.issued: s'emet en el mateix
instant i amb el mateix data.object, de manera que un endpoint subscrit a tots
dos rep dues entregues. Subscriu els endpoints nous a invoice.issued. Una
factura s'emet quan rep el número definitiu; passa a enviada
(invoice.marked_sent) quan el client la rep. Consulta
Treballar amb factures.
Esdeveniments de tasques i projectes
Els 21 esdeveniments de tasques, comentaris, temps imputat i projectes
s'entreguen com qualsevol altre, i el seu data.object porta el recurs complet.
task.completed és un subconjunt de task.status_changed: completar una tasca
n'entrega els dos, així que un endpoint subscrit a tots dos rep dues entregues.
Consulta Esdeveniments per veure la llista
completa i les claus extra de cadascun.
Per escoltar un sol projecte, filtra per l'id de projecte que porta cada
esdeveniment de tasca, comentari i temps: a data.object.project_id, o a
data.project_id per als esborrats i els comentaris. En els esdeveniments
project.* el projecte és data.object.id.
function projectIdOf(event) {
if (event.type.startsWith("project.")) return event.data.object.id;
return event.data.project_id ?? event.data.object.project_id;
}
const isTaskEvent = /^(task|task_comment|task_time_entry|project)\./.test(event.type);
if (isTaskEvent && projectIdOf(event) === WATCHED_PROJECT_ID) {
await enqueue({ eventId: event.id, type: event.type, object: event.data.object });
}Com amb qualsevol altre esdeveniment, deduplica per l'id de l'esdeveniment.
Esdeveniments de comandes
Subscriu-te a order.invoiced i order.refunded per rebre snapshots complets de la factura o rectificativa a data.object. Tots dos inclouen data.store.uuid, data.store.provider i data.order.external_id; les devolucions afegeixen data.refund.scope i data.refund.reason_code. Fes servir l’id de l’esdeveniment per deduplicar lliuraments i correlation_id per relacionar operacions. Consulta el processament de botigues i el trigger de n8n.
if (event.type === "order.invoiced" || event.type === "order.refunded") {
const invoice = event.data.object;
const storeId = event.data.store.uuid;
const orderId = event.data.order.external_id;
await saveOrderDocument({ eventId: event.id, storeId, orderId, invoice });
}Webhooks de compte
Tot l'anterior parla dels endpoints d'un sol NIF. Conserven el seu contracte, i continuen sent l'eina adequada quan integres un únic NIF.
Un endpoint de webhook de compte és un recurs diferent, sota /v1/accounts/{account}/webhooks. Un sol endpoint rep els esdeveniments de cada NIF del teu compte, els dels comptes que gestiones i els esdeveniments account.* del mateix compte, amb el seu propi secret de signatura, el seu propi topall per tier del pla i el seu propi historial de lliuraments. Signa, reintenta i rota el seu secret com un endpoint d'empresa, així que el codi de verificació d'aquesta pàgina li serveix sense canvis. Webhooks de compte explica què rep i qui el pot gestionar, i la seva taula d'equivalències aparella cada operació amb la seva bessona d'empresa, per exemple replay amb retry, rotate-secret amb secret i test-event amb test.
Els esdeveniments account.* no són al catàleg d'esdeveniments d'empresa: un endpoint d'empresa no s'hi pot subscriure i respon 422. Un esdeveniment d'un NIF pot arribar alhora a un endpoint d'empresa i a un de compte, i el seu id és el mateix als dos sobres: consulta Idempotència de la teva banda.
Endpoints d'empresa sobre un NIF assolit per una relació
Ningú no crea ni gestiona endpoints de webhook d'empresa sobre un NIF que el teu compte assoleix per una relació amb un altre compte, sigui perquè un altre compte te l'ha cedit o perquè pertany a un compte que gestiones. Les onze operacions de …/webhook-endpoints responen 403 forbidden_action abans de llegir o canviar res, amb qualsevol nivell d'accés, i les tools MCP d'aquestes operacions responen el mateix. Sobre els teus NIF propis conserven el seu contracte.
Els registres de peticions d'aquest NIF i el seu log d'esdeveniments, GET /v1/companies/{company}/events, estan tancats igual: consulta Llistar esdeveniments via API. Per seguir els NIF dels comptes que gestiones, fes servir un endpoint de compte amb el filtre managed o all. Webhooks de compte en té el detall.