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.
No code at all? Two apps are built on exactly this: BillOS for Zapier and BillOS for Make, both in early access by invite link. The business owner connects on the BillOS consent screen (Connect, section 15) and picks the business; each trigger then subscribes its own endpoint. They carry four instant triggers (a document issued, an invoice paid in full, a quote accepted, an expense recorded) and actions that create a document, return its PDF link and find a document.
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 (agorot), currency, fxTotalIncVat, fxRate, 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 |
document.paid | an invoice (305) or proforma (300) reached a zero balance: a receipt that names it was issued, or a later allocation closed it | documentId, docType, docNumber, totalIncVat, settledAmount, creditedAmount, paidBy.{documentId,docType,docNumber}, paidAt |
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 |
connection.created | Connect (section 15): a business owner granted your app access to a business. Sent to your app's client only. { connectionId, businessId, clientId, scopes } | |
connection.revoked | Connect: the owner disconnected your app (or you called /oauth/deauthorize). Its tokens are dead; the business leaves your key's list. Same payload | |
ita.connected | the business's owner authorized BillOS at רשות המסים (section 5.9.1); a reconnect fires it again | businessId, status, connected, connectedAt, expiresAt, daysLeft |
ita.expiring | the consent ends within 7 days: ask the owner to connect again. Once per consent | same |
ita.expired | the consent ended: documents that need a מספר הקצאה issue without one until the owner connects again | same |
ita.disconnected | the authorization was removed, by you or from a BillOS screen | same |
recurring.ran | a recurring plan ran: produced a draft, issued a document, or failed to | planId, runDate, outcome (draft | issued | failed), documentId, docNumber, error, nextRunOn |
quote.sent | a quote went out, or a revised one did | quoteId, number, partyName, totalIncVat, currency, validUntil, status, revised |
quote.accepted | the customer accepted it, on his link or by an answer the owner recorded | same, plus respondedAt, responderName |
quote.declined | the customer declined it | same, plus declineReason |
> 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)
"eventId": "…", // the event (GET /events/:id); the same across a redelivery or replay
"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. Every retry of one delivery carries the same id. A redelivery or a replay (below) is a new delivery with a new id and the same eventId: dedupe on eventId instead when the handler must act once per event however often it is sent again.
GET /webhooks/:id/deliveries shows what was sent and what your endpoint answered, the first place to look when a scenario "didn't fire".
The event log
Deliveries are per endpoint and per attempt; the event is the fact itself, recorded once under your key whether or not a webhook was subscribed at the time. After an outage on your side, page the log and act on what you missed:
curl "$BASE/events?event=document.issued&before=2026-09-06T00:00:00Z&limit=100" -H "X-Api-Key: $KEY"
# → {"ok":true,"events":[{"id":"…","event":"document.issued","businessId":"…","data":{…},"createdAt":"…"}, …]}
curl "$BASE/events/$EVENT_ID" -H "X-Api-Key: $KEY" # + deliveries: what each endpoint answered
Every delivery carries eventId, and the envelope's data is the event's data. Kept for the same retention as deliveries (30 days by default).
Redelivery
When your endpoint was down or your handler had a bug: fix it, then replay. A redelivery is a new delivery of the same event to the same endpoint (the original keeps its history), attempted now and retried on the normal schedule:
curl -X POST "$BASE/webhooks/$HOOK_ID/deliveries/$DELIVERY_ID/redeliver" -H "X-Api-Key: $KEY"
# 201 → {"ok":true,"delivery":{"id":"…","status":"pending",…},"redeliveryOf":"…"}
Refused while the endpoint is paused or auto-disabled (PATCH {"active": true} first). Dedupe on the envelope id as always: a replay has a new one.
A whole window at once. When you were down for hours rather than for one delivery, replay the range: every event in [from, to) the endpoint would have received is queued again, read from the event log (so it also covers an endpoint that did not exist yet, or was paused, at the time). What already reached you is skipped unless you ask for it:
curl -X POST "$BASE/webhooks/$HOOK_ID/replay" -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
-d '{"from":"2026-09-08T02:00:00Z","to":"2026-09-08T05:00:00Z"}'
# 201 → {"ok":true,"queued":41,"skipped":3,"truncated":false,"nextFrom":null,…}
events and businessId narrow the window inside the endpoint's own filters; includeDelivered: true re-sends what was already delivered. One call queues up to 1,000 events; a longer window answers truncated: true with nextFrom, which you pass as the next from. Rate-limited on its own (5 per minute), since each call is up to a thousand outbound requests from us.
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.