Factuarea API
Core concepts

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.

Not delivered in test mode. Events generated with a fact_test_ (sandbox) key are recorded but never delivered to your external endpoints. To exercise your receiver's signature verification in sandbox, use the dedicated POST /v1/webhook_endpoints/{id}/ping, which is delivered. See Test mode & sandbox.

Create an 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"
    ]
  }'

Response (the secret is returned only once):

{
  "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"
}

To subscribe to all events, pass "enabled_events": ["*"]. 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
  • t — UNIX timestamp of the delivery (seconds).
  • v1 — HMAC SHA256 of the string {t}.{body} with the endpoint secret, in hex.

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 responds with a status that's not 2xx or doesn't respond within the endpoint timeout_seconds (default 10 s), Factuarea retries with exponential back-off:

AttemptDelay after previous
1immediate
21 minute
35 minutes
430 minutes
52 hours
612 hours
71 day
83 days

After the final attempt the delivery moves to failed_permanently and stops retrying. It remains visible in GET /v1/webhook_endpoints/{id}/deliveries for 30 days, and you can retry it manually via POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/replay or from the dashboard.

Delivery body

The delivery body is the event object itself:

{
  "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d",
  "object": "event",
  "type": "invoice.paid",
  "api_version": "2026-05-22",
  "data": {
    "invoice": { "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03" }
  }
}

The data field holds a thin reference to the affected resource — fetch it from its own endpoint for the full representation. The api_version is always present on delivered events (null only for legacy events emitted before versions were sealed).

Expected response

  • Status 200, 201, 202 or 204 → delivery delivered.
  • Any other status → delivery failed, next retry scheduled.
  • Body irrelevant. We don't process it — only response_status and duration_ms are stored in the delivery log.

Replay from the dashboard

Developers > Webhooks > Deliveries lets you manually retry any delivery, even failed_permanently ones. A manual retry resets the counter and leaves an audit log entry.

Secret rotation (dual-signing)

curl -X POST https://api.factuarea.com/v1/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). Retries of the same event always carry the same id. Persist the ids you've processed (table webhook_events_processed) and return 200 without acting if you've already processed it.

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 '', 200

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.sent, 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

On this page