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.

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, 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
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

> 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.