Sandbox: test keys
Every key is either live (bk_live_…) or sandbox (bk_test_…). The API is the same in both: same base URL, same endpoints, same request and response shapes, same error codes. What differs is where the data goes.
A sandbox key answers from a separate BillOS database. Businesses, parties, documents, numbers, expenses and exports you create with it exist only there: nothing done with a test key can reach a real ledger, and nothing in a real ledger is visible to a test key. Every response says which side it came from:
X-BillOS-Mode: sandbox
Check that header on your first request. A live key pasted into a test environment issues real, numbered, immutable documents, and a test key pasted into production returns 404 for every business you created in live.
Getting a sandbox key: POST /sandboxes
The one resource in this API that takes no credentials: it is how a script, a CLI or a coding agent that holds no key yet asks for one. A person approves the request once, in the console.
curl -X POST "https://api.billos.co.il/v1/sandboxes" -H "Content-Type: application/json" -d '{"name":"my app"}'
# 202 → {"ok":true,"status":"authorization_required",
# "verifyUrl":"https://dash.billos.co.il/device?code=KQ7M-2XHD","userCode":"KQ7M-2XHD",
# "deviceCode":"…","expiresIn":600,"interval":3,"tokenUrl":"https://api.billos.co.il/v1/sandboxes/token"}
# the person opens verifyUrl, signs in and approves; meanwhile, every `interval` seconds:
curl -X POST "https://api.billos.co.il/v1/sandboxes/token" -H "Content-Type: application/json" -d '{"deviceCode":"…"}'
# 200 → {"ok":true,"status":"pending","interval":3}
# 200 → {"ok":true,"status":"approved","mode":"sandbox","apiKey":"bk_test_…","keyPrefix":"bk_test_3QfT", …} (once)
# 403 → {"reason":"authorization_denied", …} the person declined (once)
From a terminal, npx billos sandbox does all of it: it prints the link, waits, and stores the key.
- Show
verifyUrlto a person. It opens a page of its own that names youruserCodeand thenameyou sent and says what the key can and cannot do. They sign in there and approve or decline: no phone number, no enrolment, and the same sign-in opens an account for someone new. An agent shows the link to its user and waits; it never opens it itself. - A refusal is an answer. If the person declines, the poll answers
403 authorization_denied, once. Tell them it was declined; do not start another authorization by yourself. deviceCodeis the secret half. Whoever holds it collects the key, so it is never shown to anyone. The key is handed over exactly once: the next poll is404, as is a code that expired (ten minutes) or is not known. Start again withPOST /sandboxes.- The key is an ordinary sandbox key on that person's developer account: no expiry, listed in the console and revocable there. It can never be a live key, whatever the request asked. While the account has no verified phone its sandbox is capped at 5,000 requests a day (
429 rate_limited, with a hint that says so;GET /eventsis not counted); verifying a phone in the console lifts the cap. - Requests are limited per address (
429 rate_limitedwithRetry-After). An authorization you already started stays valid: keep polling it.
Anonymous sandboxes are a server option, off by default. Where the operator enabled them, POST /sandboxes answers 201 with status: "ready" and an apiKey at once, for a sandbox nobody owns yet. Such a sandbox is small and short (30 requests a minute and 500 a day, one key, no ocr, an expiresAt 7 days out), may not register webhooks or Connect apps or have documents read, and a person keeps it by opening its claimUrl, which replaces the key: the one it was born with stops, and the claimer is shown a new one. A client reads status and handles both answers.
What behaves differently
| Live | Sandbox | |
|---|---|---|
| Numbering | gapless, permanent | gapless within the sandbox business; the database may be reset |
| PDFs | signed (PAdES), marked [מקור] / [העתק] | a SANDBOX watermark on every render, a footer note that the document is not an accounting document, and not signed: issue returns warnings: ["unsigned"] |
| Frozen snapshot | the legal record | the same shape, plus software.sandbox: true |
| Israel Tax Authority (allocation numbers) | requested automatically for a connected business | never called. POST /ita/connect opens a simulated consent page instead of the Authority's, and POST /ita/simulate moves the connection to its end. An invoice over the threshold issues with the allocation_missing warning, or is refused with allocation_required when the business has requireAllocation: true, so both paths are testable |
| Files (PDFs, receipts, exports, backups) | object storage + off-site archive | stored in the sandbox database only, never archived |
| OCR | a billed AI call, ocr scope, daily cap | the same: a scan costs the same in either mode |
| Rate limit | 60 requests/min per key | the same |
Everything else (validation, totals and VAT math, idempotency, credit notes, prints, מבנה אחיד, the continuity report, backups) runs the same code and returns the same answers. If it works against the sandbox it works against live.
Fixtures: POST /sandbox/seed
Something to look at before you have written a line. With a bk_test_ key, one call creates a demo business (three customers; four issued documents covering the shapes worth seeing: a paid invoice-receipt, an unpaid tax invoice with a due date, a dollar document in English with zero-rated VAT, a cash receipt; one open draft; one recorded expense and one expense draft), through the same code the API runs, so numbering, PDFs, webhooks and the event log all behave as in live. Idempotent: calling it again returns the same business with seeded: false. A live key is refused.
curl -X POST "$BASE/sandbox/seed" -H "X-Api-Key: $TEST_KEY"
# 201 → {"ok":true,"seeded":true,"business":{…},"parties":[…],"documents":[…],"expenses":[…]}
Retention
Sandbox data is kept, but it is a test database: it is not backed up, it is not archived, and we may reset it (with notice). Do not use it as storage, and do not expect a sandbox business to exist in live.
Going live
- Get a live key. A sandbox key is self-serve (
POST /sandboxesand an approval, or the console's keys page); a live key is opened in the console after a short check of the business setup, because an issued document is permanent. Both are shown once. - Replace the key. Nothing else changes: same base URL, same code.
- Create each real business again with
POST /businesses. Ids from the sandbox do not exist in live. - Confirm
X-BillOS-Mode: liveon the first response.