Submit an invoice PDF and get a structured proposal back: supplier, line items, tax and totals, extracted from the document. Values the engine cannot verify come back as field objects with status “unclear” and a null value instead of guessed. Scanned or image-only PDFs — and image files — submitted through the API are refused at submission with a 422 NO_EXTRACTABLE_TEXT error: they are never silently misread, they never create a job, and they are never counted against your quota. Submit a text-layer (digitally generated) PDF instead.
Every request carries your API key as a bearer token:
Authorization: Bearer bk_test_...Test keys start with bk_test_; live keys start with bk_live_. Keep live keys secret. Requests are rate-limited to 60 per minute per key; exceeding the limit returns 429 RATE_LIMITED — see Errors below.
POST /v1/invoices:intakeMultipart file upload (form field file, PDF). Returns 202 with an invoice_id and a job_id — extraction runs asynchronously, never on the request path. An optional Idempotency-Key header makes a retry safe: the same key returns the stored response instead of processing the document again.
curl -X POST https://<your-api-host>/v1/invoices:intake \
-H "Authorization: Bearer bk_test_..." \
-H "Idempotency-Key: invoice-2026-001" \
-F "file=@invoice.pdf"
# -> 202 {"request_id": "req-...", "invoice_id": "...", "job_id": "..."}GET /v1/jobs/{job_id}Returns the job's state; when the state is done, the response carries the proposal_id — or, when the job ended without a proposal, a reason_code naming why (for example NO_EXTRACTABLE_TEXT on a job whose document carried no extractable text — API submissions of that kind are refused at intake, so the code surfaces on jobs filed through other channels).
curl https://<your-api-host>/v1/jobs/<job_id> \
-H "Authorization: Bearer bk_test_..."
# -> {"request_id": "req-...", "job_id": "...", "state": "done",
# "proposal_id": "..."}GET /v1/proposals/{proposal_id}Returns the proposal payload — the result of the extraction, with a {value, status, confidence, provenance, reason} object for every extracted field and any review warnings.
curl https://<your-api-host>/v1/proposals/<proposal_id> \
-H "Authorization: Bearer bk_test_..."
# -> the proposal payload: every extracted field as a
# {value, status, confidence, provenance, reason} object,
# line items, tax, totals and review warningsGET /v1/usageReturns your key's meter for the current calendar month: documents_used, cap, remaining, percent_used, the period_start and the period_end / reset_date (the first of next month, when the allowance resets). A valid API key is required for this endpoint even on deployments where keys are otherwise optional, because usage is per-key data.
curl https://<your-api-host>/v1/usage \
-H "Authorization: Bearer bk_test_..."
# -> {"request_id": "req-...", "key_prefix": "bk_test_...",
# "kind": "test", "period_start": "2026-10-01",
# "period_end": "2026-11-01", "reset_date": "2026-11-01",
# "documents_used": 3, "cap": 100, "remaining": 97,
# "percent_used": 3.0, "zero_retention": false}A successful intake response (202) under a key also carries a usage field — {"documents": ..., "cap": ..., "remaining": ...} — counted after that intake. When a test key reaches 80% of its cap, the intake response also carries the top-level notice FREE_TIER_80_PERCENT.
GET /healthGET /health returns a minimal status object (not the envelope): status (up, degraded or down), service and model_provider. HTTP 200 when up or degraded, 503 when down.
A machine-readable OpenAPI description of exactly these operations — schemas, the problem-details error schema, statuses and the key schemes — is served at /openapi.json on this host.
Extraction never runs on the request path. A submission moves through four states, visible via GET /v1/jobs/{job_id}:
| State | Meaning |
|---|---|
pending | Accepted and queued. |
running | A worker is processing the document. |
done | Finished. The response carries proposal_id — or, when the job ended without a proposal, a reason_code naming why (see “How verification works”). On a zero-retention job whose result has already been delivered or expired, the response carries outcome and result_purged: true instead of a proposal_id. |
failed | Processing could not complete. The response carries a structured error (code: EXTRACTION_FAILED, retryable: false) — never internal error text. A job stuck in running with no progress for 30 minutes is failed automatically with the reason code JOB_STALE_REAPED. |
Most single invoices complete in seconds in production, but there is no latency guarantee and no SLA on the free tier. Poll with backoff — start around 2 seconds, back off exponentially to at most one poll every 30 seconds — rather than polling in a tight loop; polling counts toward your per-key rate limit.
If the same document bytes are submitted again in the same scope (the same tenant scope, or the unscoped pool for keys not bound to a tenant), the API recognises the document by its content fingerprint and returns 202 with deduped: true and null ids. No new job is created, no extraction runs, and the duplicate is never counted against your quota.
Separately, an Idempotency-Key header makes a retried request safe for 24 hours: the same key returns the stored response instead of processing again, whether or not the bytes match.
After a zero-retention result has been purged, resubmitting the same document still dedupes — against a minimal tombstone record — and the recorded outcome stays visible on the job-status endpoint. A purged result is never resurrected.
Your monthly allowance (test keys: 100 documents; live keys: a per-key cap set at issuance, 5,000 by default) is metered on new accepted documents only:
| Counted (1 document each) | Never counted |
|---|---|
| A new document accepted and queued for extraction under your key | Duplicate submissions (deduped: true) |
| Idempotency-Key replays | |
Scanned / image-only PDFs and image files refused at intake (422 NO_EXTRACTABLE_TEXT) | |
Oversize uploads (413 PAYLOAD_TOO_LARGE) and other rejected requests (4xx) | |
| Jobs filed through non-API channels |
Usage is metered per calendar month and resets on the first of the next month. A successful intake response carries your meter after that intake as usage: {"documents": ..., "cap": ..., "remaining": ...}; GET /v1/usage returns the full picture at any time. When a test key reaches 80% of its cap, intake responses also carry the notice FREE_TIER_80_PERCENT. At 100%, intake stops with 429 QUOTA_EXHAUSTED — the message states your cap, the reset date, and that no charge has occurred.
GET /v1/proposals/{proposal_id} is the delivery: the extracted payload and the stored document are permanently deleted immediately after that first successful response, and any later fetch returns 404. If you never fetch, the result is deleted 24 hours after the job completes, and a fetch after that also returns 404. Store the response on your side — it cannot be recovered from us. The job-status endpoint keeps working after the purge and reports the recorded outcome and reason code.The model reads the document; deterministic code decides what is believed. Every response the API returns has passed three layers of checks:
These checks run again after the gates have discarded unverifiable values, so a result never claims success on arithmetic its own surviving fields no longer support.
Each extracted field is an object — {value, status, confidence, provenance, reason} — never a bare value (see “The proposal contract” below for the full anatomy).
0.00 is a value — zero is information, not absence.reason names the gap.value is null: the engine could not verify a value from the document. It is never a guess and never a placeholder for a number we chose not to show you: there is no verified value. Treat an unclear field as “route this field to a human”, not as zero and not as an error in your integration.When a job ends without a proposal, GET /v1/jobs/{job_id} returns a reason_code. The codes an integrator can act on:
| Reason code | What happened | What to do |
|---|---|---|
NO_EXTRACTABLE_TEXT | The document had no extractable text layer (scan / photo). On the API this is normally refused at intake with a 422 before any job exists; the code also appears on jobs filed through other channels. | Obtain a text-layer (digitally generated) PDF. |
TAX_BRIDGE_UNVERIFIED | The total only reconciled via a tax figure that is not printed digit-exact in the document (e.g. tax printed only as separate CGST/SGST components), and the derived-bridge preconditions did not all hold — no single unambiguous component pair, a component that could not be verified verbatim, or the arithmetic identity did not confirm the sum. The tax value was discarded rather than computed, so the document escalated instead of claiming success. | Route to a human; the printed components are on the document. |
VALIDATION_FR4 | Line amounts and totals do not reconcile within tolerance. | Route to a human; the document's own arithmetic disagrees with itself or was misread. |
VALIDATION_FAILED | Another deterministic validation check failed. | Route to a human. |
PROPOSAL_BUILD_FAILED | Extraction passed but a proposal could not be built from it. | Route to a human. |
JOB_STALE_REAPED | The job stopped progressing and was failed automatically after 30 minutes. | Resubmit the document. |
EXTRACTION_FAILED | Processing failed outright (surfaced as the job's error.code, not reason_code). | Resubmit; if it repeats, contact us with the request_id. |
Reason codes are part of the audit record for the job and are all we keep about the outcome in zero-retention mode — never the document's content.
Header fields (invoice_number, supplier_name, invoice_date, stated_total, tax_total, currency) and the extracted line fields (desc, qty, price, amount) are all field objects:
{
"value": "33800.00",
"status": "verified",
"confidence": 0.98,
"provenance": {"page": 1, "bbox": [412.0, 688.0, 512.0, 704.0]},
"reason": null
}value — the field's value as a string (decimal-exact for figures), or null when the status is unclear. The literal string “unclear” never appears as a value.status — verified, extracted or unclear, with the meanings in “How verification works”.confidence — a number in [0, 1] from the rubric below.provenance — {page, bbox}, where the value was read from, when the extraction recorded a location; null when it did not. A location is never derived from the value.reason — null for verified fields; otherwise a short explanation (or a reason code) naming the gap.Confidence is computed deterministically from the verification signals the pipeline recorded — the extraction confidence label, the arithmetic identities recomputed from the recorded figures, and the gates above. It is never the model's self-reported confidence: a number we cannot derive from checks would be a fabricated impression, so every score this API returns is one of these:
| Status | Signals | Confidence |
|---|---|---|
unclear | No verified value exists for the field. | 0.00 |
extracted | A value is present, recorded at low extraction confidence. | 0.30 |
extracted | A value is present, but the pipeline records no confidence label for this field (for example the supplier name or the currency). | 0.50 |
extracted | A value is present, recorded at medium extraction confidence. | 0.55 |
extracted | Recorded at high confidence, but an arithmetic cross-check does not confirm it (or cannot be recomputed from the recorded figures). | 0.80 |
verified | Recorded at high confidence; every arithmetic identity that applies confirms (or none applies, for text fields). | 0.95 |
verified | As above, and an arithmetic identity that actually exercises the figure confirms it. | 0.98 |
Route on status first; use confidence to order a review queue within a status.
tax_total is a transcription: the total tax printed as an addition below the line table. It is never computed. tax_total_verified is true when that transcription also passed the arithmetic identity (line amounts + tax total = grand total); it is false when the transcription is present but unverified, and when tax_total is unclear.
Some documents — typically Indian GST invoices — print tax only as separate CGST and SGST components, with no single tax total anywhere. When exactly one unambiguous pair of components is printed, each component is verified verbatim, and the arithmetic identity confirms their sum against the printed totals, the proposal carries a tax_bridge: {value, status: "derived", components: [{label, value, page, bbox}], identity_confirmed: true}. The bridge is a labelled derivation, built by deterministic code — tax_total itself stays unclear on such documents, with reason TAX_BRIDGE_DERIVED, because a sum is not a transcription. When any precondition fails, the document escalates with reason code TAX_BRIDGE_UNVERIFIED instead — nothing is computed into place. Under zero-retention the bridge, like the tax transcription, is not retained after processing: tax_bridge reads null on the delivered payload, tax_total's reason is TAX_BRIDGE_DERIVED, and the derivation stands in the job's reason code and the audit record.
The field names above are ours; for integrators working in the European e-invoicing vocabulary, the proposal's fields correspond to EN 16931 business terms as follows:
| Backroom field | EN 16931 | Business term |
|---|---|---|
supplier_name | BT-27 | Seller name |
invoice_number | BT-1 | Invoice number |
invoice_date | BT-2 | Invoice issue date |
currency | BT-5 | Invoice currency code |
lines[].pos | BT-126 | Invoice line identifier |
lines[].desc | BT-153 | Item name |
lines[].qty | BT-129 | Invoiced quantity |
lines[].unit | BT-130 | Quantity unit code |
lines[].price | BT-146 | Item net price |
lines[].amount | BT-131 | Invoice line net amount — see the note below |
lines_total | BT-106 | Sum of invoice line net amounts |
tax_total | BT-110 | Total VAT amount |
stated_total | BT-112 | Amount due for payment (the printed grand total) |
One honest difference: EN 16931 line amounts (BT-131) are net of VAT. Our line amount is the printed line total exactly as the document prints it — tax-inclusive on documents that print it that way. We report the printed truth rather than recomputing a net figure the document never stated.
Every error is an RFC 9457 problem-details body, served as application/problem+json. The standard members are type (a URI naming the error code — it points at that code's row in the table below), title, status (mirrors the HTTP status) and detail (a plain-language explanation). Three extension members sit alongside them: an uppercase machine-readable code, a retryable flag and the request_id. Branch on code, never on detail text:
{
"type": "https://backroom-api-wmk6.onrender.com/backroom/api/docs#errors-invalid-key",
"title": "Invalid API key",
"status": 401,
"detail": "...",
"code": "INVALID_KEY",
"retryable": false,
"request_id": "req-..."
}The problem body's retryable flag is authoritative: if it is false, do not retry the same request — fix the cause first. If it is true, retry with exponential backoff and jitter, honouring Retry-After when present, and reuse the same Idempotency-Key on intake retries so a retry can never double-process a document.
| Status | Code | Retryable | Guidance |
|---|---|---|---|
| 401 | API_KEY_REQUIRED | No | No API key was sent. Send one as Authorization: Bearer … or X-Api-Key. |
| 401 | INVALID_KEY | No | The key is unknown or revoked — the two are deliberately indistinguishable. Check the key; repeated invalid attempts from one IP are themselves rate-limited. |
| 429 | RATE_LIMITED | Yes | Too many requests — 60 per minute for your key. Wait for the period named in the Retry-After header. |
| 429 | QUOTA_EXHAUSTED | No | The key's document allowance for this period is used up. The message states the cap and the reset date, and confirms that no charge has occurred. |
| 422 | VALIDATION_ERROR | No | The request was malformed. Fix the request shape. |
| 422 | UNSUPPORTED_DOCUMENT | No | The bytes are not a readable PDF or image document. |
| 422 | NO_EXTRACTABLE_TEXT | No | The submission has no extractable text (a scanned or image-only PDF, or an image file). Submit a text-layer PDF. The submission was not counted against your quota. |
| 413 | PAYLOAD_TOO_LARGE | No | The file exceeds the 10 MiB (10,485,760 bytes) upload limit. Split or regenerate the document. |
| 404 | NOT_FOUND | No | No such job or proposal for this key — including a zero-retention result that has already been delivered once or has expired. |
| 503 | KILL_SWITCH_ENGAGED | Yes | The service is paused by the operator kill switch. Retry after a longer interval. |
| 503 | MODEL_UNAVAILABLE | Yes | The extraction service is temporarily unavailable. Back off and retry. |
| 500 | INTERNAL_ERROR | No | Something went wrong on our side. If it persists, contact us with the request_id from the problem body. |
When the monthly allowance is exhausted, intake returns 429 QUOTA_EXHAUSTED: the message states the cap and the reset date and confirms that no charge has occurred.
file. Text-layer PDF. Maximum size 10 MiB (10,485,760 bytes).422 NO_EXTRACTABLE_TEXT when you submit with an API key — free, unmetered, and no job is created. A PDF that cannot be parsed at all is not refused on suspicion; it follows the normal path and its job reports the outcome.RateLimit and RateLimit-Policy headers with the limiter's own remaining count and reset time, and a 429 carries them too (remaining 0) alongside Retry-After. GET /v1/usage is authenticated but not rate-limited, so a key that has spent its allowance can always read its meter. Tenant creation is limited to 10 per minute per client IP, and failed key validations to 30 per minute per client IP.Idempotency-Key replays are stored for 24 hours.INVALID_KEY response as an unknown one.Test keys (bk_test_) carry a 100-document monthly allowance. Test keys run against the same extraction engine as live keys; they are for integration and evaluation, not production traffic.
The free tier must NEVER silently convert into charges — charging begins only after a future explicit plan signup with a payment method.
Get a sandbox test key instantly — self-serve, no account and no email, nothing to fill in: get a test key. The key is shown once on that page and only its hash is stored, so it cannot be recovered afterwards; if you lose it, just create a new one on that page. Questions: Backroom798@gmail.com.
In zero-retention mode the PDF is stored transiently in the object store during processing and is deleted at delivery or expiry. Results are purged after first delivery or 24 hours, whichever comes first. A content-free audit record and an arrival-metadata tombstone persist by design — the audit trail keeps reason codes and check outcomes only — never invoice content or values.
Model calls go to the configured provider (production: OpenAI gpt-5-mini). Provider API data is not used for training; standard provider abuse-monitoring retention applies unless zero-data-retention terms are in place.
The API is versioned in the path (/v1/…). Within v1 we may add response fields, endpoints, error codes and reason codes at any time — clients should ignore fields they do not recognise and treat unknown error and reason codes as not retryable and route-to-a-human respectively. A breaking change — removing or renaming a field or endpoint, changing a field's type or meaning, changing an existing error code's meaning or HTTP status, removing a reason code, or tightening what input is accepted — will only ship under a new version path, with the previous version kept available for a transition period of at least 90 days.
The field-status semantics in “How verification works” are contractual: a field's value either passed the gates (verified), is present with a named gap (extracted), or is null with status unclear — and those meanings will not change within v1. (Contract v1.1 itself, shipped 2026-10-28 before any external customer integrated, replaced the pre-v1.1 field shapes and error envelope with the field objects and problem+json above; that deliberate break is the exception that proves the rule, and it is recorded in the changelog of this page's contract.)
Send feedback about the API with POST /v1/feedback (JSON). An API key is optional.