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
import os, requests
with open("bill.jpg", "rb") as f:
r = requests.post(
"https://your-host/v1/analyze",
headers={"Authorization": f"Bearer {os.environ['BILL_AGENT_KEY']}"},
files={"files": f},
timeout=120,
)
bill = r.json()
if r.ok:
print(bill["vendor"]["name"], bill["amounts"]["total"], bill["needs_review"])
else:
print(bill["error"]["code"], bill["error"]["message"])
// Node 18+ — run this on your server, never in a browser (it holds the key)
import { readFile } from "node:fs/promises";
const form = new FormData();
form.append("files", new Blob([await readFile("bill.jpg")]), "bill.jpg");
const r = await fetch("https://your-host/v1/analyze", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.BILL_AGENT_KEY}` },
body: form,
});
const bill = await r.json();
console.log(r.ok ? bill.amounts.total : bill.error.code);
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 field | What it does |
|---|---|
files | The 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_hint | Where 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_hint | ar, en or ur. Leave it out and the languages are detected. |
fields | Comma-separated list of the standard fields you want, if you do not need all of them. |
custom_fields | Extra 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_threshold | 0 to 1. Fields read with less confidence than this are listed for review. Default 0.8. |
return_raw_text | true 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). nullmeans “not on the document”. The reader never guesses a value that is not printed.processing.cost_estimateis 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 infield_pages. - Utility bills also fill
usage(meter readings),identifiers.account_number, and onamountstheprevious_balance,payments_receivedandcurrent_charges. - Shop ledgers (tab books) come back as
"document_type": "ledger"with one entry per written row inledger, not inline_items. A ledger has no vendor, date or grand total, soconfidence.overallis the average of the row confidences.
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:
needs_reviewistruewhen something should be looked at by a person.review_fieldslists exactly which fields, so your screen can highlight just those.validation.issuessays 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/jobs | Your 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/documents | Your 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}/review | Record 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
}
}
| Code | HTTP | What to do |
|---|---|---|
UNAUTHORIZED | 401 | The key is missing, wrong or revoked. |
UNSUPPORTED_FORMAT | 415 | Send JPEG, PNG, WebP, HEIC/HEIF or PDF. |
FILE_TOO_LARGE | 413 | Keep each file under 20 MB. A phone photo is usually 2–5 MB. |
TOO_MANY_PAGES | 413 | At 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_LIMITED | 429 | Too many requests this minute. Wait retry_after_s seconds and try again. |
QUOTA_EXCEEDED | 429 | Your key has reached its daily or monthly spending limit. Nothing was charged; ask us to raise it. |
PROVIDER_UNAVAILABLE | 503 | The reading service is down for a moment. Retry with backoff. |
JOB_FAILED | 502 | The bill could not be read this time. Retry once; if it fails again, send us the request_id. |
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
| Tool | What it does |
|---|---|
analyze_upload | Read a bill you uploaded first (see below), by its upload_id. The way to send real files. |
analyze_bill_data | Read a small file passed inline as base64. Only for files under about 150 KB. |
get_schema | The JSON Schema of the result. |
list_packs | The 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.
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.