Factuarea APIDevelopers

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-3c5d6e7f8a0d
  • t — UNIX timestamp of the delivery (seconds).
  • v1 — HMAC SHA256 of the string {t}.{body} with the endpoint secret, in hex.
  • Idempotency-Key — the standard industry header, with the same value as Factuarea-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:

  1. Extract t and v1 from Factuarea-Signature.
  2. Compute signed_payload = t + "." + raw_body (raw bytes of the body, without reformatting the JSON).
  3. Compute expected = hmac_sha256(secret, signed_payload) in hex.
  4. Compare v1 == expected using constant-time comparison.
  5. 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 '', 200

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

AttemptDelay after previous
1immediate
21 minute
35 minutes
430 minutes
52 hours
612 hours
71 day
83 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 the Factuarea-Event-Id header.
  • type — the event name, as in the catalog.
  • api_version — the date-based payload version data is shaped in: the one your endpoint is pinned to, or the current one. It is always present (null only for legacy events emitted before versions were sealed).
  • created — when the event was created, as a Unix timestamp in seconds.
  • livemode and test — see below.
  • correlation_id — a UUID v7 that links the events caused by one operation, or null when there is none. The key is always present.
  • data — { "type", "object" }. object is the full snapshot of the affected resource as it was when the event happened, identical to what a GET of 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 to object, such as amount and payment_date in invoice.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_versionInvoices in data.object
2026-10-05The 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-01status: "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 2xx status → delivery delivered, whatever the body.
  • A 5xx, a 408, 425 or 429, or no answer within 10 seconds → delivery failed, next retry scheduled.
  • Any other 4xx → delivery failed_permanently, with no retry.
  • Body irrelevant. We don't process it — the delivery log keeps response_status, duration_ms and 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}/ping sends a synthetic { "type": "webhook.ping" } request to your URL and returns the result in its own response (success, http_status, response_body, error_message and duration_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: a fact_test_ key cannot call it, because it cannot carry webhooks:write.
  • POST /v1/companies/{company}/webhook-endpoints/{id}/test-event triggers a test delivery of a real catalog event type, marked "test": true in the envelope. It records a real Event (visible in GET /v1/companies/{company}/events) and queues a WebhookDelivery signed and retried exactly like a production delivery — so you validate your real handler end-to-end. Its data.object is a placeholder with a made-up id, 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:

  1. Call /rotate-secret → get the new secret.
  2. Deploy the new secret to your env.
  3. Your handler accepts either v1 during the grace window (the verification helpers above already loop over every v1).
  4. 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.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
  • task.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.

On this page

Need a hand?Contact support