Webhooks
Polling GET /documents to notice a new invoice is the wrong shape for automation. Subscribe an HTTPS URL instead and BillOS POSTs you events as they happen: the trigger side of a Make / Zapier / n8n scenario ("catch webhook" / "custom webhook" modules) or of your own worker.
curl -X POST "$BASE/webhooks" -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"url": "https://hook.example.com/billos", "events": ["document.issued"]}'
The response includes the endpoint's secret (whsec_…). Keep it; it signs every delivery. One endpoint covers every business under your key, or pass businessId to narrow it.
Events
| Event | Fires when | data highlights |
|---|---|---|
document.issued | a document is recorded, credit notes included (a cancellation arrives as the credit note's own document.issued) | documentId, docType, docNumber, totalIncVat, partyName, externalRef |
document.pdf_ready | the signed מקור + העתק PDFs exist and prints will serve them | documentId, files.{origin,copy}.{id,slug} |
document.allocation_assigned | a מספר הקצאה came back from the Tax Authority after issue | documentId, allocationNumber |
expense.recorded | an expense became part of the permanent file | expenseId, amountIncVat, supplierName |
export.completed | a מבנה אחיד generation finished | exportRunId, fileId, recordCount |
business.status_changed | a business's standing moved: approved, suspended, returned to the queue, or paused/resumed by you | businessId, approvalStatus, previousStatus, active, writesOpen |
> business.status_changed is the one event worth wiring before you need it. Without it, an account we suspend is something your user discovers by getting a 403 mid-invoice; with it, writesOpen: false reaches your backend first and you decide what your user sees. It fires for your own deactivate/reactivate calls too, so a second integration stays in step with the one that made the change.
Payloads are summaries, not rows: the event says which thing changed; the full record is one authenticated GET away. Your automation platform's task log should never be where a tax document's contents live.
The delivery
{
"id": "del_…", // the delivery id, DEDUPE ON THIS (retries re-send it)
"event": "document.issued",
"mode": "live", // or "sandbox", matches the X-BillOS-Mode header
"businessId": "…",
"createdAt": "2026-08-30T18:00:00.000Z",
"data": { … }
}
Headers: X-BillOS-Event, X-BillOS-Delivery, X-BillOS-Mode, and the signature:
X-BillOS-Signature: t=1756576800,v1=5257a86…
v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Verify before trusting anything:
import crypto from 'node:crypto'
function verify(secret, header, rawBody) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300 // 5 min replay window
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}
Sign-verify against the raw body bytes: a re-serialized JSON object will not match.
Retries, ordering, at-least-once
Answer 2xx within 10 seconds (do the work after responding). Anything else is retried with backoff: ~1m, 5m, 30m, 2h, 12h, 24h, then the delivery is marked failed. An endpoint failing many times in a row is auto-disabled (disabledAt set); fix your side and PATCH {"active": true} to re-arm.
Delivery is at-least-once and unordered: a retry can duplicate, a slow retry can arrive after a newer event. Dedupe on the delivery id; don't infer state from arrival order.
GET /webhooks/:id/deliveries shows what was sent and what your endpoint answered, the first place to look when a scenario "didn't fire".
Testing
POST /webhooks/:id/test delivers one synthetic webhook.test event immediately: how you wire a Make/Zapier trigger without issuing anything. And webhooks fire in the sandbox (section 10) exactly as in live, with "mode": "sandbox" in the envelope: build the whole scenario against test data, then swap the key.