Using the API
Webhook events
Signed webhooks for every change. Payload shape, delivery and retries, verifying the FIA-Signature header in Node.js, and the full event list.
Webhooks tell your systems the moment something changes in your account: an invoice is sent, a client pays, a credit note is created. Instead of polling the API, you give us an HTTPS URL and we POST a signed JSON event to it. This page is the reference for the payload, delivery, signatures and event types. To create and test endpoints, see Webhooks.
The payload
Every delivery is a POST with a JSON body in the same shape:
{
"id": "0193f3c0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"type": "document.sent",
"api_version": "v1",
"created_at": "2027-01-04T10:15:00Z",
"account_id": "0193f29d-4b5c-7d6e-8f90-1a2b3c4d5e6f",
"business_id": "0193f29e-11aa-7c3b-8d2e-5a6b7c8d9e0f",
"data": {
"object": {
"id": "0193f2b4-5a6b-7c8d-9e0f-1a2b3c4d5e6f",
"type": "invoice",
"number": "INV-2027-0001",
"status": "sent",
"total_minor": "125000",
"currency": "USD"
}
}
}The event id. It is the same on every retry of this event, so use it to ignore duplicates.
The event type, such as document.sent. See the event list.
Always v1 for this API version. See Versioning.
When the change happened (RFC 3339, UTC).
The account the event belongs to.
The business the event belongs to.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
FIA-Signature | t=<unix seconds>,v1=<hex signature> |
FIA-Event-Id | The event id, so you can dedupe before parsing the body |
FIA-Event-Type | The event type |
The signature scheme and header names are proposed and may be finalised before launch.
Delivery
- Each endpoint chooses its events. You only receive the types you subscribed to. An endpoint can also be limited to one business.
- HTTPS only. Endpoint URLs must use
httpsand may not point at private or internal network addresses. Redirects are not followed (proposed). - Acknowledge with any 2xx status, quickly. Return
200as soon as you have stored the event, then do the work in the background. A delivery that times out counts as failed; the planned timeout is 10 seconds (proposed). - Retries. A failed delivery is retried with exponential backoff, about 8 attempts over roughly 24 hours (proposed). After the last attempt the delivery is marked dead and shown in the delivery log, where you can redeliver it by hand.
- Same id on every retry. Store the ids you have processed and skip repeats.
- Order is not guaranteed. A
document.paidcan arrive before thedocument.sentthat preceded it. Usecreated_at, or fetch the current state from the API, rather than relying on arrival order. - Repeated failures. An endpoint that keeps failing can be disabled automatically (proposed: after 50 failed deliveries in a row). Re-enable it once it is fixed.
Delivery statuses in the log are pending, succeeded, failed and dead. You can see them in the app and with List webhook deliveries, and redeliver with Redeliver a webhook.
Verifying signatures
Every delivery is signed with your endpoint's signing secret, shown when you create the endpoint. Always verify the signature before trusting a delivery; anyone can send a POST to a public URL.
The signature is:
v1 = HMAC-SHA256(endpoint_secret, "<t>.<raw body>")where t is the timestamp from the header and <raw body> is the exact bytes of the request body. To verify:
- Read
FIA-Signatureand split it intotand one or morev1values. - Reject the request if
tis more than 5 minutes from your current time. This stops replays of old deliveries. - Compute the HMAC-SHA256 of
t, a full stop, and the raw body, using your secret, as lowercase hex. - Compare it to each
v1value in constant time. Accept if any one matches.
Verify against the raw body. If your framework parses JSON first and you re-serialise it, the bytes change and the signature will not match.
Node.js
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyFiaSignature(rawBody, header, secret) {
if (!header) return false;
const parts = header.split(",").map((part) => part.trim().split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
if (!Number.isFinite(timestamp) || signatures.length === 0) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expectedBuffer.length && crypto.timingSafeEqual(given, expectedBuffer);
});
}Rotating the secret
When you rotate an endpoint's secret, we send both signatures for a grace period, as two v1 values: t=1767521700,v1=<old>,v1=<new>. The code above accepts a match on either, so you can deploy the new secret at your own pace. After the grace period, only the new signature is sent.
Event list
Event names are proposed and each maps to a state change in the product. Events for later releases are added to v1 when their feature ships.
Documents
| Event | When | Release |
|---|---|---|
document.created | A document (invoice, estimate, credit note, receipt) is created | Launch |
document.updated | A document is changed | Launch |
document.revised | A sent document gets a new revision | Launch |
document.sent | A document is sent to the client | Launch |
document.viewed | The client opens the document (proposed) | Launch |
document.overdue | An invoice passes its due date unpaid | Launch |
document.voided | A document is voided | Launch |
document.partially_paid | A payment covers part of the balance | Launch |
document.paid | The balance reaches zero | Launch |
credit_note.created | A credit note is issued against an invoice | Launch |
Estimates
| Event | When | Release |
|---|---|---|
estimate.accepted | The client accepts an estimate | Launch |
estimate.changes_requested | The client asks for changes | Launch |
estimate.converted | An estimate is converted to an invoice | Launch |
Payments
| Event | When | Release |
|---|---|---|
payment.recorded | A person records a payment manually | Launch |
payment.succeeded | A payment rail (such as Stripe) confirms a payment | Launch |
payment.refunded | A payment is refunded | Launch |
payment_proof.submitted | A client clicks "I have paid" and submits a proof | Launch |
Clients and items
| Event | When | Release |
|---|---|---|
client.created, client.updated | A client is created or changed | Launch |
item.created, item.updated | A saved item is created or changed | Launch |
AI agent
| Event | When | Release |
|---|---|---|
agent_run.completed | An agent run finished | Launch |
agent_run.failed | An agent run failed | Launch |
Collections
| Event | When | Release |
|---|---|---|
reminder.sent | A reminder was sent | January 2027 |
late_fee.applied | A late fee was added to an invoice | January 2027 |
late_fee.waived | A late fee was waived | January 2027 |
recurring.document_created | A recurring schedule created a document | January 2027 |
installment.due | A payment schedule instalment falls due | January 2027 |
installment.paid | An instalment is paid | January 2027 |
Saved cards and contracts
| Event | When | Release |
|---|---|---|
auto_charge.succeeded | An automatic charge on a saved card succeeded | March 2027 |
auto_charge.failed | An automatic charge failed | March 2027 |
saved_payment_method.removed | A client removed a saved card | March 2027 |
contract.sent | A contract was sent for signature | March 2027 |
contract.signed | The client signed a contract | March 2027 |
contract.sealed | A fully signed contract was sealed | March 2027 |
Time, expenses, accounting and ledger
| Event | When | Release |
|---|---|---|
time_entry.created | A time entry was logged | April 2027 |
expense.created | An expense was recorded | April 2027 |
accounting_sync.failed | A push to QuickBooks or Xero failed | May 2027 |
journal_entry.posted | A journal entry was posted | June to July 2027 |
accounting_period.locked | An accounting period was locked | June to July 2027 |
Good practice
- Respond fast, work later. Store the event, return
200, then process it from a queue. - Be idempotent. Dedupe on
id; the same event can arrive more than once. - Treat the payload as a hint. For decisions that matter, such as releasing goods when an invoice is paid, fetch the document from the API to confirm its current state.
- Ignore unknown types and fields. New ones are added over time.
- Keep the secret secret. Store it like an API key. If it leaks, rotate it.