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.
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-3c5d6e7f8a0ct— timestamp UNIX de l'entrega (segons).v1— HMAC SHA256 de la cadena{t}.{body}amb elsecretde 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:
- 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",
"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,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.
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. 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