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-3c5d6e7f8a0ct— UNIX timestamp of the delivery (seconds).v1— HMAC SHA256 of the string{t}.{body}with the endpointsecret, 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:
- 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 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:
| 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 |
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,202or204→ deliverydelivered. - Any other status → delivery
failed, next retry scheduled. - Body irrelevant. We don't process it — only
response_statusandduration_msare 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:
- 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). 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 '', 200IP 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.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