Webhooks
Signed POST notifications with HMAC SHA256. Verification, exponential retries and secret rotation.
Webhooks notify your server when an event happens in Factuarea (invoice
paid, quote accepted, client created, etc.) without you having to
poll. Each event is delivered to your URL through a signed HTTPS
POST.
This guide covers the endpoints of a tax ID, under
/v1/companies/{company}/webhook-endpoints. To receive the events of a
whole account at one endpoint, see
Account-level webhooks.
Not delivered in test mode. Events generated with a fact_test_
(sandbox) key are recorded but never delivered to your external
endpoints, test-event included. A test key cannot call the webhook
endpoints of a tax ID at all, ping included, because it cannot carry
webhooks:write. POST /v1/companies/{company}/webhook-endpoints/{id}/ping,
called with a live key, only checks that your URL answers: it carries no
signature. See Test mode & sandbox. To exercise your signature
verification and your real handler end-to-end, send a
test-event with a live key instead.
Create an 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"
]
}'Response, abridged (the secret is returned only once):
{
"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 lists event names from the catalog, one by one, at least
one. A wildcard such as * or invoice.* is not a valid name and answers
422, so name every event you want. The full catalog is at
Events.
HMAC SHA256 signature
Each delivery includes these 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— UNIX timestamp of the delivery (seconds).v1— HMAC SHA256 of the string{t}.{body}with the endpointsecret, in hex.Idempotency-Key— the standard industry header, with the same value asFactuarea-Event-Id(the stable UUID v7 of the event). Use it to deduplicate redeliveries with the header your stack may already understand. See Idempotency on your side.
Using an official SDK? Skip the manual HMAC below — both the TypeScript and PHP SDKs ship a webhook verifier that does the constant-time comparison, the timestamp tolerance and the rotation-grace handling for you. See Verifying webhooks with the SDK. The manual recipe below is for any other language.
To validate:
- Extract
tandv1fromFactuarea-Signature. - Compute
signed_payload = t + "." + raw_body(raw bytes of the body, without reformatting the JSON). - Compute
expected = hmac_sha256(secret, signed_payload)in hex. - Compare
v1 == expectedusing constant-time comparison. - Check that
|now - t| <= 300(±5-minute tolerance against replay attacks).
During a secret-rotation grace window the header carries two v1
values — one per active secret (t=...,v1=<current>,v1=<previous>).
Accept the request if any v1 matches. See Secret
rotation.
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 '', 200Verify with the official SDK
The TypeScript and PHP SDKs wrap the five steps above —
constant-time comparison, the ±5-minute tolerance and the rotation-grace
window — behind one call. Pass the raw request body, the
Factuarea-Signature header and the endpoint secret:
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;
}
});A custom tolerance (in seconds) is the optional fourth argument:
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);
}Both verifiers accept both v1 signatures during a secret-rotation
grace window (see Secret rotation), so a
rotation never drops a delivery.
Retries
If your endpoint answers with a 5xx, a 408, 425 or 429, or doesn't
answer within 10 seconds, Factuarea retries with exponential back-off.
Any other 4xx, and a URL that Factuarea refuses to call for security
reasons, end the delivery as failed_permanently with no retry.
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 1 day |
| 8 | 3 days |
Each delay varies by up to ±10 %. After the final attempt the delivery
moves to failed_permanently and stops retrying. Deliveries stay visible
in GET /v1/companies/{company}/webhook-endpoints/{id}/deliveries for
90 days after they were created, and the events behind them are kept
for 30 days. You can retry a failed or failed_permanently delivery
manually via
POST /v1/companies/{company}/webhook-endpoints/{id}/deliveries/{delivery}/replay
or from the dashboard, as long as its event is still stored: after 30
days the replay answers 422 business_rule_violation with the subcode
replay_event_expired. A delivery in any other state answers the same
422 with the subcode replay_delivery_not_retryable.
Delivery body
The body of each delivery is the signed envelope of the event:
{
"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"
}
}The invoice under data.object is abridged here to six of its fields.
These are the fields of the envelope:
id— the identifier of the event (UUID v7). It is also theFactuarea-Event-Idheader.type— the event name, as in the catalog.api_version— the date-based payload versiondatais shaped in: the one your endpoint is pinned to, or the current one. It is always present (nullonly for legacy events emitted before versions were sealed).created— when the event was created, as a Unix timestamp in seconds.livemodeandtest— see below.correlation_id— a UUID v7 that links the events caused by one operation, ornullwhen there is none. The key is always present.data—{ "type", "object" }.objectis the full snapshot of the affected resource as it was when the event happened, identical to what aGETof that resource returned at that moment, and it is frozen: later changes to the resource don't rewrite past events. The event-specific keys sit next toobject, such asamountandpayment_dateininvoice.paid.
The body has no object: "event" and no aggregate_id: those belong to
the event object that you read with
GET /v1/companies/{company}/events, which carries the same id, type,
created and data.
The test field is always present: true only for a test delivery
sent with test-event (see below), false for real events. It is
orthogonal to livemode: test says whether this delivery is a
test, while livemode reflects the key environment (live vs
sandbox). A test delivery can be issued in either, so livemode: true, test: true is valid.
Payload version
Each endpoint has an api_version that fixes the shape of the resource
snapshots it receives under data.object. The supported payload versions are
2026-05-22, 2026-10-01 and 2026-10-05 (the latest), and the api_version of each delivered envelope
is the version of that delivery.
Endpoint api_version | Invoices in data.object |
|---|---|
2026-10-05 | The vocabulary of 2026-10-01, with every amount as a decimal string and the payment date as paid_recorded_at and paid_date (paid_at and paid_on are gone). Endpoints created with the REST version 2026-10-01, or moved here with api_version, receive it. |
2026-10-01 | status: "issued" for an issued invoice, with issued_at, sent_at (first delivery), sent_via and is_sent, on the numeric amounts and paid_at/paid_on of 2026-05-22. |
2026-05-22 (every endpoint that existed before 2026-10-01, and those created without api_version on an earlier REST version) | The previous vocabulary: status: "sent", sent_at equal to the issuance instant, no issued_at, is_sent or sent_via. |
An endpoint created without api_version is pinned when it is created. With no
Factuarea-Version header and no pinned key, or from the app, it stays on
2026-05-22; if the request that creates it uses the REST version
2026-10-01, it takes the latest payload version (2026-10-05). To start with
the new vocabulary, create it with Factuarea-Version: 2026-10-01 or with
"api_version": "2026-10-01".
The stored event is never rewritten: the projection happens when delivering.
Move an endpoint to the new vocabulary with
PATCH /v1/companies/{company}/webhook-endpoints/{webhook_endpoint} and "api_version": "2026-10-01"
once your receiver accepts issued. See
Versioning.
Expected response
- Any
2xxstatus → deliverydelivered, whatever the body. - A
5xx, a408,425or429, or no answer within 10 seconds → deliveryfailed, next retry scheduled. - Any other
4xx→ deliveryfailed_permanently, with no retry. - Body irrelevant. We don't process it — the delivery log keeps
response_status,duration_msand a truncated copy of the body (response_body_truncated) so you can debug.
Replay from the dashboard
Developers > Webhooks > Deliveries lets you retry a delivery by hand,
the same as the API replay: it works on failed and failed_permanently
deliveries, while their event is still stored, and it queues a new
delivery of the same event with the same body. The original delivery
keeps its history.
Test deliveries
Two endpoints let you exercise your receiver without waiting for a real event — they solve different problems:
POST /v1/companies/{company}/webhook-endpoints/{id}/pingsends a synthetic{ "type": "webhook.ping" }request to your URL and returns the result in its own response (success,http_status,response_body,error_messageandduration_ms). It is not signed, never enters the event log, isn't stored as a delivery and isn't a real event type. Use it to confirm that your URL is reachable. It needs a live key: afact_test_key cannot call it, because it cannot carrywebhooks:write.POST /v1/companies/{company}/webhook-endpoints/{id}/test-eventtriggers a test delivery of a real catalog event type, marked"test": truein the envelope. It records a realEvent(visible inGET /v1/companies/{company}/events) and queues aWebhookDeliverysigned and retried exactly like a production delivery — so you validate your real handler end-to-end. Itsdata.objectis a placeholder with a made-upid, not a snapshot of your data. From the sandbox it is recorded and never delivered.
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 is optional: omit it to use the endpoint's first subscribed event.
If given, it must be one of the endpoint's enabled_events (otherwise
422 with the subcode event_not_subscribed). The delivery reaches only this endpoint,
never the other endpoints subscribed to the same type.
Secret rotation (dual-signing)
curl -X POST https://api.factuarea.com/v1/companies/{company}/webhook-endpoints/{id}/rotate-secret \
-H "Authorization: Bearer $FACTUAREA_API_KEY"Returns the new secret. For 24 hours (the
previous_secret_valid_until instant in the response) both secrets are
valid: each delivery is signed twice in the same
Factuarea-Signature header — one v1 per secret
(t=...,v1=<current>,v1=<previous>). After the window, the old secret is
invalidated.
Lets you roll out the new secret with zero downtime:
- Call
/rotate-secret→ get the newsecret. - Deploy the new secret to your env.
- Your handler accepts either
v1during the grace window (the verification helpers above already loop over everyv1). - After the window, only the new secret is in use.
Idempotency on your side
Every event includes an id field (UUID v7, unique) that never changes:
the automatic retries of a delivery, a manual replay and the copies that
other endpoints receive all carry the same id — and the Idempotency-Key
header carries that exact value too, so you can deduplicate from the header
without parsing the body. The id is also the same at the two axes: a
company endpoint and an account endpoint that both
admit the same event of a tax ID receive the same id in their two
envelopes. Persist the ids you've processed and answer 2xx without acting
if you've already processed one.
Deduplicate webhook deliveries has the
whole recipe: a unique key on the id, the signature verified first, how
long to keep the ids, and what sends the same id again.
IP allowlist (optional)
If your endpoint runs behind a firewall that filters by IP, you can
restrict source IPs via ip_allowlist when creating the endpoint.
Factuarea delivers from a pool of stable IPs documented in the
dashboard.
Validate the HMAC signature, not the IP — IPs can change with 30 days' notice, signatures cannot.
Available events
The full catalog is returned by GET /v1/event-catalog and documented at
Events. Key examples:
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 is a deprecated alias of invoice.issued: it is emitted at
the same instant with the same data.object, so an endpoint subscribed to both
receives two deliveries. Subscribe new endpoints to invoice.issued. An invoice
is issued when it gets its definitive number; it becomes sent
(invoice.marked_sent) when the customer receives it. See
Working with invoices.
Task and project events
The 21 events of tasks, comments, logged time and projects are delivered like any
other, and their data.object carries the whole resource. task.completed is a
subset of task.status_changed: completing a task delivers both, so an endpoint
subscribed to both receives two deliveries. See
Events for the full list and the extra keys
of each one.
To listen to a single project, filter on the project id that every task, comment
and time event carries: in data.object.project_id, or in data.project_id for
deletions and comments. For project.* events the project is 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 });
}As with every other event, deduplicate by the event id.
Order events
Subscribe to order.invoiced and order.refunded to receive full invoice or corrective-invoice snapshots under data.object. Both include data.store.uuid, data.store.provider and data.order.external_id; refunds also include data.refund.scope and data.refund.reason_code. Use the event id for delivery deduplication and correlation_id for related operations. See store processing and the n8n trigger.
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 });
}Account-level webhooks
Everything above is about the endpoints of one tax ID. They keep their contract, and they are still the right tool when you integrate a single tax ID.
An account webhook endpoint is a different resource, under /v1/accounts/{account}/webhooks. One endpoint receives the events of every tax ID of your account, those of the accounts you manage and the account.* events of the account itself, with its own signing secret, its own cap per plan tier and its own delivery history. It signs, retries and rotates its secret like a company endpoint, so the verification code of this page works on it unchanged. Account webhooks explains what it receives and who can manage it, and its equivalence table pairs each operation with its company twin, for example replay with retry, rotate-secret with secret and test-event with test.
The account.* events are not in the company event catalog: a company endpoint cannot subscribe to them and answers 422. An event of a tax ID can reach a company endpoint and an account endpoint at once, and its id is the same in both envelopes: see Idempotency on your side.
Company endpoints on a tax ID reached through a relation
Nobody creates or manages company webhook endpoints on a tax ID that your account reaches through a relation with another account, whether another account granted it to you or it belongs to an account you manage. The eleven operations of …/webhook-endpoints answer 403 forbidden_action before anything is read or changed, at any access level, and the MCP tools of those operations answer the same. On your own tax IDs they keep their contract.
The request logs of that tax ID and its event log, GET /v1/companies/{company}/events, are closed the same way: see List events via API. To follow the tax IDs of the accounts you manage, use an account endpoint with the managed or all filter. Account webhooks has the detail.