Tabscanner for AI agents
Give an assistant or agent the ability to read receipts. This page is written for the developer wiring up the tool, and for the agent that ends up calling it.
Overview
Tabscanner turns a photo of a receipt into JSON: merchant, date, totals, taxes, tip, payment method and line items, each with a confidence score. The HTTP API is two calls: an upload that returns a token, and a result endpoint that is polled until the data is ready. See the API reference for every field.
For an agent, hide the polling. Expose one tool, scan_receipt, that takes an image and returns the result object, plus a trivial get_tabscanner_account. The model should never have to decide when to poll again, and it should never see the API key.
Everything the model needs to know about the data is in the field descriptions of openapi.yaml. If your agent will call the HTTP API directly, put the AGENTS.md snippet on the right in its instructions.
The API uses real status codes and application/problem+json errors with a stable code, so a generic HTTP or OpenAPI tool handles failures correctly. The one non-error to know: a result with "status": "pending" comes back as 200 and means "ask again after Retry-After seconds".
## Tabscanner receipt API (v2)
Use these rules when calling https://api3.tabscanner.com directly.
- Send `Authorization: Bearer $TABSCANNER_API_KEY`. Read the key from the environment; never print or log it.
- Upload: `POST /v2/receipts`, `multipart/form-data`, one part named `file` (JPEG or PNG). Add `region` (two-letter lower-case country code) when known. Send a new `Idempotency-Key: <uuid>` header per image.
- `202` means accepted: keep `token`. Any other status is an error: read `code` and `detail` from the JSON body, report them, and stop.
- Poll `GET /v2/receipts/{token}`, sleeping the `Retry-After` header's seconds before each call. `status` is `pending` (keep polling), `done` (data in `result`) or `failed` (unreadable image; do not retry it).
- Stop polling after 60 seconds (120 with a `model`) and report a timeout. The token stays valid.
- Retry network errors, `5xx`, `409` and `429` with backoff, reusing the same `Idempotency-Key` for uploads. Never retry other `4xx` unchanged.
- Each accepted upload uses one credit. Never re-upload an image to "check" it; fetch the result again with its token instead.
- `model: "harajuku"` works on every account. Only send a `-pro` model when `GET /v2/account` reports `features.proModels: true`; otherwise it fails with `403 MODEL_NOT_PERMITTED` (not charged).
- Ignore result fields you do not recognise.Rules for agents
If you are an agent calling the HTTP API directly, follow these rules exactly.
- Send the API key as
Authorization: Bearer <key>. Read it from the environment; never print, log or repeat it. - Upload with
POST https://api3.tabscanner.com/v2/receiptsasmultipart/form-data, with one file part namedfile(JPEG or PNG). Addregionwith the receipt's two-letter country code when you know it; do not guess. Send a newIdempotency-Key(a UUID) per image. - A
202response means accepted: keep thetoken. Any other status is an error: report the body'scodeanddetailand stop, unless the retry rule below applies. - Wait the
Retry-Afterseconds, thenGET https://api3.tabscanner.com/v2/receipts/{token}. Ifstatusispending, waitRetry-Afteragain and repeat. Ifdone, the data is inresult. Iffailed, the image cannot be read: ask the user for a clearer photo. - Stop polling after 60 seconds (120 when a
modelwas chosen) and report a timeout. The token stays valid, so the result can be collected later. - Retry network errors and
5xx,409and429responses with exponential backoff. For uploads, resend the same request with the sameIdempotency-Key, so a retry is never charged twice. Never retry other4xxresponses unchanged. - Each accepted upload uses one credit. Never upload the same image again to "check" it; fetch the result again by its token.
- Ignore result fields you do not recognise. New fields are added without a version change.
Error codes an agent should explain
| Status | Code | When | What to do |
|---|---|---|---|
400 | FORM_PARSER | The body is not valid multipart/form-data, or exceeds the part or field limits. | Fix the request encoding. Do not retry unchanged. |
400 | IDEMPOTENCY_KEY_INVALID | The Idempotency-Key header is empty, too long or contains invalid characters. | Use a printable ASCII value of up to 255 characters, such as a UUID. |
400 | MODEL_UNKNOWN | The model form field is not one of the available models. | Use one of the values listed in the error detail, or omit model. |
400 | NO_FILE | The request contains no file part. | Send the image as a part named file. |
400 | TOO_MANY_FILE_FIELDS | Files were sent under more than three different field names. | Send every image as a part named file. |
400 | TOO_MANY_FILES | More than one image was sent and multi-image uploads are not enabled for the account, or more than three were sent. | Send one image, or up to three if features.multipleImages is true. |
400 | VALIDATION | A path parameter or header is malformed, for example a token with invalid characters. | Fix the request. Do not retry unchanged. |
401 | API_KEY_INVALID | No API key was sent, or the key is unknown or revoked. | Check the Authorization header. Do not retry with the same key. |
402 | INSUFFICIENT_CREDIT | The account has no credit left. | Top up in the dashboard. Do not retry until credit is available. |
402 | SUBSCRIPTION_INACTIVE | The account’s subscription is not active. | Renew the subscription in the dashboard. |
402 | SUBSCRIPTION_NOT_FOUND | The account references a subscription that does not exist. | Contact support. |
403 | MODEL_NOT_PERMITTED | A -pro model was requested, but the account does not have the Pro package. | Use the base model instead of the -pro one, omit model for standard OCR, or upgrade. features.proModels on GET /v2/account tells you in advance. |
403 | PDF_NOT_PERMITTED | A PDF was uploaded. PDF uploads are not currently available. | Convert the page to JPEG or PNG and upload that. |
404 | NOT_FOUND | The method and path do not match any endpoint. | Check the URL against this reference. |
404 | TOKEN_NOT_FOUND | The token does not exist, belongs to another account, or is older than the retention period. | Check the token. Do not retry. |
409 | IDEMPOTENCY_IN_PROGRESS | An earlier request with the same Idempotency-Key is still being processed. | Wait a second and retry; you will then receive the original response. |
413 | FILE_TOO_LARGE | A file is larger than 30 MB. | Send a smaller image. Phone photos are well under this limit. |
415 | UNSUPPORTED_EXTENSION | The file name does not end in .jpg, .jpeg or .png. | Name the multipart file with the matching extension. |
415 | UNSUPPORTED_FILE_TYPE | The file content is not an image. | Send a JPEG or PNG. |
415 | UNSUPPORTED_MIME_TYPE | The file is an image, but not JPEG or PNG (for example GIF or WebP). | Convert to JPEG or PNG. |
422 | IMAGE_PROCESSING | The image could not be decoded, for example a truncated or corrupt file. | Check the file opens locally, then upload it again. |
429 | RATE_LIMITED | More than 50 requests per second from one IP address, after a burst allowance of 200. | Wait for the Retry-After seconds, then continue at a lower rate. |
500 | DB_QUERY | An unexpected internal error. | Retry once; if it persists, contact support with the requestId. |
500 | INTERNAL | An unexpected internal error. | Retry once; if it persists, contact support with the requestId. |
500 | RESULT_INVALID | The stored result for this token is corrupt. | Contact support with the token and requestId. |
503 | DB_CONNECTION | A temporary internal outage. | Retry with exponential backoff. |
503 | QUEUE | The processing queue is temporarily unavailable. Nothing was charged. | Retry with exponential backoff, using the same Idempotency-Key. |
503 | SERVICE_UNAVAILABLE | The API is restarting or unreachable. | Retry with exponential backoff. |
503 | STORAGE | Image storage is temporarily unavailable. Nothing was charged. | Retry with exponential backoff, using the same Idempotency-Key. |
Tool definitions
The JSON on the right defines both tools as a name, a description and a JSON Schema for the input. This is the shape every major agent framework and function-calling API accepts; if yours names the schema key differently (parameters instead of input_schema, for example), rename that key and change nothing else.
The input_schema properties other than image_path map one to one onto the upload's form fields, using snake_case: region, document_type (documentType) and model.
Design notes
image_pathis a local path because the agent runtime usually has the file. If your runtime passes URLs or base64, adapt the implementation, not the schema the model sees.- Keep
regionoptional. If the model knows the country from context (a user in the UK, a receipt in euros) it will pass it; forcing it invites guesses. - Leave
modeloptional and let the agent reach for it when standard OCR returns low confidences or broken arithmetic. See Choosing a model. - Return the whole result object. Trimming it "to save tokens" removes the confidence fields the model needs to answer honestly.
- Surface errors as tool errors that include the API's
codeanddetail, so the model can tell the user "out of credit" rather than "something went wrong".
[
{
"name": "scan_receipt",
"description": "Extract structured data from a photo of a receipt or invoice using the Tabscanner API. Uploads the image, waits for processing (usually a few seconds; 10 to 20 seconds when a model is chosen) and returns the result object: establishment, date, total, subTotal, tax, tip, discount, currency, paymentMethod, address, lineItems, summaryItems and confidence scores (0 to 1). Each call uses one credit, so call it once per image. Pass the receipt's country as region for best accuracy. Before relying on total, check totalConfidence and that the lineItems add up.",
"input_schema": {
"type": "object",
"properties": {
"image_path": {
"type": "string",
"description": "Local path to a JPEG or PNG photo of the receipt."
},
"region": {
"type": "string",
"description": "Two-letter lower-case ISO 3166-1 country code of the receipt, for example gb, us or de. Omit if unknown; do not guess.",
"pattern": "^[a-z]{2}$"
},
"document_type": {
"type": "string",
"enum": ["receipt", "invoice", "auto"],
"description": "Type of document. Default receipt. Use auto when unsure."
},
"model": {
"type": "string",
"enum": ["harajuku", "harajuku-pro"],
"description": "Optional Tabscanner model for difficult documents: harajuku reads more thoroughly than standard OCR; harajuku-pro is more thorough again and slower, and needs the Pro package (features.proModels). Omit for standard OCR, which suits most receipts. Without the Pro package a -pro model fails with MODEL_NOT_PERMITTED and nothing is charged."
}
},
"required": ["image_path"],
"additionalProperties": false
}
},
{
"name": "get_tabscanner_account",
"description": "Return the remaining Tabscanner credit and the features enabled for the account, including whether the Pro models may be used (features.proModels). Free. Use it to check there is enough credit before scanning a batch.",
"input_schema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
]Reference implementation
A dependency-free Node module that implements both tools. scanReceipt uploads with an Idempotency-Key (retrying transient failures safely), polls at the pace Retry-After sets, and resolves with the result object plus the token and duplicate flag. For anything else it throws a TabscannerError carrying the HTTP status, the error code and a retryable flag.
To use it, register the tool definitions above with your agent framework. When the model calls scan_receipt, call scanReceipt(input) and return the JSON as the tool result; on a TabscannerError, return its message as a tool error. To serve the tools over the Model Context Protocol, wrap the same two functions as MCP tools.
The module is one of the tested samples; run it directly with an image path to see the output. Porting it to another language means taking that language's Quickstart sample and returning the result instead of printing it.
// Tabscanner as an agent tool. scanReceipt() uploads, polls and returns the result object,
// so the model sees one call instead of a polling loop. getAccount() returns the credit.
// Node 20 or newer, no packages. Usage as a script: node agent-tool.js receipt.jpg [region] [model]
import { randomUUID } from 'node:crypto';
import { openAsBlob } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const API_URL = process.env.TABSCANNER_API_URL ?? 'https://api3.tabscanner.com';
const AUTH = { Authorization: `Bearer ${process.env.TABSCANNER_API_KEY}` };
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));
/** An API error, carrying the problem-details code so the agent can explain it. */
export class TabscannerError extends Error {
constructor(status, problem) {
super(`${problem.code ?? status}: ${problem.detail ?? problem.title ?? 'request failed'}`);
this.status = status;
this.code = problem.code;
this.retryable = status >= 500 || status === 409 || status === 429;
}
}
async function request(route, init = {}) {
const response = await fetch(`${API_URL}${route}`, { ...init, headers: { ...AUTH, ...init.headers } });
const body = await response.json().catch(() => ({}));
return { response, body };
}
/** Upload with retries: the same Idempotency-Key makes resending safe and never double-charges. */
async function upload(form, attempts = 4) {
const headers = { 'Idempotency-Key': randomUUID() };
for (let attempt = 1; ; attempt++) {
try {
const { response, body } = await request('/v2/receipts', { method: 'POST', headers, body: form });
if (response.status === 202) return { token: body.token, duplicate: body.duplicate, retryAfter: Number(response.headers.get('retry-after') ?? 2) };
const error = new TabscannerError(response.status, body);
if (!error.retryable || attempt === attempts) throw error;
} catch (error) {
if (error instanceof TabscannerError && !error.retryable) throw error;
if (attempt === attempts) throw error;
}
await sleep(2 ** (attempt - 1));
}
}
/** Tool: scan_receipt. Resolves with the result object plus the token and duplicate flag. */
export async function scanReceipt({ image_path, region, document_type, model }) {
const form = new FormData();
const type = image_path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
form.append('file', await openAsBlob(image_path, { type }), path.basename(image_path));
if (region) form.append('region', region);
if (document_type) form.append('documentType', document_type);
if (model) form.append('model', model);
const { token, duplicate, retryAfter } = await upload(form);
const deadline = Date.now() + (model ? 120_000 : 60_000);
let wait = retryAfter;
while (Date.now() < deadline) {
await sleep(wait);
const { response, body } = await request(`/v2/receipts/${token}`);
if (response.status !== 200) throw new TabscannerError(response.status, body);
if (body.status === 'done') return { token, duplicate, ...body.result };
if (body.status === 'failed') throw new TabscannerError(422, { code: 'OCR_FAILED', detail: body.error.detail });
wait = Number(response.headers.get('retry-after') ?? 2);
}
throw new Error(`Timed out waiting for the result. Token ${token} stays valid; fetch it later.`);
}
/** Tool: get_tabscanner_account. */
export async function getAccount() {
const { response, body } = await request('/v2/account');
if (response.status !== 200) throw new TabscannerError(response.status, body);
return body;
}
// Run directly: node agent-tool.js receipt.jpg [region] [model]
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [image_path = 'receipt.jpg', region, model] = process.argv.slice(2);
const result = await scanReceipt({ image_path, region, model });
console.log(`Establishment: ${result.establishment}`);
console.log(`Total: ${result.total} ${result.currency}`);
console.log(`Credit left: ${(await getAccount()).credit}`);
}Which fields to trust
The result carries a confidence between 0 and 1 for every important value, and boolean shortcuts for the highest tier. An agent answering questions about a receipt should:
- Check
totalbefore relying on it:totalConfidencetells you how sure the reading is, and the arithmetic should agree. ThelineItemsamounts should sum tosubTotal, andsubTotalplus tip and service charges (and tax, when not already included) minus discounts should equaltotal. When confidence is low or the sums disagree, say so. - Treat
establishmentas certain whenvalidatedEstablishmentistrue; it was cross-checked against the merchant's phone number or address. - Use
dateISOfor anything programmatic. It has no time zone; it is the local time printed on the receipt. - Prefer
descCleanoverdescfor item names. Aqtyof0means no quantity was printed, not zero items. - Read
currencyfrom the result rather than assuming it fromregion. - Say so when a field is empty or has low confidence: the receipt did not print it, or it could not be read.
{
"token": "dv9aLY1MVThnSJRo",
"duplicate": false,
"establishment": "GREENLEAF CAFE",
"validatedEstablishment": false,
"date": "2026-10-08 12:41:00",
"dateISO": "2026-10-08T12:41:00",
"total": 40.22,
"totalConfidence": 0.99,
"subTotal": 35.75,
"serviceCharges": [4.47],
"currency": "GBP",
"lineItems": [
{ "qty": 2, "descClean": "Flat White", "lineTotal": 7.0 },
{ "qty": 1, "descClean": "Avocado Toast", "lineTotal": 9.5 }
]
}
Choosing a model
Standard OCR, the default, is the right first call for almost every receipt. A model reads the document more thoroughly at the cost of 10 to 20 seconds of processing. A sensible agent policy:
- Scan without
model. - If
totalConfidenceis low and the line items do not add up, or the user says the result is wrong, scan again withmodel: "harajuku". Tell the user this uses another credit. - If that result still doesn't add up, and
features.proModelsis true, trymodel: "harajuku-pro", the more thorough and slower version.
harajuku is available to every account. The -pro models need the Pro package: check features.proModels from get_tabscanner_account before offering one; if a scan fails with MODEL_NOT_PERMITTED, tell the user the Pro models are not included in their plan and fall back to harajuku. Nothing is charged for the refused request.
model | Profile | Use it for |
|---|---|---|
| omitted | Standard OCR | The default. Fastest turnaround, layout-aware OCR with regional rules. |
harajuku | Model processing | Reads the document more thoroughly than standard OCR, for long, crumpled, faded or unusually laid out receipts and invoices. |
harajuku-pro | Model processing, pro | A more thorough version of harajuku, for documents where accuracy matters most. Longer processing time. |
Machine-readable docs
- llms.txt: a short index of this documentation for language models.
- llms-full.txt: the whole API reference as one Markdown file, ready to put in a model's context.
- openapi.yaml and openapi.json: OpenAPI 3.1, generated from the API's own route definitions. Import it into any OpenAPI-aware agent toolkit to get every endpoint as a tool.
- schemas/result.schema.json: JSON Schema 2020-12 for the result object, for validating or typing tool output.