Tabscanner/Docs
Dashboard

Tabscanner for AI agents

Give a language model the ability to read receipts. This page is written for the developer wiring the tool up 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_credit. 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. Put the AGENTS.md snippet in your agent's instructions if it will also call the HTTP API directly.

The one thing agents get wrong

Every Tabscanner response is HTTP 200. The outcome is the numeric code in the JSON body. 301 means "not ready yet", 202 means "done", 402 means "unknown token". A tool that checks response.ok will treat every failure as success.

Rules for agents

If you are an agent calling the HTTP API directly, follow these rules exactly.

  1. Send the API key in the apikey header. Read it from the environment; never print or log it.
  2. Upload with POST https://api.tabscanner.com/api/2/process as multipart/form-data with exactly one file part named file (JPG or PNG). Add region with the receipt's two-letter country code when you know it.
  3. Read code from the JSON body. 200 or 300 means accepted: keep the token. Anything else is an error: stop and report message.
  4. Wait one second, then GET https://api.tabscanner.com/api/result/{token}. If code is 301, wait one second and call again. If it is 202, the data is in result. Any other code is an error: stop and report message.
  5. Stop polling after 60 seconds and report a timeout. The token stays valid, so the result can be collected later.
  6. Never retry a 4xx code with the same input. Retry 5xx codes with backoff, except 500 (OCR failure), which means the image itself cannot be read.
  7. Each accepted upload uses one credit. Do not upload the same image twice to "check"; identical bytes return duplicate: true and the original token.
  8. Ignore result fields you do not recognise. New fields are added without a version change.
CodeMeaningWhat to do
200Process request submitted successfullyStore the returned token and start polling the result endpoint after about 2 seconds.
202Result availableRead the result object. Stop polling.
300Image uploaded, but did not meet the recommended dimension of 720x1280 (WxH)Processing continues. Accuracy may be reduced; capture higher resolution images if possible.
301Result not yet availableKeep polling, about once per second. Give up after 60 seconds and report a timeout.
400API key not foundCheck the apikey header. Do not retry with the same key.
401Not enough creditTop up credit or upgrade the plan. Do not retry until credit is available.
402Token not foundThe token is unknown or older than the retention period. Do not retry.
403No file detectedSend exactly one multipart part named file.
404Multiple files detected, can only upload 1 file per API callSend one file per call.
405Unsupported mimetypeSend image/jpeg or image/png.
406Form parser errorThe multipart body is malformed. Check the request encoding.
407Unsupported file extensionUse a .jpg, .jpeg or .png filename in the multipart part.
408File system errorRetry once. If it persists contact support.
500OCR FailureThe image could not be processed. Retry with a clearer image.
510Server errorRetry with exponential backoff.
520Database Connection ErrorRetry with exponential backoff.
521Database Query ErrorRetry with exponential backoff.

Tool definitions

The JSON on the right is ready for the Claude Messages API tools array. The input_schema properties other than image_path map one to one onto the form fields of the process endpoint, and the test suite checks that they stay a subset of the API's parameters.

OpenAI-style function calling

Wrap each definition as {"type": "function", "function": {"name", "description", "parameters"}}, using the same schema under parameters instead of input_schema. Nothing else changes.

Design notes

  • image_path is 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 region optional. 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.
  • Return the whole result object. Trimming it "for tokens" removes the confidence fields the model needs to answer honestly.
  • Surface errors as tool errors with the API message, so the model can tell the user "no credit" rather than "something went wrong".
examples/agents/tools.json
[
  {
    "name": "scan_receipt",
    "description": "Extract structured data from a photo of a receipt using the Tabscanner API. Uploads the image, waits for processing (usually a few seconds) 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. Call it once per receipt image. Pass the receipt's country as region for best accuracy. Trust total when validatedTotal is true; otherwise check totalConfidence and the arithmetic of lineItems.",
    "input_schema": {
      "type": "object",
      "properties": {
        "image_path": {
          "type": "string",
          "description": "Local path to a JPG or PNG photo of the receipt."
        },
        "region": {
          "type": "string",
          "description": "Two-letter lower-case ISO 3166-1 country code of the receipt, for example \"us\" or \"gb\". Optional but recommended."
        },
        "documentType": {
          "type": "string",
          "enum": ["receipt", "invoice", "auto"],
          "description": "Type of document. Default \"receipt\". Use \"auto\" to let the API detect it."
        },
        "defaultDateParsing": {
          "type": "string",
          "enum": ["d/m", "m/d"],
          "description": "How to read an ambiguous numeric date such as 02/03/2026: \"d/m\" is 2 March, \"m/d\" is 3 February."
        }
      },
      "required": ["image_path"]
    }
  },
  {
    "name": "get_credit",
    "description": "Return the number of Tabscanner credits left on the account. Each scan_receipt call uses one credit.",
    "input_schema": {
      "type": "object",
      "properties": {}
    }
  }
]

Reference implementation

A dependency-free Node module that implements both tools. scanReceipt uploads, polls once per second, and resolves with the result object plus the token and duplicate flag; it throws a TabscannerError carrying the API code and message for anything else. The module is part of the tested samples: run it directly with an image path to see the output.

To use it with the Claude API, register the tool definitions above and, when the model returns a tool_use block named scan_receipt, call scanReceipt(block.input) and return the JSON as the tool_result. For an MCP server, wrap the same two functions as MCP tools.

Porting to another language is a matter of taking the scan sample for that language from the Quickstart and returning the result instead of printing it.

examples/node/agent-tool.js
// Tabscanner as an agent tool. scanReceipt() uploads, polls and returns the result object,
// so the model sees one call instead of a polling loop. getCredit() returns the balance.
// Node 20 or newer, no packages.  Usage as a script: node agent-tool.js receipt.jpg [region]
import { openAsBlob } from 'node:fs';
import path from 'node:path';

const API_URL = process.env.TABSCANNER_API_URL ?? 'https://api.tabscanner.com';
const API_KEY = process.env.TABSCANNER_API_KEY;
const POLL_MS = Number(process.env.TABSCANNER_POLL_MS ?? 1000);

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Every response is HTTP 200; the outcome is the "code" field in the JSON body.
async function api(route, init = {}) {
  const response = await fetch(`${API_URL}${route}`, { ...init, headers: { apikey: API_KEY } });
  return response.json();
}

class TabscannerError extends Error {
  constructor(body) {
    super(`Tabscanner ${body.code}: ${body.message}`);
    this.code = body.code;
    this.retryable = body.code >= 500 && body.code !== 500;
  }
}

/**
 * Tool: scan_receipt. Returns the result object for one receipt image.
 * @param {{ image_path: string, region?: string, documentType?: string, defaultDateParsing?: string }} input
 */
export async function scanReceipt(input, { timeoutMs = 60_000 } = {}) {
  const form = new FormData();
  const mimeType = input.image_path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
  form.append('file', await openAsBlob(input.image_path, { type: mimeType }), path.basename(input.image_path));
  for (const key of ['region', 'documentType', 'defaultDateParsing']) {
    if (input[key]) form.append(key, input[key]);
  }

  const upload = await api('/api/2/process', { method: 'POST', body: form });
  if (upload.code !== 200 && upload.code !== 300) throw new TabscannerError(upload);

  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await sleep(POLL_MS);
    const body = await api(`/api/result/${upload.token}`);
    if (body.code === 202) return { token: upload.token, duplicate: upload.duplicate, ...body.result };
    if (body.code !== 301) throw new TabscannerError(body);
  }
  throw new Error(`Tabscanner: result for ${upload.token} not ready after ${timeoutMs} ms; poll it later`);
}

/** Tool: get_credit. Returns the number of credits left on the account. */
export async function getCredit() {
  const body = await api('/api/credit');
  if (typeof body !== 'number') throw new TabscannerError(body);
  return body;
}

if (process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname) {
  const result = await scanReceipt({ image_path: process.argv[2] ?? 'receipt.jpg', region: process.argv[3] });
  console.log(JSON.stringify(result, null, 2));
}

Which fields to trust

The result object 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:

  • Report total without hedging when validatedTotal is true. Otherwise mention that the total was read with totalConfidence confidence, and verify it: the lineItems amounts should sum to subTotal, and subTotal plus tax, tip and serviceCharges minus discount should equal total.
  • Treat establishment as certain when validatedEstablishment is true; it was cross-checked against the merchant's phone number or address.
  • Use dateISO for anything programmatic. There is no time zone; it is the local time printed on the receipt.
  • Prefer descClean over desc for item names. A qty of 0 means no quantity was printed, not zero items.
  • Read currency from the result rather than assuming it from region.
  • Say so when a field is null; the receipt did not print it or it could not be read.
Example tool result (abridged)
{
  "token": "8f3c1e2ab7d94d1c9f0e5b6a2c4d7e81",
  "duplicate": false,
  "establishment": "Corner Deli & Grocery",
  "validatedEstablishment": true,
  "dateISO": "2026-03-14T12:41:00",
  "total": 19.93,
  "totalConfidence": 0.995,
  "validatedTotal": true,
  "subTotal": 18.45,
  "tax": 1.48,
  "currency": "USD",
  "paymentMethod": "CASH",
  "lineItems": [
    { "descClean": "TURKEY CLUB SANDWICH", "qty": 1, "lineTotal": 8.95 },
    { "descClean": "SPARKLING WATER 500ML", "qty": 2, "lineTotal": 7.00 },
    { "descClean": "BANANA", "qty": 0, "unit": 0.612, "lineTotal": 2.50 }
  ]
}

AGENTS.md snippet

Paste this into the instructions file of a coding agent (AGENTS.md, CLAUDE.md, a system prompt) when it will work with the Tabscanner API directly. It is the shortest complete description of the API's behaviour.

examples/agents/AGENTS.md
## Tabscanner receipt OCR

Base URL `https://api.tabscanner.com`. Auth: header `apikey: <key>` (read it from `TABSCANNER_API_KEY`, never print it).

Flow: `POST /api/2/process` (multipart, one JPG/PNG in field `file`, optional `region`
two-letter country code) returns `{ token }`. Then `GET /api/result/{token}` once per second
until body `code` is 202; `result` holds the data. `GET /api/credit` returns a bare number.

Rules:
- Every response is HTTP 200. Read the numeric `code` in the JSON body, never the HTTP status.
- 200/300 = upload accepted (300 = image smaller than 720x1280, still processed).
- 301 = not ready, keep polling. 202 = done. Any other code = stop and report `message`.
- 4xx codes are permanent for the same input; do not retry. 5xx: retry with backoff. 500 = unreadable image.
- One credit per accepted upload. Re-uploading identical bytes returns `duplicate: true` and the original token.
- Prefer `validatedTotal`/`validatedEstablishment` = true. Otherwise compare `*Confidence` fields and check
  that `lineItems` sum to `subTotal` and `subTotal + tax + tip + serviceCharges - discount = total`.
- Results and images are deleted after 90 days; store what you need.

Spec: https://docs.tabscanner.com/openapi.yaml · Full docs: https://docs.tabscanner.com/llms-full.txt

Machine-readable resources

  • openapi.yaml: OpenAPI 3.1. Every parameter and field has a description; the body codes are listed under x-tabscanner-codes with a recommended action each. Suitable for generating clients or for feeding to a model as context.
  • schemas/result.schema.json: JSON Schema 2020-12 for the result object, for validating tool results.
  • llms.txt: a short index in the llms.txt format.
  • llms-full.txt: the entire API reference as one Markdown file, generated from the reference page.
  • Samples in eight languages live in the docs repository under examples/, each with the same four programs (process, result, credit, scan), tested against a mock of the API.

These files are served from the same origin as this page and are updated together with it.