BillOS v1
Console llms.txt OpenAPI

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

EventFires whendata highlights
document.issueda 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_readythe signed מקור + העתק PDFs exist and prints will serve themdocumentId, files.{origin,copy}.{id,slug}
document.allocation_assigneda מספר הקצאה came back from the Tax Authority after issuedocumentId, allocationNumber
document.paidan invoice (305) or proforma (300) reached a zero balance: a receipt that names it was issued, or a later allocation closed itdocumentId, docType, docNumber, totalIncVat, settledAmount, creditedAmount, paidBy.{documentId,docType,docNumber}, paidAt
expense.recordedan expense became part of the permanent fileexpenseId, amountIncVat, supplierName
export.completeda מבנה אחיד generation finishedexportRunId, fileId, recordCount
business.status_changeda business's standing moved: approved, suspended, returned to the queue, or paused/resumed by youbusinessId, approvalStatus, previousStatus, active, writesOpen
connection.createdConnect (section 15): a business owner granted your app access to a business. Sent to your app's client only. { connectionId, businessId, clientId, scopes }
connection.revokedConnect: the owner disconnected your app (or you called /oauth/deauthorize). Its tokens are dead; the business leaves your key's list. Same payload
ita.connectedthe business's owner authorized BillOS at רשות המסים (section 5.9.1); a reconnect fires it againbusinessId, status, connected, connectedAt, expiresAt, daysLeft
ita.expiringthe consent ends within 7 days: ask the owner to connect again. Once per consentsame
ita.expiredthe consent ended: documents that need a מספר הקצאה issue without one until the owner connects againsame
ita.disconnectedthe authorization was removed, by you or from a BillOS screensame
recurring.rana recurring plan ran: produced a draft, issued a document, or failed toplanId, runDate, outcome (draft | issued | failed), documentId, docNumber, error, nextRunOn
quote.senta quote went out, or a revised one didquoteId, number, partyName, totalIncVat, currency, validUntil, status, revised
quote.acceptedthe customer accepted it, on his link or by an answer the owner recordedsame, plus respondedAt, responderName
quote.declinedthe customer declined itsame, 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.