Backroom API — developer docs

What the API does

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.

Authentication

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.

Endpoints

1. Submit an invoice — POST /v1/invoices:intake

Multipart 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": "..."}

2. Poll the job — 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": "..."}

3. Fetch the result — 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.

Warning — fetch a zero-retention result exactly once. In zero-retention mode (the production default), this GET is the delivery: the proposal payload is permanently purged immediately after that first successful response, and any later fetch returns 404. If you never fetch, the result is purged 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.
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 warnings

4. Check your usage — GET /v1/usage

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

Health — GET /health

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

How processing behaves

The job lifecycle

Extraction never runs on the request path. A submission moves through four states, visible via GET /v1/jobs/{job_id}:

StateMeaning
pendingAccepted and queued.
runningA worker is processing the document.
doneFinished. 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.
failedProcessing 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.

Duplicates are free, and dedup is automatic

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.

What counts against your quota

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

Warning — fetch a zero-retention result exactly once. In zero-retention mode (the production default), 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.

How verification works

The model reads the document; deterministic code decides what is believed. Every response the API returns has passed three layers of checks:

  1. The verbatim gate. Every number in the result must appear digit-for-digit in the document's own text. A figure the model produced that is not printed in the document is not corrected and not rounded into place — it is discarded, and the field comes back “unclear”.
  2. Arithmetic cross-checks. The line items must reconcile with the stated total under one of the two shapes real invoices use: line amounts plus the tax total equal the grand total, or the line amounts already include tax and equal the grand total (tolerance: the larger of 0.5% of the total or 0.05). A document whose numbers do not reconcile is not forced through; it ends without a proposal, with a reason code.
  3. Role and sanity gates. A printed number is only used for the job its label gives it — a discount figure must trace to a figure the document labels as a discount, not merely exist somewhere on the page. Dates must not be in the future, totals must not be negative, and a document whose lines are all negative is treated as the credit-note shape it is, not as an invoice.

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.

What a field value means

Each extracted field is an object — {value, status, confidence, provenance, reason} — never a bare value (see “The proposal contract” below for the full anatomy).

Reason codes

When a job ends without a proposal, GET /v1/jobs/{job_id} returns a reason_code. The codes an integrator can act on:

Reason codeWhat happenedWhat to do
NO_EXTRACTABLE_TEXTThe 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_UNVERIFIEDThe 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_FR4Line amounts and totals do not reconcile within tolerance.Route to a human; the document's own arithmetic disagrees with itself or was misread.
VALIDATION_FAILEDAnother deterministic validation check failed.Route to a human.
PROPOSAL_BUILD_FAILEDExtraction passed but a proposal could not be built from it.Route to a human.
JOB_STALE_REAPEDThe job stopped progressing and was failed automatically after 30 minutes.Resubmit the document.
EXTRACTION_FAILEDProcessing 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.

The proposal contract

Every field is an object

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
}

The confidence rubric

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:

StatusSignalsConfidence
unclearNo verified value exists for the field.0.00
extractedA value is present, recorded at low extraction confidence.0.30
extractedA value is present, but the pipeline records no confidence label for this field (for example the supplier name or the currency).0.50
extractedA value is present, recorded at medium extraction confidence.0.55
extractedRecorded at high confidence, but an arithmetic cross-check does not confirm it (or cannot be recomputed from the recorded figures).0.80
verifiedRecorded at high confidence; every arithmetic identity that applies confirms (or none applies, for text fields).0.95
verifiedAs 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: tax_total, tax_total_verified, and the derived bridge

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.

Field mapping (EN 16931)

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 fieldEN 16931Business term
supplier_nameBT-27Seller name
invoice_numberBT-1Invoice number
invoice_dateBT-2Invoice issue date
currencyBT-5Invoice currency code
lines[].posBT-126Invoice line identifier
lines[].descBT-153Item name
lines[].qtyBT-129Invoiced quantity
lines[].unitBT-130Quantity unit code
lines[].priceBT-146Item net price
lines[].amountBT-131Invoice line net amount — see the note below
lines_totalBT-106Sum of invoice line net amounts
tax_totalBT-110Total VAT amount
stated_totalBT-112Amount 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.

Errors

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.

StatusCodeRetryableGuidance
401API_KEY_REQUIREDNoNo API key was sent. Send one as Authorization: Bearer … or X-Api-Key.
401INVALID_KEYNoThe key is unknown or revoked — the two are deliberately indistinguishable. Check the key; repeated invalid attempts from one IP are themselves rate-limited.
429RATE_LIMITEDYesToo many requests — 60 per minute for your key. Wait for the period named in the Retry-After header.
429QUOTA_EXHAUSTEDNoThe 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.
422VALIDATION_ERRORNoThe request was malformed. Fix the request shape.
422UNSUPPORTED_DOCUMENTNoThe bytes are not a readable PDF or image document.
422NO_EXTRACTABLE_TEXTNoThe 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.
413PAYLOAD_TOO_LARGENoThe file exceeds the 10 MiB (10,485,760 bytes) upload limit. Split or regenerate the document.
404NOT_FOUNDNoNo such job or proposal for this key — including a zero-retention result that has already been delivered once or has expired.
503KILL_SWITCH_ENGAGEDYesThe service is paused by the operator kill switch. Retry after a longer interval.
503MODEL_UNAVAILABLEYesThe extraction service is temporarily unavailable. Back off and retry.
500INTERNAL_ERRORNoSomething 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.

Limits and file rules

Sandbox

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.

Data handling

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.

Versioning

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

Feedback

Send feedback about the API with POST /v1/feedback (JSON). An API key is optional.


links your feedback to that job's audit record

if you want a reply