Bill Agent

Developers

Put bill reading inside your own software.

Send a photo, scan or PDF of a bill. Get back one JSON document: vendor, customer, dates, line items, taxes and totals, with a confidence for every field and a flag for anything a person should check.

Start

Get an API key

Every call carries an API key. If you have a Bill Agent account, log in, open your dashboard and press Create API key. The key uses your account's spending limits and bill history. For a system rather than a person, ask the Bill Agent team for one.

A key looks like ba_live_… and is shown only once, when it is created. If it is lost or leaked, press Replace the key: the old key stops working at once. Keep it on your server: a key in a web page or mobile app can be copied and used by anyone.

Send it on every request as a header:

Authorization: Bearer ba_live_…

Start

Your first request

Upload a bill as multipart/form-data in a field called files. The answer comes back in the same request. Allow 30 seconds for a one-page bill.

curl -H "Authorization: Bearer $BILL_AGENT_KEY" \
     -F files=@bill.jpg \
     https://your-host/v1/analyze

Several files in one request are read as pages of one document, for example the front and back of a receipt. To read several bills, send one request per bill.

REST API

Analyze a bill

POST/v1/analyze reads the document and answers in the same request. Every field except files is optional.

Form fieldWhat it does
filesThe bill. JPEG, PNG, WebP, HEIC/HEIF or PDF, up to 20 MB each and 30 pages in total. Repeat the field for more pages of the same document.
country_hintWhere the bill is from, as a two-letter code: OM, SA, AE or PK. Helps with tax rules and number formats. Leave it out and the country is detected.
language_hintar, en or ur. Leave it out and the languages are detected.
fieldsComma-separated list of the standard fields you want, if you do not need all of them.
custom_fieldsExtra things to read that are not in the standard schema, as JSON of {"name": "what it is"}. For example {"meter_number": "the electricity meter number"}. Answers come back under custom_fields.
confidence_threshold0 to 1. Fields read with less confidence than this are listed for review. Default 0.8.
return_raw_texttrue to also get the text as it was read, in raw_text.
curl -H "Authorization: Bearer $BILL_AGENT_KEY" \
     -F files=@page1.jpg -F files=@page2.jpg \
     -F country_hint=OM \
     -F 'custom_fields={"meter_number": "the electricity meter number"}' \
     https://your-host/v1/analyze

Send an X-Request-ID header with your own id and it comes back as request_id, so you can match our answer to your records.

REST API

The response

One JSON document, the same shape for every kind of bill. This is an invoice from Oman, shortened:

{
  "schema_version": "1.0",
  "document_id": "7d8d5dcd…",          // open it again later under /v1/documents
  "document_type": "invoice",
  "country": "OM",
  "currency": "OMR",
  "vendor": { "name": "Al Maha Trading LLC", "tax_id": "OM1100012345", "email": null, … },
  "customer": { "name": "Blue Ocean Cafe", "tax_id": null, … },
  "identifiers": { "invoice_number": "INV-2026-0912", … },
  "dates": { "issue_date": "2026-09-12", "due_date": "2026-10-12", … },
  "line_items": [
    { "description": "Mineral water 500ml x24", "quantity": 10, "unit_price": 2.4,
      "line_total": 24.0, "tax_rate": 0.05, "confidence": 0.977, "page": 1 }
  ],
  "amounts": {
    "subtotal": 104.0, "discount": null,
    "taxes": [{ "type": "VAT", "rate": 0.05, "base": 104.0, "amount": 5.2 }],
    "total": 109.2, "amount_due": 109.2, …
  },
  "payment": { "iban": "OM1201234567890123456789", "bank": "Bank Muscat", "terms": "Net 30", … },
  "confidence": { "overall": 0.96, "fields": { "vendor.name": 0.98, "amounts.total": 1.0, … } },
  "validation": { "arithmetic_ok": true, "issues": [] },
  "needs_review": false,
  "review_fields": [],
  "field_pages": { "amounts.total": 1, … },
  "gating": { "stage": 3, "passed": true, "reason": null },
  "processing": { "pages": 1, "duration_ms": 8120, "cost_estimate": 0.008 }  // what this bill was charged, in USD
}
  • Values are clean. Amounts are numbers, dates are YYYY-MM-DD, countries and currencies are ISO codes, whatever the bill printed (Arabic digits, ر.ع., 12/10/2026).
  • null means “not on the document”. The reader never guesses a value that is not printed.
  • processing.cost_estimate is what the bill was charged, in US dollars. The same amount counts against your key's daily and monthly limits.
  • Every field has a confidence from 0 to 1 in confidence.fields, and the page it was read from in field_pages.
  • Utility bills also fill usage (meter readings), identifiers.account_number, and on amounts the previous_balance, payments_received and current_charges.
  • Shop ledgers (tab books) come back as "document_type": "ledger" with one entry per written row in ledger, not in line_items. A ledger has no vendor, date or grand total, so confidence.overall is the average of the row confidences.
In the schema, but empty for now. Do not build on these yet: category is always null; entity_resolution stays not_checked; statement is null, as bank and card statement rows are not extracted; regions is not returned.

The full list of fields, their types and allowed values is the JSON Schema at GET /v1/schema. It needs no key, so you can generate types from it.

REST API

Trusting the numbers

Before you post a bill to your books, check two things:

  1. needs_review is true when something should be looked at by a person. review_fields lists exactly which fields, so your screen can highlight just those.
  2. validation.issues says what did not add up. We recalculate the bill: line items against the subtotal, tax against the rate, subtotal minus discount plus tax against the printed total.
"validation": {
  "arithmetic_ok": false,
  "issues": [{
    "code": "TOTAL_MISMATCH",
    "field": "amounts.total",
    "message": "subtotal 104.000 − discount 0 + tax 5.200 = 109.200, printed total is 120.000"
  }]
},
"needs_review": true,
"review_fields": ["amounts.total", "dates.due_date"]

Not a bill? A photo of something else is answered with 200, "document_type": "not_a_bill" and "gating": {"passed": false, "reason": "…"}. A photo with no text on it is caught before any model runs.

REST API

Long documents: jobs

A long bill can take a few minutes, longer than many clients and proxies will wait. If you would rather not hold a request open, queue it as a job. A job takes the same form fields as /v1/analyze, plus an optional webhook, and has the same 30-page limit.

curl -H "Authorization: Bearer $BILL_AGENT_KEY" \
     -F files=@long-bill.pdf \
     -F webhook_url=https://your-app.example.com/bill-agent-hook \
     -F webhook_secret="a long random string" \
     https://your-host/v1/jobs
# 202 {"job_id": "9f2c…", "status": "queued", "poll": "/v1/jobs/9f2c…", "pages": 30}

Then either poll, or wait for the webhook:

GET/v1/jobs/{id}status is queued, running, done or error. When done, result holds the same document /v1/analyze returns; when error, error says why. Poll every few seconds.
GET/v1/jobsYour recent jobs, newest first.
DELETE/v1/jobs/{id}Remove the job and its result once you have collected it. Otherwise results are deleted after 7 days.

Webhooks

With webhook_url, the finished job is POSTed to you whether it succeeded or failed: the same JSON as GET /v1/jobs/{id}, with up to 3 attempts. The URL must be https:// on a public address; redirects are not followed.

With webhook_secret, each delivery carries X-Bill-Agent-Signature: sha256=<hex>, an HMAC-SHA256 of the exact body with your secret. Check it before trusting the body:

import hashlib, hmac

def is_from_bill_agent(body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

REST API

Stored bills

Each bill you send is kept with its result, so you can open it again, record a correction or delete it. You only ever see the bills sent with your own key.

GET/v1/documentsYour bills, newest first. Filter with ?needs_review=true, ?reviewed=false, ?status=error or ?search=; page with limit and offset.
GET/v1/documents/{id}One bill with its full result and any corrections.
GET/v1/documents/{id}/file/{n}The original file you sent, page n starting at 0.
POST/v1/documents/{id}/reviewRecord what a person corrected, as form field corrections = JSON of {"field": value}, and optionally by. The original reading is kept beside the correction, never overwritten.
DELETE/v1/documents/{id}Delete the bill, its file and its result.
curl -H "Authorization: Bearer $BILL_AGENT_KEY" \
     -F 'corrections={"vendor.name": "Al Maha Trading L.L.C."}' -F by=sara \
     https://your-host/v1/documents/7d8d5dcd…/review

REST API

Errors and limits

Errors always have the same shape, so one handler covers them all:

{
  "schema_version": "1.0",
  "request_id": "…",
  "error": {
    "code": "UNSUPPORTED_FORMAT",
    "message": "Accepted formats: JPEG, PNG, WebP, HEIC/HEIF, PDF.",
    "retry_after_s": null
  }
}
CodeHTTPWhat to do
UNAUTHORIZED401The key is missing, wrong or revoked.
UNSUPPORTED_FORMAT415Send JPEG, PNG, WebP, HEIC/HEIF or PDF.
FILE_TOO_LARGE413Keep each file under 20 MB. A phone photo is usually 2–5 MB.
TOO_MANY_PAGES413At most 30 pages per request across all its files, for /v1/analyze and jobs alike. Send a longer document in parts of 30 pages or fewer.
RATE_LIMITED429Too many requests this minute. Wait retry_after_s seconds and try again.
QUOTA_EXCEEDED429Your key has reached its daily or monthly spending limit. Nothing was charged; ask us to raise it.
PROVIDER_UNAVAILABLE503The reading service is down for a moment. Retry with backoff.
JOB_FAILED502The bill could not be read this time. Retry once; if it fails again, send us the request_id.
Spending limits are checked before any model runs. Over the limit, a request is refused without being charged. A few requests at once, or a pile of queued jobs, cannot spend past it.

MCP

Connect an AI assistant

Bill Agent is also an MCP server, so an assistant such as Claude can read bills as a tool, in the middle of a conversation or an automated workflow. It uses the same API key, limits and stored bills as the REST API.

The server address is https://your-host/mcp (streamable HTTP).

Claude Code

claude mcp add --transport http bill-agent https://your-host/mcp \
  --header "Authorization: Bearer ba_live_…"

Any other MCP client

Add a remote HTTP server with the URL above and the Authorization header. In a .mcp.json file:

{
  "mcpServers": {
    "bill-agent": {
      "type": "http",
      "url": "https://your-host/mcp",
      "headers": { "Authorization": "Bearer ba_live_…" }
    }
  }
}

MCP

Tools

ToolWhat it does
analyze_uploadRead a bill you uploaded first (see below), by its upload_id. The way to send real files.
analyze_bill_dataRead a small file passed inline as base64. Only for files under about 150 KB.
get_schemaThe JSON Schema of the result.
list_packsThe countries and languages the reader has rules for.

The analyze tools take the same options as the REST API: country_hint, language_hint, fields, custom_fields and return_raw_text. They return the same result document. Errors come back as data, {"error": {"code": …, "message": …}}, not as a failed call.

Each analyze call is billed to your key. The tool descriptions say so, so that an assistant does not read a whole folder of bills unasked.

MCP

Sending files to the assistant

An MCP tool call cannot carry a file, and asking an assistant to type out a photo as base64 fails for anything but tiny files. So upload the file first, with the same key, and hand the assistant the id:

curl -H "Authorization: Bearer ba_live_…" -F files=@bill.jpg https://your-host/v1/uploads
# 201 {"upload_id": "LYvs…", "files": 1, "pages": 1, "expires_in_s": 3600}

Then the assistant calls analyze_upload(upload_id="LYvs…"). Several files in one upload are pages of one document. Uploading is free; an upload belongs to your key, is deleted once it is read, and expires after an hour.