Conventions
Money is integer agorot
Every amount in every request and response is an integer in agorot (₪1 = 100 agorot). 35400 means ₪354.00. There are no floats anywhere. VAT rates are in basis points: 1800 = 18%.
Response envelope
Success: {"ok": true, ...payload}. Failure: an error body with the matching HTTP status:
{
"error": "payments 30000 != total 35400",
"reason": "payments_mismatch",
"hint": "The payments add up to 30000 agorot and the document total is 35400. PATCH the draft so the sum of payments[].amount is exactly 35400.",
"param": "payments",
"doc_url": "/errors#payments_mismatch",
"retryable": false,
"request_id": "5b0c7a52-6f0e-4c0e-9d0e-2f7f1f0a9a11"
}
Branch on reason, never on the wording of error. hint says what to change so the same call succeeds, param names the request field when the refusal is about one, and retryable is true only when sending the same request again can succeed. The Errors page lists every reason with what to do about it; it is generated from the table the API answers from.
reason | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing, unknown, revoked or expired credential |
not_found | 404 | No such resource under your key |
validation | 400 | Bad input (message says what) |
doc_type_not_allowed | 400 | This dealer type may not issue this document type |
payments_mismatch | 400 | Payments don't cover the document total |
not_draft | 409 | Tried to modify/delete an issued (immutable) document |
conflict | 409 | State conflict (e.g. numbering) |
pdf_pending | 409 | PDF not rendered yet. Retry shortly (section 5.8) |
allocation_required | 422 | Invoice over the threshold needs an allocation number (section 5.9) |
fx_rate_required | 422 | A foreign-currency document with no exchange rate to be had: pass fxRate |
approval_required | 403 | The business's account is not open: pending (never approved) or suspended (approved once, then shut by us). Issuing, expense recording and uploads are refused; the message says which. Contact us |
upgrade_required | 402 | A free plan reached what it includes (its documents for the month): the business needs a paid plan |
limit_reached | 429 | A per-business ceiling hit: the monthly document/expense ceiling BillOS set, or a pending account's daily draft cap |
rate_limited | 429 | Slow down; honor Retry-After |
quota_exceeded | 429 | Daily OCR quota exhausted. Resets at midnight Asia/Jerusalem |
too_large | 413 | Request body over the size limit (OCR images: 5MB) |
forbidden | 403 | The key lacks the required scope, or the route is not open to it (a sandbox-only route with a live key, a paused business) |
unconfigured | 503 | Service-side configuration missing |
internal | 500 | Unexpected server error. Report it to us |
provider | 502 | Internal dependency failed. Transient, safe to retry (with the same Idempotency-Key a retry can never double-create) |
Request ids
Every response carries X-Request-Id. Send your own (up to 64 chars of A-Z a-z 0-9 _ . : -) and it is echoed back, so your trace and ours share a handle; otherwise BillOS mints a UUID. An opaque provider / internal error quotes the same id as its ref. Quote it when you write to support: it goes straight to the log line.
Your own request log. GET /requests lists every call made with your key over the last 7 days, newest first: the route, the status, the server time in milliseconds, the request id, the reason of an error, the address it came from and the User-Agent it sent. No bodies are kept, and no other headers. ?status=error narrows to 4xx/5xx, ?endpoint=POST /businesses/:bid/documents to one route, ?method=, ?businessId=, ?reason= (an error's reason) and ?source= (sdk, python, cli, curl, browser, other, read off the User-Agent and returned on every row as source) narrow further, ?requestId= finds a request by the first characters of its id, and ?before= pages back. GET /requests/:requestId is one row by the id a response carried, with guide (what its error reason means, what to change, the Errors page anchor) when it failed. GET /errors?days=7 groups the window's 4xx/5xx by reason: the count, first and last seen, the endpoints they hit, a day-by-day series and the newest request id to open. GET /traffic?period=24h|7d|30d is the same traffic as a dense series (by the hour for a day, by the day otherwise) with its totals and latency. GET /usage carries the window's latency (p50, p95, max, in ms) beside the daily counts. The console's developer pages (סקירה, לוגים, שגיאות) are these, drawn.
Idempotency
POST .../documents, POST .../documents/:id/credit-note and POST .../expenses accept an idempotency key: either the Idempotency-Key header or an externalRef field in the body (your own stable id, e.g. your order id). Retrying the same call returns the same row with "deduped": true instead of creating a duplicate. Always send one, because it makes network retries and double-clicks safe. Unique per business, max 1 per document.
Dates
Dates are YYYY-MM-DD strings (or ISO datetimes; only the date part is used). The service's calendar timezone is Asia/Jerusalem.
Versioning and deprecation
The API is versioned in the path (/v1) and /v1 is stable. Additive changes ship without notice: a new optional field on a request, a new field on a response, a new endpoint, a new event or a new reason code. Build a client that ignores fields it does not know. Breaking changes never ship silently: removing or renaming a field, changing a type or a default, or retiring an endpoint is announced in the Changelog first and the old behaviour keeps working for at least six months after the announcement, with a Deprecation header on the affected responses during that period. A /v2 would live beside /v1, not replace it. The /api/books/v1 alias, where the first keys were connected, keeps answering on every host indefinitely.
Dated versions, pinned by header. Inside /v1, a change an existing client could notice becomes a new dated version rather than a new path. Send BillOS-Version: 2026-09-08 to pin the behaviour you built against; a request without the header runs on the current version, and every response carries BillOS-Version naming the one that answered it. An unknown date is refused (400 validation), never rounded to a neighbour. The versions that exist:
| Version | What changed |
|---|---|
2026-09-08 | The first dated version. Everything documented here. |
The SDKs take it as an option (apiVersion in Node, api_version in Python) and send it on every call. A behaviour that will change is announced in the Changelog with the version that changes it, so pinning the version you shipped with is how a client keeps working until you choose to move.