# Document Recognition API documentation --- > Synchronous document recognition over HTTP. --- The full OpenAPI contract is at /docs/openapi.yaml; the API reference is generated from it. --- # Document recognition API Turn a photo of a passport or ID into structured data with one HTTP call. The API is synchronous: you send an image and the extracted fields come back in the same response. Each answer carries a confidence score and the checks the engine ran. You can call it right now, without an account, using the public sandbox key `sk_sandbox_public`. It recognizes the document you send, free of charge, for a limited number of documents and up to 10 requests per hour per IP. ## Try it in one call This runs as written. The image in it is a one-pixel placeholder, so the answer is a `200` reporting `no_document_found`. Put a base64-encoded photograph of a document in its place and the fields come back: ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d '{"image":"/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q=="}' ``` The base URL `https://api.doc.cheap` is the host the OpenAPI `servers` block names. [Your first recognition](/start/first-recognition) does the same call with your own document, in curl, JavaScript or Python, and reads every field that comes back. ## The four sections - [Get started](/start) — your first call, then a key of your own. - [Guides](/guides) — one page per task, from recognizing a passport to tracking what you spend. - [Reference](/reference) — the exact contract: endpoints, response shapes, fields, options, errors and limits. - [Concepts](/concepts) — why the API behaves the way it does. ## Start here - [Your first recognition](/start/first-recognition) — one call, and what comes back. - [What a billed scan is](/concepts/what-a-billed-scan-is) — the rule that decides whether a call is charged at all. - [Recognize a passport](/guides/recognize-a-passport) — the full request, every option, and how to read the result. - [Errors](/reference/errors) — one page per error code, with the cause and the fix. - [Changelog](/changelog) — dated list of changes, with a feed. ## For AI coding agents Machine-readable copies of this site live at [/llms.txt](/llms.txt) (a short index) and [/llms-full.txt](/llms-full.txt) (every page as one markdown file). Any page is also available as bare markdown at its `.md` address, for example [/start/first-recognition.md](/start/first-recognition.md). --- # Get started This section takes you from nothing to a working integration. Two of the three lessons need no account and no key of your own. The key they use, `sk_sandbox_public`, is published on purpose. It is not a demo that replays a canned answer. It runs the same recognition engine a paying call runs, on the image you send it, and gives the fields back. It allows 10 requests per hour per client address, and a lifetime allowance of free recognitions per client on top of that. ## The three lessons 1. [Your first recognition](/start/first-recognition) — send one document, read every field that comes back, and see what the call took. No account. 2. [From the sandbox to a live key](/start/from-sandbox-to-live) — spend the free allowance, register, and point the same code at a key that bills. An account, and no money: registration comes with 20 credits. 3. [Recognize a document without code](/start/recognize-without-code) — upload in the dashboard and read the result on screen. An account. No code at all. Start with the first if you are integrating, and with the third if you are deciding whether to. ## What a recognition costs Nothing on either sandbox key. On a live key, one credit per billable scan, and one credit is one US cent. A scan is billable when the engine determined the document type and read something from it. A refusal that happened before the engine ran is never charged. The rule, and the cases around it, are on [what a billed scan is](/concepts/what-a-billed-scan-is). ## Where to go next - [Guides](/guides) — one page per task, from a passport to a driver licence to retries. - [Reference](/reference) — the exact contract: endpoints, response shapes, fields, options, errors and limits. - [Concepts](/concepts) — why the API behaves the way it does. - [Errors](/reference/errors) — one page per error code, with the cause and the fix. --- # Your first recognition One HTTP call turns a photograph of a passport or an ID card into fields you can store. This page walks that call end to end, then reads every part of the answer. You need no account and no key of your own. ## Before you start You need three things. 1. A photograph of a document, as JPEG or PNG. Save it as `document.jpg` in the directory you run the commands from. Your own passport works, and so does a specimen page from an issuer's website. 2. curl, Node.js or Python 3 — whichever you already have. 3. The public sandbox key, `sk_sandbox_public`. It is published, it costs nothing, and it runs the same engine a paying call runs. If the photograph came straight off a phone, shrink it first. Around 1600 px on the long edge at JPEG quality 85 is what our own upload pages send. That is enough for the engine to read the print. ## Send the document The endpoint is `POST /v1/scans`. The body carries the image, base64-encoded, and nothing else is required. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); const scan = await response.json(); console.log(scan.meta.status, scan.holder?.full_name); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(scan["meta"]["status"], scan["holder"]["full_name"]) ``` The call is synchronous. Nothing is queued and no webhook is registered: the recognition happens inside the request, and the fields come back in its response. ## What comes back A recognized document answers `200` with a body like this one. The holder is an invented specimen, and the long base64 crops are elided. ```json { "meta": { "schema_version": "1.0", "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", "status": "recognized", "billed": false, "confidence": "high", "timing": { "upload_ms": 214, "processing_ms": 843, "total_ms": 1074 }, "created_at": "2026-09-17T09:41:12Z", "reference": null }, "document": { "kind": "passport", "country": "GRC", "country_name": "Greece", "issuing_state": "GRC", "type_name": "Greece - Passport", "type_confidence": "high", "is_expired": false, "days_remaining": 2001 }, "holder": { "given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA", "birth_date": "1994-03-08", "sex": "F", "nationality": "GRC" }, "fields": [], "mrz": { "status": "passed", "reason": null, "lines": [ "P fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image, reference }), }); const image = readFileSync("document.jpg").toString("base64"); const frontResponse = await send(image, "card-7781-front"); const front = await frontResponse.json(); const response = await send(image, "card-7781-back"); const back = await response.json(); console.log(front.meta.id, back.meta.id, back.mrz.status); ``` ```python import base64 import json import urllib.request def send(image, reference): request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image, "reference": reference}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: return response.status, json.load(response) with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() _, front = send(image, "card-7781-front") status, back = send(image, "card-7781-back") print(front["meta"]["id"], back["meta"]["id"], back["mrz"]["status"]) ``` ## Match the two results Two values join the pair, and they answer different questions. - `meta.reference` is yours. It is echoed back untouched, so it is what your own order, case or session record keys on. - `meta.id` is ours, and it is a UUID version 7. The leading 48 bits are the millisecond the scan was made, so two ids sort in the order the two calls happened. The front is the smaller id when you sent the front first. Sorting on `meta.id` is worth knowing when a retry leaves you holding three results for a two-sided card. The newest id is the one the retry produced. ## What each side gives you The card decides which side carries what, but the pattern holds across issuers. | Side | Usually carries | Reads as | |---|---|---| | Front | The holder's photograph, the printed name, the number and the dates | `holder`, `document`, the visual entries of `fields`, and the `main_photo` crop | | Back | The machine-readable zone, the address, the authority | `mrz` with `status: passed`, plus the entries the zone and the reverse print | The side without a zone answers `mrz.status: absent`, with `reason`, `lines` and `text` all null. That is an outcome and not a failure: a card front prints no zone, so no zone was read. Both calls fill `document.kind` and `document.country` whenever the engine recognized the side it was given. A front that produced no zone still tells you which country issued the card. ## Merge in your own code Nothing on our side joins the two results, and nothing should. Which side wins a disagreement is a policy question about your own risk, and the API does not hold your policy. A merge that works for most integrations takes three rules. 1. Take the identity values from the side whose zone passed. The zone is printed to be read by a machine and carries its own check digits. 2. Take everything the zone does not encode — the address, the place of birth, the authority — from the side that printed it. 3. Compare the values both sides carry. A surname that differs between the two sides is a document to look at by hand, not a value to pick from. ```json { "reference": "card-7781", "surname": "PARADEIGMA", "given_names": "ELENI SOFIA", "number": "AK472913", "source_of_identity": "back", "source_of_address": "front", "front_scan": "01a0af19-8595-7f03-8a15-27e6b9c40f82", "back_scan": "01a0af1a-3b55-7c94-b3d8-51f0a6e27c45" } ``` Keep both scan ids. A support question about one card is answered from the two scans behind it, and neither id can be derived from the other. ## When one side does not read Each call is independent, so a bad picture of one side costs you that side and nothing more. `meta.status` says how far the engine got on the side it was given. - `no_document_found` — nothing in the frame was located. Re-photograph that side and send it again. - `unreadable` — the type was determined and no source could be read. Usually glare, blur or a crop that cut the zone. - `unsupported_document` — the card is a type the engine does not read. The other side will not help. Retry the one side that failed. Keep the result you already have for the other, and keep the same `reference` on the retry. A card whose back never reads is still usable. The front alone fills `holder` and `document`, and the identity values then rest on the printed page rather than on a zone with check digits. ## Two calls cost two credits Each call is priced on its own. A side that produced a billable result draws one credit, at one cent, and `meta.billed` on that response says whether it did. A card front with no zone is still billable when the engine read at least five visual fields. A blurred back that produced nothing costs nothing, and its `meta.billed` reads `false`. What decides it is on [what a billed scan is](/concepts/what-a-billed-scan-is). Send both calls with the same `Idempotency-Key`, and the second one replays the first. Give each side its own key: [retry safely with idempotency](/guides/retry-safely-with-idempotency). ## Next - [Check an MRZ](/guides/check-an-mrz) — re-run the check digits of the zone the back gave you. - [Handle non-Latin scripts](/guides/handle-non-latin-scripts) — reading a card that prints the name twice. - [MRZ reference](/reference/mrz) — the three formats and how the digits are reported. --- # Recognize a driver licence A driver licence goes to the same endpoint as a passport, with the same body. What differs is where the data is printed. Most issuers put no machine-readable zone on a licence at all. The answer is then assembled from the visual zone and, where one is printed, a barcode. ## Send it The call is the same one every document takes. The response reports the zone as a single verdict, so a missing zone is one branch rather than a walk through nulls. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); const scan = await response.json(); const classes = scan.fields.filter((field) => ["dl_class", "permit_class"].includes(field.name), ); console.log(scan.document?.kind, scan.mrz.status, classes.map((f) => f.value)); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) classes = [f["value"] for f in scan["fields"] if f["name"] in ("dl_class", "permit_class")] print(scan["document"]["kind"], scan["mrz"]["status"], classes) ``` ## No machine-readable zone is not a failure Treat `absent` as an ordinary outcome, not an error. `mrz.status` reads `absent`, and `reason`, `lines` and `text` are all `null`. The group itself is present, as every group always is. `absent` and `failed` are different answers. `absent` means no zone was read, so there was nothing to check. `failed` means a zone was read and something in it did not check out, which is worth acting on. The rest of the document is unaffected. A licence is recognized off its printed face like any other document, and `meta.status` comes back `recognized`. ```json { "mrz": { "status": "absent", "reason": null, "lines": null, "text": null }, "quality": { "overall": "pass" }, "fields": [ { "id": "dl_class@0", "name": "dl_class", "label": "Driving-licence class", "category": "document", "value": "B, BE", "language": null, "confidence": "high" }, { "id": "authority@0", "name": "authority", "label": "Issuing authority", "category": "document", "value": "THESSALONIKI REGIONAL OFFICE", "language": null, "confidence": "medium" } ] } ``` ## Photographing a card A licence is a glossy plastic card, and it is harder to photograph than a passport page. 1. Fill the frame with the card, square on. A card at an angle loses the characters nearest the far edge. 2. Turn the flash off. A direct flash reflects off the laminate and erases whatever is under the highlight. 3. Light it from the side, not from behind the camera. 4. Send both sides when the barcode matters, as two calls. ## What makes the scan billable without a zone A live key is charged when the engine determined the document type **and** one of three things is true. 1. A machine-readable zone was read and its check digits passed. 2. Five or more fields of the visual zone were read. 3. A barcode was decoded. A licence therefore bills on the second or third of those. A photograph too poor to yield five fields and no barcode is free, whatever else went right. [What a billed scan is](/concepts/what-a-billed-scan-is) holds the whole rule. ## The barcode A decoded barcode gets no group of its own. Its values arrive in `fields` beside everything else, and `images.barcode` carries a crop of it when the engine cut one out. Issuers that print a barcode usually put it on the back of the card, and this endpoint reads one image per call. Send the back as its own scan when you need what the barcode carries, and merge the two results yourself. ## The fields a licence fills Licence layouts vary by issuer more than passports do, so the field list is open rather than fixed. Any field the engine reads is published, whether or not we have a curated key for it. These are the ones worth looking for by name. | `name` | Label | Category | |---|---|---| | `dl_class` | Driving-licence class | `document` | | `permit_class` | Permit / licence class | `document` | | `authority` | Issuing authority | `document` | | `document_number` | Document number | `document` | | `issue_date` | Date of issue | `dates` | | `expiry_date` | Date the licence runs out | `dates` | | `address`, `address_street`, `address_city`, `address_state`, `address_postal_code` | Address, and its parts | `address` | | `height`, `eyes_color` | Height, Eye colour | `other` | Address parts are published separately as well as whole, because an issuer may print only some of them. Read whichever your own model needs, and do not assume `address` is the sum of the parts. Key on `id`, not on `name`. A licence printed in two scripts returns one entry per language under the same `name`, and only `id` is unique across the array. ## Dates and the countdown to the end of validity Every date is ISO 8601. A licence that names the date it runs out also gets `document.days_remaining` and a derived `days_to_expire` field. Both count from the day of the scan and go negative once the licence has run out. The field carries its value as a string, the way every entry of `fields[]` does. ## Next - [The response](/reference/response) — every group, key by key. - [Recognize a passport](/guides/recognize-a-passport) — the zone layouts, for the licences that do carry one. - [Handle errors](/guides/handle-errors) — what an HTTP error means here, as against a `200` that recognized nothing. --- # Handle errors Every error this API returns has the same body and a stable code. Branch on the code. The HTTP status groups codes that need different handling: three codes share `409`, and four share `503`. A handler written against the status alone will retry what it should fix, and give up on what it should retry. ## The one shape ```json { "error": { "code": "validation_failed", "message": "/options: Unrecognized key: \"retain_hour\"", "docs_url": "https://doc.cheap/docs/errors/validation_failed", "request_id": "req_18542f0c-d7cc-400c-976e-6e3b29a07beb", "event_id": null } } ``` | Key | What to do with it | |---|---| | `code` | Branch on it. It is stable, and it is the only value here you should switch on | | `message` | Log it, show it to a developer, and do not parse it | | `docs_url` | The page for this code. Put it in your own log line and a support thread starts with the fix in it | | `request_id` | Quote it in support. It identifies this one request in our logs | | `event_id` | See below | `event_id` is present on every error and non-null only when the failure was unexpected and the service recorded it as something to look at. It is `null` for every refusal you are meant to handle: a bad key, an empty balance, a rate limit. Nothing is wrong on our side in those cases. When it is not `null`, quoting it resolves to that one recorded failure. ## Trigger one This call sends a misspelled option. Strict validation rejects unknown keys rather than ignoring them, and the message names the path that failed. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d '{"image": "aGk=", "options": {"retain_hour": 2}}' ``` ```javascript const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image: "aGk=", options: { retain_hour: 2 } }), }); const body = await response.json(); if (!response.ok) { console.error(response.status, body.error.code, body.error.docs_url); } ``` ```python import json import urllib.error import urllib.request request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": "aGk=", "options": {"retain_hour": 2}}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) try: with urllib.request.urlopen(request) as response: status = response.status body = json.load(response) except urllib.error.HTTPError as failure: status = failure.code body = json.load(failure) print(status, body["error"]["code"], body["error"]["docs_url"]) ``` Note that `fetch` does not raise on a `4xx`, while `urllib` does. Read the body in both branches: an error body is JSON and carries the code you need. ## What to do with each code The contract carries 21 codes, and every one of them has a page. Two are unreachable with an API key, and are listed so that a `docs_url` never points at nothing. | Code | HTTP | Do this | |---|---|---| | [`invalid_request`](/errors/invalid_request) | 400 | Fix the request. Retrying it unchanged fails again | | [`validation_failed`](/errors/validation_failed) | 422 | Fix the field the message names. Retrying it unchanged fails again | | [`unauthorized`](/errors/unauthorized) | 401 | Fix the key or the header. Stop until it is fixed | | [`registration_required`](/errors/registration_required) | 403 | Register and use your own key. Waiting does not refill the allowance | | [`insufficient_credits`](/errors/insufficient_credits) | 402 | Top up, then retry. Nothing was charged and the engine never ran | | [`not_found`](/errors/not_found) | 404 | Stop. No such scan, or its retention window has passed | | [`idempotency_conflict`](/errors/idempotency_conflict) | 409 | The key was used with a different body. Send this body under a new key | | [`idempotency_in_progress`](/errors/idempotency_in_progress) | 409 | Wait a moment, then retry the same request with the same key | | [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | 409 | The first answer is gone. Send the request again under a new key | | [`payload_too_large`](/errors/payload_too_large) | 413 | Shrink the image. Retrying it unchanged fails again | | [`unsupported_media_type`](/errors/unsupported_media_type) | 415 | Send `Content-Type: application/json` | | [`rate_limited`](/errors/rate_limited) | 429 | Wait the `Retry-After` seconds, then retry the same request | | [`document_repeated`](/errors/document_repeated) | 429 | Wait the `Retry-After` seconds, or register. The same image went up too often on the free sandbox | | [`internal_error`](/errors/internal_error) | 500 | Retry once with backoff. Quote `event_id` if it keeps happening | | [`engine_unavailable`](/errors/engine_unavailable) | 503 | Retry with backoff. Nothing was charged | | [`service_unavailable`](/errors/service_unavailable) | 503 | Retry after `Retry-After`. A store we depend on is away | | [`maintenance`](/errors/maintenance) | 503 | Retry after `Retry-After`. Planned work, and nothing is charged | | [`rate_unavailable`](/errors/rate_unavailable) | 503 | Top-up path only. No price source agreed a rate; retry later | | [`topup_in_progress`](/errors/topup_in_progress) | 409 | Top-up path only. One is already settling for this account; wait | | [`impersonation_read_only`](/errors/impersonation_read_only) | 403 | Not reachable with an API key | | [`payment_driver_unavailable`](/errors/payment_driver_unavailable) | 501 | Not reachable with an API key | The full table, with the cause behind each, is [the error reference](/reference/errors). ## Retry the four 503s, and only those Four codes share `503`, and every one of them is worth retrying with backoff. `engine_unavailable`, `service_unavailable` and `maintenance` cost nothing. A credit is reserved before the engine is called, and released when it does not answer. A failed scan leaves the balance where it was. Honor `Retry-After` where the response carries it, rather than guessing an interval. A retry that ignores it arrives inside the same closed window and spends its attempt on a refusal. Send an [`Idempotency-Key`](/guides/retry-safely-with-idempotency) on the original request. A retry after a timeout is the one case where you cannot tell whether the first call landed. The key is what makes the answer safe. ## The shape of a handler Every code in the table falls into one of four buckets, and a handler needs one branch per bucket rather than twenty-one. | Bucket | Codes | Branch | |---|---|---| | Fix the call | `invalid_request`, `validation_failed`, `payload_too_large`, `unsupported_media_type` | Log the message, fail the operation, do not retry | | Fix the account | `unauthorized`, `registration_required`, `insufficient_credits` | Alert an operator; retry only after somebody acts | | Wait and retry | `rate_limited`, `document_repeated`, the four 503s, `internal_error` | Back off, honor `Retry-After`, cap the attempts | | Arbitrate a replay | the three `idempotency_` codes | Keep the key, or take a new one, per the table above | `not_found` is outside all four: it is an answer about a scan that is not there, and the caller decides what that means. ## Reading `Retry-After` The header carries whole seconds. Parse it as an integer and clamp it to something your own system can wait for. Fall back to your own backoff when the header is absent. ```bash curl -sS -D - -o /dev/null -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_live_not_a_real_key" \ -H "Content-Type: application/json" \ -d '{"image": "aGk="}' ``` ```javascript const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_live_not_a_real_key", "Content-Type": "application/json", }, body: JSON.stringify({ image: "aGk=" }), }); const retryAfter = Number(response.headers.get("Retry-After") ?? 0); const wait = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 1; console.log(response.status, wait); ``` ```python import json import urllib.error import urllib.request request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": "aGk="}).encode(), headers={ "Authorization": "Bearer sk_live_not_a_real_key", "Content-Type": "application/json", }, ) try: with urllib.request.urlopen(request) as response: status = response.status headers = response.headers except urllib.error.HTTPError as failure: status = failure.code headers = failure.headers wait = int(headers.get("Retry-After") or 1) print(status, wait) ``` Those three send a key that does not exist, so they answer `401` and carry no `Retry-After`. The fallback is what runs, which is the branch worth proving. ## A `200` that recognized nothing is not an error `no_document_found`, `unreadable`, `unsupported_document` and `rejected` come back with HTTP `200` and a complete body. The engine ran; it found nothing it could publish. A live key is not charged for any of them. Branch on `status` for those, and on `error.code` for the table above. A handler that treats `no_document_found` as a failure retries a photograph that will never read. One that treats it as a success stores a document with no fields in it. ## What to log One line per failure, carrying `code`, `request_id`, `event_id` and `docs_url`. That is enough for someone else to open the right page and quote the right id. Leave out the request body: it carries an identity document, which does not belong in a log. ## Next - [Retry safely with idempotency](/guides/retry-safely-with-idempotency) — the three conflict codes, and what each one arbitrates. - [The error reference](/reference/errors) — all 21 codes with their causes. - [HTTP status codes](/reference/http-status-codes) — which codes share a status, and why. --- # Retry safely with idempotency A recognition that times out on your side may already have run on ours. Retry it blind and you pay twice. Send an `Idempotency-Key` with the original request and the retry returns the first result instead of producing a second one. ## Send a key `Idempotency-Key` is a request header on `POST /v1/scans`, between 1 and 255 characters. Generate one per logical operation — one document you are trying to read — and reuse it for every retry of that operation. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\", \"reference\": \"order-1042\"}" ``` ```javascript import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; const image = readFileSync("document.jpg").toString("base64"); const idempotencyKey = randomUUID(); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ image, reference: "order-1042" }), }); const scan = await response.json(); console.log(idempotencyKey, scan.meta.id, scan.meta.billed); ``` ```python import base64 import json import urllib.request import uuid with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() idempotency_key = str(uuid.uuid4()) request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image, "reference": "order-1042"}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(idempotency_key, scan["meta"]["id"], scan["meta"]["billed"]) ``` A UUID is a good key. A per-document value from your own system is better, because it survives a process restart that a freshly generated UUID would not. ## Where it applies Idempotency arbitrates the billing path, which means a live key. Both sandbox keys run free and store nothing, so a key sent with them is accepted and decides nothing. A key is scoped to your account. Two accounts using the same string never collide. ## What makes two requests the same The key alone does not. Each claim also carries a fingerprint of the request body — a digest over `image`, `options` and `reference`, with the keys sorted so that property order cannot change it. - Same key, same body: a replay. The first result comes back. - Same key, different body: a conflict, refused. Change a single option and the body is different. Reuse the key only for a retry of the same call. ## The three conflict codes All three arrive as `409`, and each says something different about the first request under this key. | Code | What happened | What to do | |---|---|---| | [`idempotency_conflict`](/errors/idempotency_conflict) | The key was already used with a different body | Send this body under a new key | | [`idempotency_in_progress`](/errors/idempotency_in_progress) | The first request is still running | Wait a moment, then retry the same request with the same key | | [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | The first request finished, and its result is no longer stored | Send the request again under a new key | Never treat `409` as one case. `idempotency_in_progress` means keep the key; the other two mean take a new one, and retrying either with the same key repeats the refusal. ## The wait before `idempotency_in_progress` A claim is taken before the engine runs. A second caller arriving while the first is still inside recognition therefore finds a claim with no result on it. That second caller is not sent away at once. Nor is it allowed to redo the work: redoing it is the double charge the key exists to prevent. It waits a short time for the first request to land, re-checking as it waits. It replays the result when one arrives. Only when the wait runs out does it answer `idempotency_in_progress`. That wait is deliberately short, because it is held against the second caller's own request timeout. A claim whose request died mid-flight would otherwise hold the key forever. Past a much longer deadline it counts as abandoned, and the next caller takes it over. ## A retry loop Retry on a transient refusal, keep the key, and honor `Retry-After` where it is present. Cap the attempts: a loop with no ceiling turns one slow minute into an outage of your own. ```bash curl -X POST https://api.doc.cheap/v1/scans \ --retry 3 \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; const image = readFileSync("document.jpg").toString("base64"); const idempotencyKey = randomUUID(); const retryable = new Set([429, 500, 502, 503, 504]); let response; for (let attempt = 0; attempt < 4; attempt += 1) { response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ image }), }); if (!retryable.has(response.status)) break; const after = Number(response.headers.get("Retry-After") ?? 0); const seconds = after > 0 ? after : 2 ** attempt; await new Promise((done) => setTimeout(done, seconds * 1000)); } console.log(response.status); ``` ```python import base64 import json import time import urllib.error import urllib.request import uuid with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() idempotency_key = str(uuid.uuid4()) retryable = {429, 500, 502, 503, 504} status = None for attempt in range(4): request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", "Idempotency-Key": idempotency_key, }, ) try: with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) break except urllib.error.HTTPError as failure: status = failure.code if status not in retryable: break time.sleep(int(failure.headers.get("Retry-After") or 2 ** attempt)) print(status) ``` `curl --retry` retries a transient failure on its own, and since version 7.66 it obeys a `Retry-After` header when the response carries one. The two scripts do the same thing by hand, because the header has to be read either way. Do not retry a `409`. The three conflict codes above are answers, not transients, and two of them say the key itself has to change. ## Why a replay can become unavailable A replay returns the stored result of the first call. When nothing was stored, there is nothing to return, and the answer is `idempotency_replay_unavailable` rather than a silent re-run. That is what `retain_hours: 0` does: it writes no row at all. If you want retries to replay, ask for a retention window on the original request, at any value above zero. Otherwise a replay past it needs a new key, which is what the message on that code says. ## What a retry costs Nothing, when it replays. A replay does not call the engine and does not draw a credit. The credit was drawn by the first call, and `billed` on the replayed body is the first call's answer. A failure before the engine ran costs nothing either. When recognition fails after the credit was reserved, the reservation is released and the claim is dropped. The key is then free for an honest retry. ## Next - [Handle errors](/guides/handle-errors) — every code, and what to do with it. - [Idempotency](/reference/idempotency) — the exact rules, including how long a key is remembered. - [Control history retention](/guides/control-history-retention) — the window a replay depends on. --- # Control history retention A recognition result can be read back later, and how long it stays readable is your choice. This guide covers the account default, the per-request override, and what shortening a window does to results you already have. ## Pick the account default Open the dashboard at and choose one of four windows. | Setting | Hours | Pick it when | |---|---|---| | 24 hours | 24 | A result is consumed the same day and nothing needs it after that | | 7 days | 168 | A case is worked within a week | | 1 month | 720 | A result is evidence for a decision that can be reopened | | 1 year | 8760 | You want the longest history the service offers | A new account starts on 1 year. The setting applies to every scan made with a live key that named no window of its own. Every upload made from the dashboard is one of those. ## Override it on one request Send `options.retain_hours` when one request must differ from the account setting. The value is a whole number of hours from 0 to 8760. An explicit value always wins, including zero. The account setting decides only what happens when the request is silent. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\", \"options\": {\"retain_hours\": 24}}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image, options: { retain_hours: 24 } }), }); const scan = await response.json(); console.log(scan.meta.id, scan.meta.created_at); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() body = {"image": image, "options": {"retain_hours": 24}} request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps(body).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(scan["meta"]["id"], scan["meta"]["created_at"]) ``` The window runs from `meta.created_at`, not from the last read. Reading a result does not extend it. ## Keep nothing at all Send `retain_hours: 0`. No row is written, so nothing expires and nothing has to be deleted later. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\", \"options\": {\"retain_hours\": 0}}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image, options: { retain_hours: 0 } }), }); const scan = await response.json(); console.log(scan.meta.billed, scan.holder.full_name); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() body = {"image": image, "options": {"retain_hours": 0}} request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps(body).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(scan["meta"]["billed"], scan["holder"]["full_name"]) ``` A zero-retention scan is still charged and still counted in usage. What it does not do is leave a row behind. > **Warning.** `retain_hours: 0` also removes the replay an > `Idempotency-Key` would have served. A retry under the same key answers 409 > [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable), > because nothing was kept to replay. ## Read a result back `GET /v1/scans/{id}` returns a scan while its window lasts, and 404 after it. Only a live key reads stored scans. A sandbox key answers 404 for every id, including its own. ```bash curl https://api.doc.cheap/v1/scans/01a0af18-cd8d-7a61-9f2d-4c7b8e105da3 \ -H "Authorization: Bearer sk_sandbox_public" ``` ```javascript const response = await fetch( "https://api.doc.cheap/v1/scans/01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", { headers: { Authorization: "Bearer sk_sandbox_public" } }, ); const body = await response.json(); console.log(body.error.code, body.error.docs_url); ``` ```python import json import urllib.error import urllib.request request = urllib.request.Request( "https://api.doc.cheap/v1/scans/01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", headers={"Authorization": "Bearer sk_sandbox_public"}, ) try: with urllib.request.urlopen(request) as response: status = response.status body = json.load(response) except urllib.error.HTTPError as error: status = error.code body = json.load(error) print(status, body["error"]["code"]) ``` ## List what is still readable `GET /v1/scans` returns the account's history rows, most recent first. A row is the summary a list view needs, not the whole result. | Key | Carries | |---|---| | `id` | The scan id, which is what `GET /v1/scans/{id}` takes | | `status` | The recognition status | | `billed` | Whether the scan drew a credit | | `duration_ms` | Server-side processing time | | `reference` | Your own string, echoed back | | `created_at` | When the scan was made, and what the window runs from | Only scans made with a live key under a non-zero window are listed, and only while that window lasts. A sandbox key sees an empty list rather than the account's. The extracted data and the crops are not in a row. Read one scan back by id when you need them. ## Choose a window for the job Match the window to what the result is for, rather than keeping everything for a year because that is the default. - **A result consumed on receipt** needs none. Send `retain_hours: 0` and store what you need in your own system. - **A result a support agent may be asked about** needs the window your support promise covers, plus a margin. - **A result that backs a decision somebody can reopen** needs the window that process runs for. Setting a long window on the account and sending `retain_hours: 0` for the traffic that does not need it is a working pattern. The override is per request, so the two do not fight. Weigh the window against what a stored row is. It is identity-document data about a real person, and the shortest window that does your job is the one to pick. ## What is kept, and what is not A stored scan is the reading, not the picture. - The image crops are never written down. A scan read back answers with every slot of `images` set to null, whatever the original call returned. - The engine's own quality measurement is not kept either, so `quality.overall` reads `not_checked` on a read-back rather than `pass`. - A 96 px thumbnail of at most 16 KiB is kept beside the row. It shows which document a row is about in the dashboard's operations log, and it is not readable through the API. - Everything else comes back as it was sent to you: `meta`, `document`, `holder`, `fields` and `mrz`. ## Shortening the window back-dates what you already have Choosing a shorter setting applies to the results already stored, in the same step as the choice itself. Each row is measured from its own `meta.created_at`. A scan made yesterday under the 1 year setting expires one day after it was made once the setting becomes 24 hours. It does not get a fresh day. Rows the change has already pushed past their window go immediately, and their thumbnails are queued for deletion in the same step. A person who asks for less history gets less history now. Lengthening the window never brings anything back. A row already deleted stays deleted, and a row written under a shorter window keeps the shorter one. The new setting governs the scans made after it. ## Next - [Data retention and privacy](/concepts/data-retention-and-privacy) — why the image is never stored. - [Scan options](/reference/scan-options) — every option and its default. - [Limits](/reference/limits) — the range `retain_hours` accepts. --- # Rotate API keys A key that has leaked, or that has been in one deployment for a long time, should be replaced. This guide covers the replacement that leaves no window in which calls fail. Rotate on a schedule you choose, and rotate at once when a key reaches a log, a ticket, a screenshot or a repository. ## Understand what identifies a key A key is shown in full once, at the moment it is created. Copy it then; it cannot be read again from anywhere. What identifies it afterwards is its **prefix**: the kind marker plus the first 8 characters of the secret body, as in `sk_live_9c41ba2e`. The dashboard, the listing and your own records all work from that. The prefix also carries the kind. A key whose prefix begins `sk_sandbox_` is never charged, and the kind is read from those characters rather than looked up anywhere. A key that starts `sk_live_` bills. An account holds up to 10 active keys, which is what makes the overlap below possible. ## Overlap, then revoke Do the three steps in this order. The old key keeps working throughout. 1. **Issue the new key.** Open , create a key of the same kind, and give it a name that says where it is going. Copy the secret. 2. **Deploy it.** Put the new secret into the configuration of every service that calls the API, and roll them. Both keys authenticate during this window, so a half-rolled fleet is a fleet that still works. 3. **Revoke the old key.** Come back to the key list and revoke the one you replaced. Do it only once nothing sends it any more. The list shows a **Last used** column for every key. It is stamped at most once a minute, so a key that has been quiet for an hour has genuinely been quiet. Wait for the old key to go quiet before step 3, and the rotation costs no failed call. > **Warning.** The **Rotate** action in the dashboard is not this procedure. It > withdraws the old secret at once and issues the replacement in the same step, > so every caller still holding the old one starts failing. Use it when a key > has leaked and the leak matters more than the gap. ## Verify the new key before you revoke anything Make one call with the new secret and check that it answers 200. Read the key out of the environment rather than pasting it into the code you deploy. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer ${DOC_CHEAP_API_KEY:-sk_sandbox_public}" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; const key = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); console.log(response.status, key.slice(0, 16)); ``` ```python import base64 import json import os import urllib.request key = os.environ.get("DOC_CHEAP_API_KEY", "sk_sandbox_public") with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status print(status, key[:16]) ``` ## What a revoked key answers A revoked key stops authenticating immediately, and it is refused the way an invented key is: 401 [`unauthorized`](/errors/unauthorized). A caller cannot tell a revoked key from one that never existed, which is deliberate. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_live_revoked_example" \ -H "Content-Type: application/json" \ -d '{"image": "aGVsbG8="}' ``` ```javascript const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_live_revoked_example", "Content-Type": "application/json", }, body: JSON.stringify({ image: "aGVsbG8=" }), }); const body = await response.json(); console.log(body.error.code, body.error.docs_url); ``` ```python import json import urllib.error import urllib.request request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": "aGVsbG8="}).encode(), headers={ "Authorization": "Bearer sk_live_revoked_example", "Content-Type": "application/json", }, ) try: with urllib.request.urlopen(request) as response: status = response.status body = json.load(response) except urllib.error.HTTPError as error: status = error.code body = json.load(error) print(status, body["error"]["code"]) ``` A revoked key also drops out of the key list. Withdrawal is permanent, so keep your own note of which prefix served which deployment. ## Keep an inventory you can act on A rotation is fast when you already know which key is where. Keep three things for every live key you issue. | Record | Why it is needed | |---|---| | The prefix | The only identifier that survives after the secret is shown | | Where the secret is configured | What has to be rolled before the old key goes | | Who issued it, and when | What tells a stale key from a current one | Name the key for its destination when you create it. A list of keys named for their services is an inventory; a list of keys named "key 3" is not. Rotate one destination at a time. A rotation that touches two services at once turns a failure into a question about which one it came from. ## Set an expiry when the key is temporary A key can be created with an end of validity. Past it the key is refused like an unknown one, with no action needed from you. Use it for a contractor, a migration or a trial integration. A key that expires on a date you chose is one you cannot forget to withdraw. ## Next - [API keys and sessions](/concepts/api-keys-and-sessions) — the three kinds of key and why they are kept apart. - [Handle errors](/guides/handle-errors) — what to branch on when a call is refused. - [Limits](/reference/limits) — the rate a registered key is allowed. --- # Check an MRZ The machine-readable zone (MRZ) carries its own check digits, and this service publishes the zone verbatim so that you can re-run them. This guide covers reading our verdict, re-checking it yourself, and deciding what a failure means. Re-checking is worth doing when the document decides money or access. It is the one part of the answer you can verify without trusting us. ## Read the verdict Every result carries `mrz.status`, which is `passed`, `failed` or `absent`. On `failed`, `mrz.reason` is one sentence naming what did not check out. Branch on `status` first. Most integrations need nothing else, and the digits below are for the cases where they do. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); const scan = await response.json(); console.log(scan.mrz.status, scan.mrz.reason, scan.mrz.text); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(scan["mrz"]["status"], scan["mrz"]["reason"], scan["mrz"]["text"]) ``` ## Hand `mrz.text` to a checksum routine `mrz.text` is the zone's lines run together with nothing between them: one unbroken string, no newlines and no spaces. The zone's alphabet is `A-Z`, `0-9` and the filler `<`, so joining the lines loses nothing. That is the value a checksum library wants. Pass it unchanged. ```text P. It runs every digit of the format it detects and shows each one computed beside the one the zone prints. It needs no account and no key, and it is the fastest way to settle a disagreement between two implementations. ## Compare the zone with the printed page A check digit proves the zone is internally consistent. It does not prove the zone agrees with the rest of the document. The verdict covers both. A `failed` status can name a cross-zone disagreement even when every digit checked out, and the sentence then says which field disagreed. Both zones are read on a document that carries both, and each value appears as its own reading in `fields`. A disagreement is visible there as two entries under one `name` with different values. Treat the two clauses differently. A digit failure points at the reading; a cross-zone mismatch points at the document. ## Handle a document with no zone `mrz.status: absent` means the document carries no machine-readable zone, or none was read. `reason`, `lines` and `text` are all null. A driving licence is the common case, and the front of an identity card is the other. Neither is a failure. Such a document offers nothing to re-check. The values come from the printed page, and what backs them is the per-field confidence rather than a check digit. ## Decide what a failure means `mrz.reason` is assembled from two clauses, and they say different things. | Reason names | What it points at | |---|---| | `Check digit failed for: document number` | One field of the zone did not check out | | `MRZ check digits did not validate` | The zone failed as a whole and no single field could be blamed | | `MRZ does not match the visual zone for: surname` | The zone and the printed page disagree | The second sentence is worth recognizing exactly. It appears when the aggregate verdict is a failure but no individual field was marked invalid, so there is nothing more specific to name. A failure is not by itself a verdict on the document. Three ordinary causes come first. - **A poor photograph.** A glare across one character changes a value and breaks the digit it feeds. Re-photograph and scan again. - **A damaged document.** A worn or creased zone reads wrong in the same way. - **A transcription somewhere upstream.** A zone that was retyped by a person at any point is a zone that no longer checks out. A cross-zone mismatch is the one worth escalating. The zone and the printed page were produced together, and a document where they disagree is one for a person to look at. ## Next - [MRZ reference](/reference/mrz) — the three formats, the fields each one fills and the shape of every verdict. - [The MRZ and the visual zone](/concepts/mrz-and-the-visual-zone) — why a document says the same thing twice. - [Recognize an ID card](/guides/recognize-an-id-card) — reading the zone off the back of a card. --- # Work with result images A recognition returns crops of what it found: the document itself, the holder's photograph, the signature and a few more. This guide covers asking for them, reading them, and the properties that decide how you can use them. Every crop is small on purpose. They identify and illustrate a result; they are not a scan of the document. ## Read them out of the response `images` carries seven slots, and each one is either a `data:` URL or null. A slot is null when the document carried nothing for it. | Slot | What it is | |---|---| | `document_crop` | The document, cropped out of your picture and deskewed | | `rear` | The reverse side, when the picture carried one | | `main_photo` | The holder's photograph as printed | | `signature` | The printed signature | | `watermark_face` | The faint second copy of the face printed as a security feature | | `barcode` | The barcode region | | `chip` | The chip region | ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" \ --output scan.json ``` ```javascript import { readFileSync, writeFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); const scan = await response.json(); const crop = scan.images.document_crop; if (crop !== null) { const payload = crop.slice(crop.indexOf(",") + 1); writeFileSync("document-crop.jpg", Buffer.from(payload, "base64")); } console.log(Object.entries(scan.images).filter(([, value]) => value !== null).length); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) crop = scan["images"]["document_crop"] if crop is not None: payload = crop.split(",", 1)[1] with open("document-crop.jpg", "wb") as out: out.write(base64.b64decode(payload)) print(len([value for value in scan["images"].values() if value is not None])) ``` The curl block saves the whole body. Decode the payload after the comma of the `data:` URL to get the bytes; the two blocks beside it do exactly that. The specimen passport of Eleni Sofia Paradeigma fills four of the seven slots. The payloads are elided here after their first bytes. ```json { "meta": { "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", "status": "recognized" }, "images": { "document_crop": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD…", "rear": null, "main_photo": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD…", "signature": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD…", "watermark_face": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD…", "barcode": null, "chip": null } } ``` Check for null before you decode. A passport carries no barcode, and a picture of one side carries no `rear`. ## Turn the portrait off Send `return_portrait: false` when you do not want the holder's face in the response at all. The slot comes back null, and nothing was kept anywhere. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\", \"options\": {\"return_portrait\": false}}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image, options: { return_portrait: false } }), }); const scan = await response.json(); console.log(scan.images.main_photo, scan.meta.billed); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() body = {"image": image, "options": {"return_portrait": False}} request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps(body).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(scan["images"]["main_photo"], scan["meta"]["billed"]) ``` It changes nothing about the price. The engine still ran, and the scan is billed on what it read. ## Size your layout for the caps Every crop is scaled down by height to a cap before it is published. | Slot | Height cap | |---|---| | `document_crop` | 250 px | | Every other slot | 100 px | The scaling is proportional and by height only, and nothing is ever scaled up. A crop the engine produced below its cap is published at the size it came out. Lay out for the cap rather than for a measured sample. A 100 px face in a 120 px box is the case to design for. Do not plan to enlarge these for display. They are sized to identify a result, and a `document_crop` blown up to full width is a blurred document. ## Know what the re-encode does A PNG source is written back as PNG. Everything else is written as JPEG at quality 90. The re-encode works from decoded pixels and nothing is copied across, so **EXIF, ICC and every other metadata block is dropped**. No camera model, no timestamp and no GPS tag reaches a published crop. The orientation tag is not applied either. A crop is the stored pixel grid, which is what the height cap is computed over. ## Nothing here can fail your scan Image processing never turns a recognition into an error. An undecodable blob, a format the encoder will not write, or a source with no readable size all answer with the original bytes. A crop that came back uncapped is that path. You still get an image, and the recognition you paid for is still the recognition you get. ## Budget for the body they add Every crop travels base64-encoded inside the JSON body, which makes it about a third larger than the bytes. The caps keep that small. A 250 px document crop and a handful of 100 px illustrations are tens of kilobytes, against a request body that carried the whole photograph. Turn off what you do not render. `return_portrait: false` is the one switch, and the other slots are filled only when the document carried them. ## Know what these crops are not They are illustrations of a result, and three uses they do not support are worth naming. - **Not a source to re-recognize from.** A 100 px face and a 250 px document have lost the print the engine read. Re-send the original photograph. - **Not an archival copy of the document.** They are capped by height and re-encoded, so they are smaller than what you sent in every dimension. - **Not proof of what was uploaded.** Every metadata block is dropped, so a crop cannot be tied back to one camera or one file. Keep your own original if your process needs one. What you sent is yours, and it is not kept here. ## Remember they are not stored The crops travel in the response of the call that produced them, and nowhere else. Read them back later with `GET /v1/scans/{id}` and every slot is null, whatever the original call returned. Save what you need at the moment you receive it. What we keep beside a retained scan is a 96 px thumbnail of at most 16 KiB. It is shown in the dashboard's operations log and is not readable through the API. ## Next - [Result images](/reference/images) — the slots, the caps and the re-encode as reference. - [Data retention and privacy](/concepts/data-retention-and-privacy) — why no crop is written down. - [Scan options](/reference/scan-options) — `return_portrait` and the rest. --- # Handle non-Latin scripts A document printed in a national script usually prints the holder's name twice: once in that script, and once transliterated into Latin. This guide covers getting both spellings and storing them without losing one. Nothing has to be requested. Both readings are already in the response, and the work is knowing which entry is which. ## Ask for nothing; read `fields` Every reading the engine made appears as its own entry of `fields`. A name read in two scripts is two entries, not one entry with two values. Two keys tell them apart. - `language` is `null` on the neutral, transliterated Latin reading, and names the language on the other. - `id` is `name@lcid`, where the number is the language identifier the reading was made under. `0` is the neutral one. ```json [ { "id": "surname@0", "name": "surname", "label": "Surname", "category": "identity", "value": "PARADEIGMA", "language": null, "confidence": "high" }, { "id": "surname@1032", "name": "surname", "label": "Surname", "category": "identity", "value": "ΠΑΡΑΔΕΙΓΜΑ", "language": "Greek", "confidence": "high" } ] ``` `name` repeats across the pair and `id` does not. Key your own records on `id`. A list keyed on `name` collapses the two readings into one and keeps whichever arrived last. ## Group the readings by field Collect the entries under their `name`, then pick a spelling per reading. The grouping is three lines in any language. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); const scan = await response.json(); const byName = new Map(); for (const field of scan.fields) { const readings = byName.get(field.name) ?? []; readings.push({ value: field.value, language: field.language }); byName.set(field.name, readings); } console.log(byName.get("surname")); ``` ```python import base64 import json import urllib.request from collections import defaultdict with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) by_name = defaultdict(list) for field in scan["fields"]: by_name[field["name"]].append( {"value": field["value"], "language": field["language"]} ) print(by_name["surname"]) ``` ## Know which value each entry carries The two readings carry different spellings, and the rule is fixed. | `language` | `value` | |---|---| | `null` | The merged Latin value, transliterated where the document was not printed in Latin | | A language name | The spelling the document prints in that script | `holder.given_names`, `holder.surname` and `holder.full_name` always carry the Latin reading. They are the values to index, to compare against a watchlist and to send to a system that speaks one alphabet. Read the `fields` entry when you want what the document actually prints. A receipt, a letter or a screen shown to the holder wants that one. ## Understand how a language is named The engine reports a language as a number, and it is resolved in three steps that narrow. 1. **The identifier itself**, looked up in a table of 418 assigned identifiers. `1032` resolves to `Greek`. 2. **The primary language family**, when the identifier is not assigned. A sublanguage nobody registered still belongs to its family, and there are 135 of those, so the reading says `Arabic` rather than nothing. 3. **A literal naming the number**, in the hexadecimal the reference is written in, when even the family is unknown. A reading then says `Language 0x4C0A`. Nothing is guessed at any step. A reading never claims a language that the number does not support. Branch on the presence of `language`, not on its exact string. The third step exists so that an unrecognized identifier is still reported honestly, and a `switch` over language names will meet one eventually. ## Label the readings for a person Every entry carries a `label` and a `category` beside its value, so a table can be rendered without a mapping of your own. - `label` is the human name of the field, such as `Surname`. It is the same for both readings of a pair, because they are the same field. - `category` is one of `identity`, `document`, `dates`, `address`, `visa` and `other`. Group by it and the report comes out in the order a person reads a document in. Show the language beside a national-script reading. A reader looking at two spellings of one name needs to be told which is which, and `language` is the word to print. ## Know what the transliteration is The Latin value is the transliteration the recognition made, or the spelling the document itself printed in Latin. It is not re-derived by us, and no second transliteration is applied on top. What you get is one reading per language, as read. A transliteration is not reversible. `PARADEIGMA` does not carry enough to reconstruct `ΠΑΡΑΔΕΙΓΜΑ`, which is why both readings are published rather than one. `document.country` is the ISO 3166-1 alpha-3 code and is what to key on. `document.country_name` is a name for a person to read, and a name is not a stable identifier. ## Render right-to-left values correctly A value in Arabic or Hebrew script is stored left to right and displayed right to left. Mixed with a Latin document number, it renders wrongly unless the direction is set. Set the direction from the content rather than from the page. In HTML that is `dir="auto"` on the element carrying the value. Do not concatenate a right-to-left value with Latin text into one string for display. Put each in its own element, and let each take its own direction. ## Expect the zone to stay Latin The machine-readable zone is Latin by definition. Its alphabet is `A-Z`, `0-9` and the filler `<`. A national-script reading therefore has nothing in the zone to compare against. Its entry is never the subject of a cross-zone mismatch. A disagreement between the zone and the printed page is reported against the Latin reading. A transliteration that differs from the zone is worth checking. The zone follows the issuer's own transliteration rules, and a document can print one spelling on the page and another in the zone. ## Store both spellings Three habits keep a national-script value intact between our response and your screen. 1. **Store in a Unicode column and serve UTF-8.** A value that survives our response and dies in your database is the usual failure. 2. **Keep the pair together.** Store the Latin reading and the national-script reading in two columns, keyed by the field's `id`. Re-deriving one from the other is a transliteration you would have to own. 3. **Sort on the Latin reading.** Alphabetical order across scripts is not a question with one answer, and the Latin reading is the one every consumer can order. A name never contains a line break in any script. Every break in the source is replaced by one space before the value reaches you, so a two-line printed name arrives as one line. ## Next - [Field languages and scripts](/reference/fields/languages) — the full table of identifiers and what each resolves to. - [Field catalogue](/reference/fields) — every key, its label and its category. - [Confidence and readings](/concepts/confidence-and-readings) — why two readings of one field may disagree. --- # Get results without webhooks Recognition is synchronous. `POST /v1/scans` returns the finished result in the response of the call that started it. This guide covers what that means for a worker, a queue and a timeout. If you came looking for a callback URL to register, that is the answer: none exists, because nothing is ever delivered later. ## Read the result from the response The call returns 200 with the whole result. No job id is handed out, nothing is queued on our side, and no second request is needed. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}" ``` ```javascript import { readFileSync } from "node:fs"; const image = readFileSync("document.jpg").toString("base64"); const response = await fetch("https://api.doc.cheap/v1/scans", { method: "POST", headers: { Authorization: "Bearer sk_sandbox_public", "Content-Type": "application/json", }, body: JSON.stringify({ image }), }); const scan = await response.json(); console.log(scan.meta.status, scan.meta.timing); ``` ```python import base64 import json import urllib.request with open("document.jpg", "rb") as file: image = base64.b64encode(file.read()).decode() request = urllib.request.Request( "https://api.doc.cheap/v1/scans", data=json.dumps({"image": image}).encode(), headers={ "Authorization": "Bearer sk_sandbox_public", "Content-Type": "application/json", }, ) with urllib.request.urlopen(request) as response: status = response.status scan = json.load(response) print(scan["meta"]["status"], scan["meta"]["timing"]) ``` `GET /v1/scans/{id}` exists for reading a result back later, while its retention window lasts. It is a history lookup, not a way of collecting an answer that was not ready. ## Put the call on a worker A synchronous call that takes a second does not belong on the thread serving your own users. Move it behind your own queue. 1. Accept the upload from your user and store the image where your worker can reach it. 2. Answer your own user at once with an identifier of your own. 3. Have a worker make the scan call, and write the result against that identifier. 4. Let your user poll your own endpoint, or push to them over whatever channel you already have. Your queue does the waiting, which is what a webhook would have bought you. What it also buys is retry control and a place to put a failure, both of which stay inside your system. Put your own identifier in `reference`. It is echoed back in `meta.reference`, so the worker's result carries the job it belongs to. ## Bound your concurrency A registered key is allowed 60 requests per minute. Size the worker pool so the fleet stays under that, and treat 429 [`rate_limited`](/errors/rate_limited) as back-pressure rather than as an error. The response carries `Retry-After` on a 429. Wait that many seconds and send the same request again. ## Size the timeout from the published figures Set a client timeout from what the service actually takes, not from a round number. `meta.timing` splits the wait into three numbers, and they measure different things. | Key | Covers | |---|---| | `upload_ms` | Your bytes arriving and being validated, with your key resolved and its rate limit checked | | `processing_ms` | The recognition call itself, and nothing else | | `total_ms` | The whole request, from the first byte to the finished result | Our own post-processing sits deliberately outside `processing_ms`. The crops are resized and re-encoded after the engine has answered. Counting that as recognition time would make the number say something it does not. The allowance and credit gates sit outside `upload_ms` for the same reason: they are ours, not your link. They fall under `total_ms`. A recognition of the specimen passport of Eleni Sofia Paradeigma reports the three like this. ```json { "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", "status": "recognized", "timing": { "upload_ms": 118, "processing_ms": 684, "total_ms": 826 } } ``` Use `total_ms` to size a timeout and `upload_ms` to tell your own link from our service. A large `upload_ms` on a small image is your network, not our engine. The live percentiles are published. `GET https://api.doc.cheap/status/summary.json` carries `recognition_ms`, a true p50 and p95 over the last 24 hours, taken over a synthetic recognition run once a minute over the real path. ```text "recognition_ms": { "p50": 612, "p95": 1144, "samples": 1437, "window_hours": 24 } ``` Set the timeout above the published p95 and well above it for a retry budget. The route needs no key and answers every origin, so a deployment script can read it. The rest of what that document carries is on [service levels](/reference/service-levels). ## Make the retry safe A client timeout does not mean the scan did not run. The work may have completed after your socket gave up, and the credit with it. Send an `Idempotency-Key` on every scan a worker makes. A retry under the same key returns the first result instead of charging a second time. Three separate 409 codes arbitrate that replay, and which one you get says what to do next: [retry safely with idempotency](/guides/retry-safely-with-idempotency). ## Give your own caller something to poll Your users still want the pattern a webhook would have given them, and your own queue is where it belongs. 1. Return your own job id when you accept the upload. 2. Expose a status endpoint of your own that answers `pending`, `done` or `failed` for that id. 3. Write the scan result against the id the moment the worker has it. Poll your own endpoint from your own client. Do not poll `GET /v1/scans/{id}` waiting for a result to appear. A scan that was never made is a 404 for ever, and a scan that was made was already in the response your worker received. ## Treat 503 as a wait, not a failure Two codes say the service cannot run your scan right now, and both carry `Retry-After`. - [`engine_unavailable`](/errors/engine_unavailable) — recognition is not reachable. Nothing was charged. - [`service_unavailable`](/errors/service_unavailable) — a store the request needed is unreachable. Requeue the job rather than failing it to your user. The decision table over all 21 codes is on [handle errors](/guides/handle-errors). ## Next - [Retry safely with idempotency](/guides/retry-safely-with-idempotency) — a retry that cannot charge twice. - [Service levels](/reference/service-levels) — what the published percentiles are a percentile of. - [Reliability](/concepts/reliability) — what is measured, and what is promised. --- # Top up with crypto Credits are bought with cryptocurrency. This guide covers taking a quote, sending the transfer, and reading what happened when the amount does not match the quote. One credit is one US cent and buys the recognition of one document. A top-up converts an amount of coin into a whole number of credits. ## Take a quote Open and choose an asset and a number of credits. | Asset | Sent on | |---|---| | `btc` | Bitcoin | | `eth` | Ethereum | | `trx` | Tron | | `usdt_erc20` | USDT on Ethereum | | `usdt_trc20` | USDT on Tron | The two USDT entries are different assets, not one asset on two chains. A transfer of one is not a transfer of the other, so pick the chain you will actually send from. The quote answers with three things. The amount of coin to send, the deposit address to send it to, and the price it was computed at. The minimum quote is $1, because below that the chain fee is most of the deposit. ## Send the transfer Send the quoted amount to the quoted address, from a wallet you control. The address is **permanent and belongs to your account**. It does not change between top-ups, so a wallet that has it saved can keep using it. Sending to it without a quote still works; the deposit is credited at the price when it is credited. Send the asset the quote named, on the chain the quote named. USDT sent on Tron to an Ethereum address is not a deposit we can see. > **Warning.** Do not send from an exchange account that deducts its withdrawal > fee from the amount. What arrives is what is credited, and a fee taken out of > the transfer makes the arrival smaller than the quote. ## Watch it arrive The top-up page follows the deposit through three states. 1. **Quoted.** The amount and the address are fixed and the price is locked. The quote stays live for 2 hours. 2. **Seen.** The transfer has been found on the chain and is waiting to be buried under enough confirmations. A larger deposit waits for a deeper burial than a small one. 3. **Credited.** The credits are on the balance, and the operations log carries the entry. A deposit is never credited before it is deep enough. That wait is the price of making a reversal cost more than the credit it would claw back. ## Know what the locked price covers The quote locks a price, and the lock has bounds in both time and amount. | Bound | Rule | |---|---| | Time | The lock holds for the 2 hour quote window plus a **6 hour grace** past it | | Amount | The lock funds the quoted amount plus **2 %**, counted across every transfer the quote ever attracts | | Order | A transfer whose block is dated before the quote gets no lock at all | Inside all three, the coin converts at the price you were shown. Outside any of them, what falls outside converts at the **corroborated current price**. The deposit is then flagged as an anomaly for an operator to look at. The 2 % tolerance exists for the wallet that rounds the amount up, or takes its network fee out of it. It is not a window to send more coin through at an old price. The grace exists because a payment sent inside the window still has to confirm. Six hours is longer than any of these chains needs, and short enough that holding a quote open is not worth anything. A late payment is still credited. What it loses is the old price, not the money. ## Read what a mismatch does Three outcomes account for every amount that is not the quoted one. - **Less than quoted.** The deposit is credited for exactly what arrived, converted at the locked price. Send the difference to the same address to make it up. - **Up to 2 % more.** The whole amount converts at the locked price. - **More than that.** The quoted portion converts at the locked price and the excess at the current one, in the same ledger entry and the same commit. The conversion always rounds **down** to a whole credit. A fraction of a cent is not credited, and a transfer worth less than one credit is too small to credit at all. ## Understand when there is no price A price is used only when independent sources corroborate it. Three or more sources are reduced to their median. When exactly two answered, the pair is used only if the two agree within 2 %. Two readings always sit the same distance from their own midpoint, so no tolerance can separate them. With no agreement, no price is used. A quote that cannot be priced answers [`rate_unavailable`](/errors/rate_unavailable). Retry shortly. A deposit that arrives while no price is available waits for the next pass rather than being credited at a number nobody corroborated. USDT is priced like every other asset rather than assumed to be one dollar. A reading outside 0.95 to 1.05 is refused rather than clamped into the band. ## If credits do not arrive Work through this in order. 1. **Check the chain.** Find the transaction in a block explorer and confirm it reached the address the quote gave you, on the chain the quote named. 2. **Wait for the depth.** A confirmed transfer is not yet a buried one. Bitcoin takes the longest. 3. **Check the amount.** A transfer worth less than one credit after conversion is not credited. 4. **Give it a day.** Past the live window, the addresses are re-read by a daily pass for 30 days. 5. **Ask for help.** Past those 30 days the automation stops, but the address stays valid and the funds are not lost. Send us the transaction hash. A second quote while one is still open answers [`topup_in_progress`](/errors/topup_in_progress). Finish or abandon the first one. ## Remember the balance cannot go negative A scan is refused with 402 [`insufficient_credits`](/errors/insufficient_credits) before the engine is called, rather than running the scan and leaving a debt. The floor is not a check in the application. A database constraint refuses the entry that would take a balance below zero, so there is no code path that overdraws an account. ## Next - [Crypto deposits](/concepts/crypto-deposits) — the reasoning behind the locks, the depths and the price rules. - [Track usage and spend](/guides/track-usage-and-spend) — reading the balance the credits landed on. - [What a billed scan is](/concepts/what-a-billed-scan-is) — what a credit buys. --- # Track usage and spend `GET /v1/usage` answers with the balance and the counters for the current period. This guide covers reading it, what each figure counts, and reconciling it against your own records. One credit is one US cent and buys the recognition of one document. Every figure below is in those units. ## Read the counters The call takes the same `Authorization` header a scan does and returns 200. ```bash curl https://api.doc.cheap/v1/usage \ -H "Authorization: Bearer sk_sandbox_public" ``` ```javascript const response = await fetch("https://api.doc.cheap/v1/usage", { headers: { Authorization: "Bearer sk_sandbox_public" }, }); const usage = await response.json(); console.log(usage.balance_credits, usage.credits_spent, usage.scans.total); ``` ```python import json import urllib.request request = urllib.request.Request( "https://api.doc.cheap/v1/usage", headers={"Authorization": "Bearer sk_sandbox_public"}, ) with urllib.request.urlopen(request) as response: status = response.status usage = json.load(response) print(usage["balance_credits"], usage["credits_spent"], usage["scans"]["total"]) ``` The body has four parts. | Key | Carries | |---|---| | `balance_credits` | Credits available to the account right now, or null for a key with no account | | `period` | The bounds of the current period, as a UTC calendar month | | `scans` | `total`, `billed`, and `by_status` with one count per recognition status | | `credits_spent` | Credits charged within the period | `balance_credits` is a **current** figure and does not belong to the period. Everything else in the body does. ## Read the status breakdown `scans.by_status` carries one count for each of the five recognition statuses. | Status | Means | |---|---| | `recognized` | The document type was determined and a zone or the printed page was read | | `no_document_found` | Nothing in the frame was located as a document | | `unsupported_document` | A document was located, and its type is not one the engine reads | | `unreadable` | The type was determined and no source could be read | | `rejected` | Recognition ran and the result was not usable | `scans.total` is every scan the period saw. `scans.billed` is the subset that drew a credit, and it is the figure to compare against `credits_spent`. The two are not the same question. A scan can be billed without being recognized, which is why both counts are published: see [what a billed scan is](/concepts/what-a-billed-scan-is). ## Reconcile against your own records Keep `meta.billed` from every scan response and sum it over the same period. Your sum and `scans.billed` are the two sides to compare. Three properties make the comparison work. 1. **Every settled scan is counted**, whether or not its result was stored. The figures do not decay as retention windows pass, so a month-old total does not shrink. 2. **A zero-retention scan still counts.** `retain_hours: 0` leaves no row to read back, and the counter was incremented at settlement. 3. **A replayed request is counted once.** An `Idempotency-Key` that returns the first result charges nothing the second time and adds nothing here. If your sum is higher than ours, look for a retry without an idempotency key that you counted twice. If ours is higher, look for a response your own code dropped before recording it. ## Know which key reads what A sandbox key of your own account reads that account's **real** balance and counters. Usage is an account-level question, and the answer does not change with the key that asked it. The public sandbox key belongs to no account. It answers with a well-formed body carrying a null balance and zero counters, rather than an invented example. Neither sandbox key ever adds to `credits_spent`. A registered sandbox key is answered from a fixed synthetic specimen and is never charged. ## Handle the period rollover The period is a UTC calendar month, and `period.start` and `period.end` name its bounds. At the rollover the counters return to zero and `balance_credits` does not. Credits carry over; the counters describe the month. Read `period.start` before you store a reading. A job that runs near midnight UTC can take two readings belonging to different months. The bounds in the body are what tells them apart. Nothing expires at the rollover. A balance is spent when it is spent, and no figure here is a monthly allowance. ## Watch the balance from a deployment Poll `GET /v1/usage` on a schedule and alert on `balance_credits` below a threshold you choose. Size the threshold on your own daily volume. A balance that reaches zero does not go negative. The next scan is refused with 402 [`insufficient_credits`](/errors/insufficient_credits), before the engine is called and before anything is charged. The dashboard shows the same figures with the operations log beside them, at . The log lists the individual scans; this endpoint is the aggregate. ## Next - [Top up with crypto](/guides/top-up-with-crypto) — putting credits on the balance. - [What a billed scan is](/concepts/what-a-billed-scan-is) — the predicate behind `billed`. - [Limits](/reference/limits) — the rate this endpoint shares with the rest. --- # Use the MCP server The MCP server gives an assistant three tools against this API: recognize a document, read the balance, search the documentation. This guide covers installing it in each client, what each tool does, and the two image sources that are fenced in. The server is a thin client of the public HTTP API. It holds no data of its own, speaks Model Context Protocol over stdio, and is launched by your client as a command. One recognized document costs one credit, $0.01. ## Install it The package is `@doc-cheap/mcp`, and every client below starts it with `npx`. Set `DOC_CHEAP_API_KEY` to your key. Leave it out and the server uses the public sandbox key, which runs 10 free recognitions and has no balance. ### Claude Desktop, Cursor and Windsurf These three read the same `mcpServers` block, in `claude_desktop_config.json`, `~/.cursor/mcp.json` and `~/.codeium/windsurf/mcp_config.json`. ```json { "mcpServers": { "doc-cheap": { "command": "npx", "args": ["-y", "@doc-cheap/mcp"], "env": { "DOC_CHEAP_API_KEY": "sk_live_your_key" } } } } ``` ### Claude Code ```bash claude mcp add-json doc-cheap '{"command":"npx","args":["-y","@doc-cheap/mcp"],"env":{"DOC_CHEAP_API_KEY":"sk_live_your_key"}}' ``` ### VS Code ```bash code --add-mcp '{"name":"doc-cheap","command":"npx","args":["-y","@doc-cheap/mcp"]}' ``` A `.vscode/mcp.json` file works too. It nests servers under `servers` rather than `mcpServers`, and each one names its `type`. ### Gemini CLI `~/.gemini/settings.json`: ```json { "mcpServers": { "doc-cheap": { "command": "npx", "args": ["-y", "@doc-cheap/mcp"], "env": { "DOC_CHEAP_API_KEY": "sk_live_your_key" } } } } ``` The public repository also carries a `gemini-extension.json`, so `gemini extensions install https://github.com/cheap-doc/ocr-mcp` installs it without editing settings at all. ### Kiro `.kiro/settings/mcp.json` in the workspace, or `~/.kiro/settings/mcp.json` for every workspace: ```json { "mcpServers": { "doc-cheap": { "command": "npx", "args": ["-y", "@doc-cheap/mcp"], "env": { "DOC_CHEAP_API_KEY": "sk_live_your_key" }, "disabled": false, "autoApprove": ["check_balance", "search_docs"] } } } ``` `autoApprove` is listed with the two read-only tools and not with `scan_document`: that one spends credit, so it is worth a prompt. Restart the client after editing the configuration. The server is started by the client, so it picks up a changed environment only on a fresh launch. ## Configure it Everything is optional, and every default is a working setting. | Variable | Default | What it decides | |---|---|---| | `DOC_CHEAP_API_KEY` | `sk_sandbox_public` | Which key the calls carry | | `DOC_CHEAP_API_BASE` | `https://api.doc.cheap` | Which API the tools call | | `DOC_CHEAP_DOCS_BASE` | `https://doc.cheap/docs` | The base the search results link to | | `DOC_CHEAP_DOCS_DIR` | The copy inside the package | Where `search_docs` reads from | | `DOC_CHEAP_IMAGE_ROOT` | Unset, which disables `image_path` | The one directory local images may be read from | | `DOC_CHEAP_SENTRY_DSN` | Unset, which reports nothing | Where failures are reported, if you want them reported | With no key set, the server uses the public sandbox key. That runs 10 free recognitions and has no balance to report. ## Use the three tools Each tool carries a title and the behaviour hints an MCP client reads before it decides whether to ask you first. | Tool | Title | `readOnlyHint` | `openWorldHint` | Calls | |---|---|---|---|---| | `scan_document` | Recognise a passport or ID document | `false` | `true` | `POST /v1/scans` | | `check_balance` | Check remaining credits | `true` | `true` | `GET /v1/usage` | | `search_docs` | Search the doc.cheap API documentation | `true` | `false` | Nothing; reads the bundled docs | `scan_document` also declares `destructiveHint: false` and `idempotentHint: true`. The second is true because of `idempotency_key`: a repeat carrying the same key returns the first result instead of charging again. A retry without a key is a second scan. `scan_document` takes the image as `image_base64`, `image_path` or `image_url`, and the same options a direct call takes: `expect_country`, `return_portrait`, `retain_hours`, `reference` and `idempotency_key`. It answers with the whole result as structured JSON, plus a one-line summary. Its summary line names the scan id, the status, the document kind and country, the holder, whether the call was billed, and the recognition time. ```text Scan 01a0af18-cd8d-7a61-9f2d-4c7b8e105da3 · recognized · passport (GRC) · PARADEIGMA ELENI SOFIA · billed · 684 ms ``` A recognized document draws one credit, $0.01. A scan that recognized nothing is counted and never charged, and `meta.billed` says which happened. `check_balance` needs a key with an account behind it. Under the public sandbox key it answers with a sentence saying there is no balance, rather than with zeros that look like a reading. `search_docs` takes a `query` and an optional `limit` between 1 and 20, defaulting to 5. It reads a copy of this documentation shipped beside the server, so it works with no network. ## For AI agents The server introduces itself with instructions the model reads before any call. They say what it recognizes, when to reach for `scan_document`, and what a call costs. Each tool description then carries its inputs, the shape of the answer and the price. A model can choose between them without a round trip. If you are an agent reading this page: call `scan_document` with `image_base64` when a user shares an identity document and wants it read, transcribed or checked. Call `check_balance` before a batch. Call `search_docs` for field names, error codes and MRZ rules rather than guessing them. ## Enable local files deliberately `image_path` is **off until you set `DOC_CHEAP_IMAGE_ROOT`** to one directory. With the variable unset, the tool answers with an error telling the assistant to send `image_base64` instead. The server runs on your machine with your privileges, and the argument is chosen by a model. Unfenced, a path argument would read any file on the disk and send it out in an API call. With the variable set, the containment is decided on real paths. Both the directory and the requested file are resolved through symlinks first. A `..` segment and a link pointing out of the directory are both refused. A relative `image_path` is taken from that directory rather than from wherever the client happened to start the process. > **Note.** A path outside the directory and a path that does not exist give > the same message. A different one for each would answer "does this file > exist?" for any path on your machine. ## Understand the URL guard `image_url` is fetched by the server, so it is fenced the same way. - The scheme must be `https:`. Any other scheme would let a tool call reach local files or services. - The host must resolve only to public internet addresses. Loopback, private, link-local, carrier-grade NAT, multicast and reserved ranges are refused, including their IPv6-mapped spellings. - One non-public answer refuses the whole URL. A name that resolves to both a public and a private address gets no second chance. - Redirects are followed by hand, at most three hops, and every hop is checked again. A public URL cannot hand off to a private one. - The body is capped at 25 MB, counted as it arrives rather than trusted from the `content-length` header. `image_base64` carries none of these constraints, because the caller already holds the bytes. Every refusal above points at it. ## Read a failure Every failure comes back as one readable line in an error block, never as a silent empty result. The assistant can act on it and can show it to you. A refusal by the API keeps the API's own wording, with the error code and the link to its page. A guard refusal names what to change. Nothing is reported anywhere by default. Failure reporting is off unless you set a reporting endpoint yourself, and without one the tracker library is never even loaded. ## Fix a server that does not answer Work through these in order when the client reports no tools. 1. **The command.** The client launches `npx` as a process, so `npx` must be on the path the client uses, which is not always your shell's. 2. **The key.** A `check_balance` that says there is no balance means the public sandbox key is in use, so `DOC_CHEAP_API_KEY` did not reach the process. 3. **The base URL.** A tool that cannot reach anything is usually pointed at something other than `https://api.doc.cheap` by an inherited `DOC_CHEAP_API_BASE`. 4. **The restart.** An edited configuration takes effect when the client next starts the server. Read the client's own MCP log for the process output. The server writes every diagnostic to standard error, because standard output belongs to the protocol. ## Next - [Recognize a passport](/guides/recognize-a-passport) — what the tool's result carries. - [Track usage and spend](/guides/track-usage-and-spend) — the figures `check_balance` reads. - [API keys and sessions](/concepts/api-keys-and-sessions) — which key to give it. --- # Reference This section states what the API does, exactly. It describes and does not instruct: the task-shaped pages are the [guides](/guides), and the reasons behind a behaviour are in [concepts](/concepts). Every page here is either generated from the contract or checked against the release named in its footer. The base URL is `https://api.doc.cheap`, the one host the contract's `servers` block declares. ## The contract itself | Page | What it states | |---|---| | [API reference](/reference/api) | The whole of `openapi.yaml`, rendered in the browser | | [POST /v1/scans](/reference/endpoints/create-a-scan) | Request fields, headers, every response status and the codes it can answer with | | [GET /v1/scans/{id}](/reference/endpoints/retrieve-a-scan) | The path parameter, the stored result and when it is gone | | [GET /v1/usage](/reference/endpoints/get-usage) | The balance and the counters for the current period | These four are rendered from `openapi.yaml` at build time. The contract is the source. The schemas and the routes produce it, and the pages are produced from it, so neither can drift from the running service. ## The result | Page | What it states | |---|---| | [The response](/reference/response) | The eight groups, every key, its type, and when it is null | | [Field catalogue](/reference/fields) | Every field key the result publishes, with its label and category | | [Field languages and scripts](/reference/fields/languages) | The 418 assigned language identifiers a reading can carry | | [MRZ reference](/reference/mrz) | The zone as it is published, and how its check digits are reported | | [Result images](/reference/images) | The seven crops, their height caps and what the re-encode does | ## The rules of the interface | Page | What it states | |---|---| | [Scan options](/reference/scan-options) | The six options, their defaults and their ranges | | [Errors](/reference/errors) | All 21 codes, including the three no public call can raise | | [Limits](/reference/limits) | Every ceiling a caller meets, and the code each one answers with | | [HTTP status codes](/reference/http-status-codes) | Which codes share a status, and what separates them | | [Idempotency](/reference/idempotency) | What makes two requests the same request | | [Versioning](/reference/versioning) | What counts as a breaking change, and what does not | | [Service levels](/reference/service-levels) | What the published indicators measure | | [Glossary](/reference/glossary) | The words this documentation uses with a precise meaning | ## What is generated and what is written The error pages are the clearest case. Their set comes from the `ErrorCode` enum in the contract, not from a list beside it. A code with no page fails the build, and a page whose name is not a code fails it too. All 21 codes therefore have a page, including the three raised only on internal surfaces. A `docs_url` that answers 404 is worse than a short page. The language table is generated the same way, from the 418 identifiers the recognition engine can report. Both are rebuilt on every commit, so a number on a page here is the number in the code. --- # The response The body `POST /v1/scans` returns, and the body `GET /v1/scans/{id}` returns for a stored scan. One response shape, with no way to ask for another. The body answers the question a caller has: what does this document say, and can it be trusted. It does not hand over the recognition engine's working notes. Every value is a conclusion — one value per reading, a confidence band, and a single verdict on the machine-readable zone. **Every key is present.** A value that is not known is `null`, never a missing key, and a collection that is empty is `[]`. A consumer can read `scan.holder.surname` after one null check on `holder`, never a chain of them. ## The eight groups | Group | Type | What it carries | |---|---|---| | `meta` | object | The scan itself: its id, outcome, billing, timing and revision | | `document` | object or null | What the document is, and whether it is still valid | | `holder` | object or null | The person the document is about | | `fields` | array | Every field read off the printed document, re-keyed | | `mrz` | object | The machine-readable zone as a verdict, with its lines | | `images` | object | Seven image slots | | `quality` | object | Whether the picture was good enough to recognize from | | `authenticity` | object | Authenticity verification, when it runs | Every example on this page is one holder: Eleni Sofia Paradeigma, an invented Greek national whose documents are invented with her. The values below are hers throughout the documentation. ```json { "meta": { "schema_version": "1.0", "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", "status": "recognized", "billed": true, "confidence": "high", "timing": { "upload_ms": 198, "processing_ms": 812, "total_ms": 1024 }, "created_at": "2026-09-17T10:15:00Z", "reference": "order-1042" }, "document": { "kind": "passport", "country": "GRC", "country_name": "Greece", "issuing_state": "GRC", "type_name": "Greece - Passport", "type_confidence": "high", "is_expired": false, "days_remaining": 2001 }, "holder": { "given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA", "birth_date": "1994-03-08", "sex": "F", "nationality": "GRC" }, "fields": [ { "id": "surname@0", "name": "surname", "label": "Surname", "category": "identity", "value": "PARADEIGMA", "language": null, "confidence": "high" }, { "id": "surname@1032", "name": "surname", "label": "Surname", "category": "identity", "value": "ΠΑΡΑΔΕΙΓΜΑ", "language": "Greek", "confidence": "high" } ], "mrz": { "status": "passed", "reason": null, "lines": [ "P`. The practical consequence: match on the keys below where a key matters, and render the rest generically from `label` and `category`. A list that assumes the catalogue is exhaustive drops data the recognition produced. ## The two derived keys Two entries are minted rather than read, and both come after the engine's own order. | Key | Category | What it is | |---|---|---| | `days_to_expire` | `dates` | The expiry countdown in days, as a string | | `mrz` | `document` | The whole machine-readable zone as one entry | `days_to_expire` is negative once the document has expired and `null` when the document carries no date of expiry. It is **absent entirely** when the scan produced no validity data at all: a countdown derived from nothing is not a countdown. Its confidence is that of the expiry-date reading it came from. `mrz` carries the zone's whole text as its value, with `language: null`. The zone is defined over a restricted Latin alphabet and has no language of its own. The formats are on [the MRZ reference](/reference/mrz). ## What is left out of the list ### Fields that belong to the machine-readable zone Three kinds of entry are the zone's own plumbing rather than facts about the holder: its raw lines, its type designation, its check digits. Each is mapped so the zone's verdict can be derived from it, then left out of the published list. A field the engine only ever read out of that zone is left out for the same reason: the printed page already states it. What the zone says is published as one verdict, `mrz`, and as its own entry in `fields`. ### Two field types withheld on purpose Two of the engine's field types are withheld from every response. Each is a decision, and each has a reason a reader can check. | Withheld | Why | |---|---| | `0`, the document class code | The one-letter class designation, `P` on a passport. `document.kind` publishes the same fact in words, so the letter would make a reader carry a code table to learn what the result already told them | | `364`, the remainder term | A countdown to expiry in whole **months**, derived by the engine from the expiry date it already reports. Among a row of dates, a bare number reads as days — wrong by a factor of about thirty, with nothing to signal it. The same fact is published as `days_to_expire`, in the unit its label promises | Neither exclusion loses a fact. Both replace a reading that would be read wrongly with one that says the same thing unambiguously. ## Name normalization A holder's name never contains a line break. A document that runs the holder's names across two printed lines makes the engine report a value with a break inside it. A name is a name whether or not the page ran out of room. Consumers put these values straight into a field, a label or a CSV cell, where a break is a broken row. The rule applies to `surname`, `given_names` and `full_name`, in every script: - every line break becomes exactly one space, including the two Unicode line separators a national-script reading can carry; - runs of spaces collapse to one; - the ends are trimmed; - a value with nothing left comes back `null`, because an empty string is not a name. A scan **stored before this rule existed is repaired when it is read back**. The same normalization runs over a field list replayed out of storage, so `GET /v1/scans/{id}` never returns a name the current rule would not have produced. Other values are not reflowed. Only the three name fields are, because only they are printed as a person's name across a line break. ## What a reading's value is `value` is the value of **this** reading, not of the field as a whole. | `language` | `value` carries | |---|---| | `null` | The neutral reading: the transliterated Latin value the engine merged across sources | | A language name | The national-script spelling, as the document prints it | A document that prints the surname in two scripts yields two entries. They share a `name` and carry two different values, one Latin and one not. Neither is a translation of the other. Both are readings of the same printed page. A value the engine produced nothing for is `null` rather than an empty string. An entry with a null value is still published, because the absence of a value is itself a fact about the document. ## Which keys a document carries No document carries every key, and nothing in the contract promises a particular key for a particular document. The recognition reports what it read. The groupings below are what the common documents print, not a guarantee. | Document | Keys it usually carries | |---|---| | Passport | `surname`, `given_names`, `document_number`, `passport_number`, `birth_date`, `sex`, `nationality`, `issuing_state_name`, `expiry_date`, `issue_date`, `birth_place`, `authority`, `mrz` | | ID card | The same identity and document keys, plus `personal_number`, `address` and its parts, and `document_series` where the country prints one | | Driving licence | `surname`, `given_names`, `birth_date`, `document_number`, `dl_class` or `permit_class`, `issue_date`, `expiry_date`, `address`, `authority` | | Visa | `visa_id`, `visa_type`, `visa_class`, `visa_valid_from`, `visa_valid_until`, `visa_duration_of_stay`, `visa_number_of_entries` | A key that a document does not print is absent from `fields` altogether. It is not published with a null value: the array carries readings, and a reading that was never made is not one. The safe shape for a consumer is a lookup over the array rather than a positional read. Find the entry whose `name` matches; its absence means the document did not print it. ## Where a key appears twice A value legitimately appears twice in one response: once in the curated block (`holder`, `document`) and once in `fields`. They are not copies of one another. The curated block carries the merged best reading in a fixed set of keys. `fields` carries one entry per language, so a national-script spelling is visible beside the transliterated one. The language a reading was made under is on [field languages and scripts](/reference/fields/languages). --- # Field languages and scripts A document prints what it says in one script or in several. A Greek passport prints the holder's surname in Greek and again in Latin; a Kazakh one does the same in Cyrillic and Latin. Each of those is a separate **reading**, and every reading carries the language it was made in. This page states where that language appears, how it is resolved, and every identifier it can be resolved from. ## Where the language appears | Key | Carries | |---|---| | `fields[].language` | The language name, for example `Greek`; `null` for the neutral Latin reading | | `fields[].id` | `name@lcid` — the numeric identifier is the part after the `@` | The response carries the same fact twice, deliberately. `language` is the name the identifier resolves to, which is what a reader renders. The identifier itself stays in the entry's `id`, so two readings of one field stay distinguishable however their names are spelled. ```json [ { "id": "surname@0", "name": "surname", "value": "PARADEIGMA", "language": null }, { "id": "surname@1032", "name": "surname", "value": "ΠΑΡΑΔΕΙΓΜΑ", "language": "Greek" } ] ``` ## The neutral identifier `0` is not a language. It is the neutral, transliterated Latin reading — the value the engine merged across sources, spelled in the alphabet the machine-readable zone uses. It is reported as `language: null`, because the absence of a language is not a language of its own. Its entries still carry the `@0` suffix in their id, so a neutral reading and a national-script reading of one field never collide. ## The three steps that resolve it The recognition engine reports a language as one of these integers and nothing else. Resolving it narrows in three steps, and nothing is guessed at any of them. 1. **The identifier itself.** A value present in the table below resolves to its language, its full name and its IETF tag. `1032` is Greek. 2. **The primary language.** Failing that, the low 10 bits of the value name a primary language, and the rest select a sublanguage — a region or a script. A sublanguage nobody has assigned still belongs to its language, and saying so is true. 135 primary languages are tabulated for this step. 3. **A literal.** When even the family is unknown, the answer is `Language 0x0ABC`, naming the identifier in the hexadecimal the reference is written in. Step 3 exists because the alternative is worse. An identifier the code did not know used to reach a reader as the bare integer it arrived as. A bare integer is not a language, and cannot be looked up. | Step | Example input | `language` | |---|---|---| | Exact | `1032` | `Greek` | | Family | An unassigned sublanguage of `0x0009` | `English` | | Literal | An identifier in no family | `Language 0x0ABC` | ## What the language is not - **Not the language of the document.** It is the language of one reading of one field. A single document commonly produces readings in two. - **Not a translation.** A national-script reading is what the document prints, and the Latin reading is what the engine transliterated. Neither was translated from the other. - **Not a locale to format with.** The identifier's IETF tag is published below as reference, but the value in a result is the language alone. ## Every assigned identifier 418 identifiers, from the published [MS-LCID] reference as it stood on 2026-09-17. The table is generated from the same module the service resolves a reading through, so a row here is a row the running service uses. **Identifier** is the integer an entry's `id` ends with. **Language** is what that entry's `language` reports. **Full name** is the identifier's own name in the reference, which names the region as well. **IETF tag** is the BCP 47 tag of the identifier. | Identifier | Hexadecimal | Language | Full name | IETF tag | |---|---|---|---|---| | 1 | `0x0001` | Arabic | Arabic | `ar` | | 2 | `0x0002` | Bulgarian | Bulgarian | `bg` | | 3 | `0x0003` | Catalan | Catalan | `ca` | | 4 | `0x0004` | Chinese (Simplified) | Chinese (Simplified) | `zh-Hans` | | 5 | `0x0005` | Czech | Czech | `cs` | | 6 | `0x0006` | Danish | Danish | `da` | | 7 | `0x0007` | German | German | `de` | | 8 | `0x0008` | Greek | Greek | `el` | | 9 | `0x0009` | English | English | `en` | | 10 | `0x000A` | Spanish | Spanish | `es` | | 11 | `0x000B` | Finnish | Finnish | `fi` | | 12 | `0x000C` | French | French | `fr` | | 13 | `0x000D` | Hebrew | Hebrew | `he` | | 14 | `0x000E` | Hungarian | Hungarian | `hu` | | 15 | `0x000F` | Icelandic | Icelandic | `is` | | 16 | `0x0010` | Italian | Italian | `it` | | 17 | `0x0011` | Japanese | Japanese | `ja` | | 18 | `0x0012` | Korean | Korean | `ko` | | 19 | `0x0013` | Dutch | Dutch | `nl` | | 20 | `0x0014` | Norwegian (Bokmal) | Norwegian (Bokmal) | `no` | | 21 | `0x0015` | Polish | Polish | `pl` | | 22 | `0x0016` | Portuguese | Portuguese | `pt` | | 23 | `0x0017` | Romansh | Romansh | `rm` | | 24 | `0x0018` | Romanian | Romanian | `ro` | | 25 | `0x0019` | Russian | Russian | `ru` | | 26 | `0x001A` | Croatian | Croatian | `hr` | | 27 | `0x001B` | Slovak | Slovak | `sk` | | 28 | `0x001C` | Albanian | Albanian | `sq` | | 29 | `0x001D` | Swedish | Swedish | `sv` | | 30 | `0x001E` | Thai | Thai | `th` | | 31 | `0x001F` | Turkish | Turkish | `tr` | | 32 | `0x0020` | Urdu | Urdu | `ur` | | 33 | `0x0021` | Indonesian | Indonesian | `id` | | 34 | `0x0022` | Ukrainian | Ukrainian | `uk` | | 35 | `0x0023` | Belarusian | Belarusian | `be` | | 36 | `0x0024` | Slovenian | Slovenian | `sl` | | 37 | `0x0025` | Estonian | Estonian | `et` | | 38 | `0x0026` | Latvian | Latvian | `lv` | | 39 | `0x0027` | Lithuanian | Lithuanian | `lt` | | 40 | `0x0028` | Tajik (Cyrillic) | Tajik (Cyrillic) | `tg` | | 41 | `0x0029` | Persian | Persian | `fa` | | 42 | `0x002A` | Vietnamese | Vietnamese | `vi` | | 43 | `0x002B` | Armenian | Armenian | `hy` | | 44 | `0x002C` | Azerbaijani (Latin) | Azerbaijani (Latin) | `az` | | 45 | `0x002D` | Basque | Basque | `eu` | | 46 | `0x002E` | Upper Sorbian | Upper Sorbian | `hsb` | | 47 | `0x002F` | Macedonian | Macedonian | `mk` | | 48 | `0x0030` | Sotho | Sotho | `st` | | 49 | `0x0031` | Tsonga | Tsonga | `ts` | | 50 | `0x0032` | Setswana | Setswana | `tn` | | 51 | `0x0033` | Venda | Venda | `ve` | | 52 | `0x0034` | Xhosa | Xhosa | `xh` | | 53 | `0x0035` | Zulu | Zulu | `zu` | | 54 | `0x0036` | Afrikaans | Afrikaans | `af` | | 55 | `0x0037` | Georgian | Georgian | `ka` | | 56 | `0x0038` | Faroese | Faroese | `fo` | | 57 | `0x0039` | Hindi | Hindi | `hi` | | 58 | `0x003A` | Maltese | Maltese | `mt` | | 59 | `0x003B` | Sami (Northern) | Sami (Northern) | `se` | | 60 | `0x003C` | Irish | Irish | `ga` | | 62 | `0x003E` | Malay | Malay | `ms` | | 63 | `0x003F` | Kazakh | Kazakh | `kk` | | 64 | `0x0040` | Kyrgyz | Kyrgyz | `ky` | | 65 | `0x0041` | Kiswahili | Kiswahili | `sw` | | 66 | `0x0042` | Turkmen | Turkmen | `tk` | | 67 | `0x0043` | Uzbek (Latin) | Uzbek (Latin) | `uz` | | 68 | `0x0044` | Tatar | Tatar | `tt` | | 69 | `0x0045` | Bangla | Bangla | `bn` | | 70 | `0x0046` | Punjabi | Punjabi | `pa` | | 71 | `0x0047` | Gujarati | Gujarati | `gu` | | 72 | `0x0048` | Odia | Odia | `or` | | 73 | `0x0049` | Tamil | Tamil | `ta` | | 74 | `0x004A` | Telugu | Telugu | `te` | | 75 | `0x004B` | Kannada | Kannada | `kn` | | 76 | `0x004C` | Malayalam | Malayalam | `ml` | | 77 | `0x004D` | Assamese | Assamese | `as` | | 78 | `0x004E` | Marathi | Marathi | `mr` | | 79 | `0x004F` | Sanskrit | Sanskrit | `sa` | | 80 | `0x0050` | Mongolian (Cyrillic) | Mongolian (Cyrillic) | `mn` | | 81 | `0x0051` | Tibetan | Tibetan | `bo` | | 82 | `0x0052` | Welsh | Welsh | `cy` | | 83 | `0x0053` | Khmer | Khmer | `km` | | 84 | `0x0054` | Lao | Lao | `lo` | | 85 | `0x0055` | Burmese | Burmese | `my` | | 86 | `0x0056` | Galician | Galician | `gl` | | 87 | `0x0057` | Konkani | Konkani | `kok` | | 89 | `0x0059` | Sindhi | Sindhi | `sd` | | 90 | `0x005A` | Syriac | Syriac | `syr` | | 91 | `0x005B` | Sinhala | Sinhala | `si` | | 92 | `0x005C` | Cherokee | Cherokee | `chr` | | 93 | `0x005D` | Inuktitut (Latin) | Inuktitut (Latin) | `iu` | | 94 | `0x005E` | Amharic | Amharic | `am` | | 95 | `0x005F` | Tamazight (Latin) | Tamazight (Latin) | `tzm` | | 96 | `0x0060` | Kashmiri | Kashmiri | `ks` | | 97 | `0x0061` | Nepali | Nepali | `ne` | | 98 | `0x0062` | Frisian | Frisian | `fy` | | 99 | `0x0063` | Pashto | Pashto | `ps` | | 100 | `0x0064` | Filipino | Filipino | `fil` | | 101 | `0x0065` | Divehi | Divehi | `dv` | | 103 | `0x0067` | Fulah | Fulah | `ff` | | 104 | `0x0068` | Hausa (Latin) | Hausa (Latin) | `ha` | | 106 | `0x006A` | Yoruba | Yoruba | `yo` | | 107 | `0x006B` | Quechua | Quechua | `quz` | | 108 | `0x006C` | Sesotho sa Leboa | Sesotho sa Leboa | `nso` | | 109 | `0x006D` | Bashkir | Bashkir | `ba` | | 110 | `0x006E` | Luxembourgish | Luxembourgish | `lb` | | 111 | `0x006F` | Greenlandic | Greenlandic | `kl` | | 112 | `0x0070` | Igbo | Igbo | `ig` | | 114 | `0x0072` | Oromo | Oromo | `om` | | 115 | `0x0073` | Tigrinya | Tigrinya | `ti` | | 116 | `0x0074` | Guarani | Guarani | `gn` | | 117 | `0x0075` | Hawaiian | Hawaiian | `haw` | | 119 | `0x0077` | Somali | Somali | `so` | | 120 | `0x0078` | Yi | Yi | `ii` | | 122 | `0x007A` | Mapudungun | Mapudungun | `arn` | | 124 | `0x007C` | Mohawk | Mohawk | `moh` | | 126 | `0x007E` | Breton | Breton | `br` | | 128 | `0x0080` | Uyghur | Uyghur | `ug` | | 129 | `0x0081` | Maori | Maori | `mi` | | 130 | `0x0082` | Occitan | Occitan | `oc` | | 131 | `0x0083` | Corsican | Corsican | `co` | | 132 | `0x0084` | Alsatian | Alsatian | `gsw` | | 133 | `0x0085` | Sakha | Sakha | `sah` | | 134 | `0x0086` | K'iche | K'iche | `quc` | | 135 | `0x0087` | Kinyarwanda | Kinyarwanda | `rw` | | 136 | `0x0088` | Wolof | Wolof | `wo` | | 140 | `0x008C` | Dari | Dari | `prs` | | 145 | `0x0091` | Scottish Gaelic | Scottish Gaelic | `gd` | | 146 | `0x0092` | Central Kurdish | Central Kurdish | `ku` | | 1025 | `0x0401` | Arabic | Arabic (Saudi Arabia) | `ar-SA` | | 1026 | `0x0402` | Bulgarian | Bulgarian (Bulgaria) | `bg-BG` | | 1027 | `0x0403` | Catalan | Catalan (Spain) | `ca-ES` | | 1028 | `0x0404` | Chinese (Traditional) | Chinese (Traditional) (Taiwan) | `zh-TW` | | 1029 | `0x0405` | Czech | Czech (Czech Republic) | `cs-CZ` | | 1030 | `0x0406` | Danish | Danish (Denmark) | `da-DK` | | 1031 | `0x0407` | German | German (Germany) | `de-DE` | | 1032 | `0x0408` | Greek | Greek (Greece) | `el-GR` | | 1033 | `0x0409` | English | English (United States) | `en-US` | | 1034 | `0x040A` | Spanish | Spanish (Spain) | `es-ES_tradnl` | | 1035 | `0x040B` | Finnish | Finnish (Finland) | `fi-FI` | | 1036 | `0x040C` | French | French (France) | `fr-FR` | | 1037 | `0x040D` | Hebrew | Hebrew (Israel) | `he-IL` | | 1038 | `0x040E` | Hungarian | Hungarian (Hungary) | `hu-HU` | | 1039 | `0x040F` | Icelandic | Icelandic (Iceland) | `is-IS` | | 1040 | `0x0410` | Italian | Italian (Italy) | `it-IT` | | 1041 | `0x0411` | Japanese | Japanese (Japan) | `ja-JP` | | 1042 | `0x0412` | Korean | Korean (Korea) | `ko-KR` | | 1043 | `0x0413` | Dutch | Dutch (Netherlands) | `nl-NL` | | 1044 | `0x0414` | Norwegian (Bokmal) | Norwegian (Bokmal) (Norway) | `nb-NO` | | 1045 | `0x0415` | Polish | Polish (Poland) | `pl-PL` | | 1046 | `0x0416` | Portuguese | Portuguese (Brazil) | `pt-BR` | | 1047 | `0x0417` | Romansh | Romansh (Switzerland) | `rm-CH` | | 1048 | `0x0418` | Romanian | Romanian (Romania) | `ro-RO` | | 1049 | `0x0419` | Russian | Russian (Russia) | `ru-RU` | | 1050 | `0x041A` | Croatian | Croatian (Croatia) | `hr-HR` | | 1051 | `0x041B` | Slovak | Slovak (Slovakia) | `sk-SK` | | 1052 | `0x041C` | Albanian | Albanian (Albania) | `sq-AL` | | 1053 | `0x041D` | Swedish | Swedish (Sweden) | `sv-SE` | | 1054 | `0x041E` | Thai | Thai (Thailand) | `th-TH` | | 1055 | `0x041F` | Turkish | Turkish (Turkey) | `tr-TR` | | 1056 | `0x0420` | Urdu | Urdu (Islamic Republic of Pakistan) | `ur-PK` | | 1057 | `0x0421` | Indonesian | Indonesian (Indonesia) | `id-ID` | | 1058 | `0x0422` | Ukrainian | Ukrainian (Ukraine) | `uk-UA` | | 1059 | `0x0423` | Belarusian | Belarusian (Belarus) | `be-BY` | | 1060 | `0x0424` | Slovenian | Slovenian (Slovenia) | `sl-SI` | | 1061 | `0x0425` | Estonian | Estonian (Estonia) | `et-EE` | | 1062 | `0x0426` | Latvian | Latvian (Latvia) | `lv-LV` | | 1063 | `0x0427` | Lithuanian | Lithuanian (Lithuania) | `lt-LT` | | 1064 | `0x0428` | Tajik (Cyrillic) | Tajik (Cyrillic) (Tajikistan) | `tg-Cyrl-TJ` | | 1065 | `0x0429` | Persian | Persian (Iran) | `fa-IR` | | 1066 | `0x042A` | Vietnamese | Vietnamese (Vietnam) | `vi-VN` | | 1067 | `0x042B` | Armenian | Armenian (Armenia) | `hy-AM` | | 1068 | `0x042C` | Azerbaijani (Latin) | Azerbaijani (Latin) (Azerbaijan) | `az-Latn-AZ` | | 1069 | `0x042D` | Basque | Basque (Spain) | `eu-ES` | | 1070 | `0x042E` | Upper Sorbian | Upper Sorbian (Germany) | `hsb-DE` | | 1071 | `0x042F` | Macedonian | Macedonian (North Macedonia) | `mk-MK` | | 1072 | `0x0430` | Sotho | Sotho (South Africa) | `st-ZA` | | 1073 | `0x0431` | Tsonga | Tsonga (South Africa) | `ts-ZA` | | 1074 | `0x0432` | Setswana | Setswana (South Africa) | `tn-ZA` | | 1075 | `0x0433` | Venda | Venda (South Africa) | `ve-ZA` | | 1076 | `0x0434` | Xhosa | Xhosa (South Africa) | `xh-ZA` | | 1077 | `0x0435` | Zulu | Zulu (South Africa) | `zu-ZA` | | 1078 | `0x0436` | Afrikaans | Afrikaans (South Africa) | `af-ZA` | | 1079 | `0x0437` | Georgian | Georgian (Georgia) | `ka-GE` | | 1080 | `0x0438` | Faroese | Faroese (Faroe Islands) | `fo-FO` | | 1081 | `0x0439` | Hindi | Hindi (India) | `hi-IN` | | 1082 | `0x043A` | Maltese | Maltese (Malta) | `mt-MT` | | 1083 | `0x043B` | Sami (Northern) | Sami (Northern) (Norway) | `se-NO` | | 1085 | `0x043D` | Yiddish | Yiddish (World) | `yi-001` | | 1086 | `0x043E` | Malay | Malay (Malaysia) | `ms-MY` | | 1087 | `0x043F` | Kazakh | Kazakh (Kazakhstan) | `kk-KZ` | | 1088 | `0x0440` | Kyrgyz | Kyrgyz (Kyrgyzstan) | `ky-KG` | | 1089 | `0x0441` | Kiswahili | Kiswahili (Kenya) | `sw-KE` | | 1090 | `0x0442` | Turkmen | Turkmen (Turkmenistan) | `tk-TM` | | 1091 | `0x0443` | Uzbek (Latin) | Uzbek (Latin) (Uzbekistan) | `uz-Latn-UZ` | | 1092 | `0x0444` | Tatar | Tatar (Russia) | `tt-RU` | | 1093 | `0x0445` | Bangla | Bangla (India) | `bn-IN` | | 1094 | `0x0446` | Punjabi | Punjabi (India) | `pa-IN` | | 1095 | `0x0447` | Gujarati | Gujarati (India) | `gu-IN` | | 1096 | `0x0448` | Odia | Odia (India) | `or-IN` | | 1097 | `0x0449` | Tamil | Tamil (India) | `ta-IN` | | 1098 | `0x044A` | Telugu | Telugu (India) | `te-IN` | | 1099 | `0x044B` | Kannada | Kannada (India) | `kn-IN` | | 1100 | `0x044C` | Malayalam | Malayalam (India) | `ml-IN` | | 1101 | `0x044D` | Assamese | Assamese (India) | `as-IN` | | 1102 | `0x044E` | Marathi | Marathi (India) | `mr-IN` | | 1103 | `0x044F` | Sanskrit | Sanskrit (India) | `sa-IN` | | 1104 | `0x0450` | Mongolian (Cyrillic) | Mongolian (Cyrillic) (Mongolia) | `mn-MN` | | 1105 | `0x0451` | Tibetan | Tibetan (People's Republic of China) | `bo-CN` | | 1106 | `0x0452` | Welsh | Welsh (United Kingdom) | `cy-GB` | | 1107 | `0x0453` | Khmer | Khmer (Cambodia) | `km-KH` | | 1108 | `0x0454` | Lao | Lao (Lao P.D.R.) | `lo-LA` | | 1109 | `0x0455` | Burmese | Burmese (Myanmar) | `my-MM` | | 1110 | `0x0456` | Galician | Galician (Spain) | `gl-ES` | | 1111 | `0x0457` | Konkani | Konkani (India) | `kok-IN` | | 1114 | `0x045A` | Syriac | Syriac (Syria) | `syr-SY` | | 1115 | `0x045B` | Sinhala | Sinhala (Sri Lanka) | `si-LK` | | 1116 | `0x045C` | Cherokee | Cherokee (United States) | `chr-Cher-US` | | 1117 | `0x045D` | Inuktitut (Syllabics) | Inuktitut (Syllabics) (Canada) | `iu-Cans-CA` | | 1118 | `0x045E` | Amharic | Amharic (Ethiopia) | `am-ET` | | 1119 | `0x045F` | Central Atlas Tamazight (Arabic) | Central Atlas Tamazight (Arabic) (Morocco) | `tzm-Arab-MA` | | 1120 | `0x0460` | Kashmiri | Kashmiri (Perso-Arabic) | `ks-Arab` | | 1121 | `0x0461` | Nepali | Nepali (Nepal) | `ne-NP` | | 1122 | `0x0462` | Frisian | Frisian (Netherlands) | `fy-NL` | | 1123 | `0x0463` | Pashto | Pashto (Afghanistan) | `ps-AF` | | 1124 | `0x0464` | Filipino | Filipino (Philippines) | `fil-PH` | | 1125 | `0x0465` | Divehi | Divehi (Maldives) | `dv-MV` | | 1127 | `0x0467` | Fulah | Fulah (Nigeria) | `ff-NG` | | 1128 | `0x0468` | Hausa (Latin) | Hausa (Latin) (Nigeria) | `ha-Latn-NG` | | 1130 | `0x046A` | Yoruba | Yoruba (Nigeria) | `yo-NG` | | 1131 | `0x046B` | Quechua | Quechua (Bolivia) | `quz-BO` | | 1132 | `0x046C` | Sesotho sa Leboa | Sesotho sa Leboa (South Africa) | `nso-ZA` | | 1133 | `0x046D` | Bashkir | Bashkir (Russia) | `ba-RU` | | 1134 | `0x046E` | Luxembourgish | Luxembourgish (Luxembourg) | `lb-LU` | | 1135 | `0x046F` | Greenlandic | Greenlandic (Greenland) | `kl-GL` | | 1136 | `0x0470` | Igbo | Igbo (Nigeria) | `ig-NG` | | 1137 | `0x0471` | Kanuri (Latin) | Kanuri (Latin) (Nigeria) | `kr-Latn-NG` | | 1138 | `0x0472` | Oromo | Oromo (Ethiopia) | `om-ET` | | 1139 | `0x0473` | Tigrinya | Tigrinya (Ethiopia) | `ti-ET` | | 1140 | `0x0474` | Guarani | Guarani (Paraguay) | `gn-PY` | | 1141 | `0x0475` | Hawaiian | Hawaiian (United States) | `haw-US` | | 1142 | `0x0476` | Latin | Latin (Vatican City) | `la-VA` | | 1143 | `0x0477` | Somali | Somali (Somalia) | `so-SO` | | 1144 | `0x0478` | Yi | Yi (People's Republic of China) | `ii-CN` | | 1146 | `0x047A` | Mapudungun | Mapudungun (Chile) | `arn-CL` | | 1148 | `0x047C` | Mohawk | Mohawk (Canada) | `moh-CA` | | 1150 | `0x047E` | Breton | Breton (France) | `br-FR` | | 1152 | `0x0480` | Uyghur | Uyghur (People's Republic of China) | `ug-CN` | | 1153 | `0x0481` | Maori | Maori (New Zealand) | `mi-NZ` | | 1154 | `0x0482` | Occitan | Occitan (France) | `oc-FR` | | 1155 | `0x0483` | Corsican | Corsican (France) | `co-FR` | | 1156 | `0x0484` | Alsatian | Alsatian (France) | `gsw-FR` | | 1157 | `0x0485` | Sakha | Sakha (Russia) | `sah-RU` | | 1158 | `0x0486` | K'iche | K'iche (Guatemala) | `quc-Latn-GT` | | 1159 | `0x0487` | Kinyarwanda | Kinyarwanda (Rwanda) | `rw-RW` | | 1160 | `0x0488` | Wolof | Wolof (Senegal) | `wo-SN` | | 1164 | `0x048C` | Dari | Dari (Afghanistan) | `prs-AF` | | 1169 | `0x0491` | Scottish Gaelic | Scottish Gaelic (United Kingdom) | `gd-GB` | | 1170 | `0x0492` | Central Kurdish | Central Kurdish (Iraq) | `ku-Arab-IQ` | | 1281 | `0x0501` | Pseudo Language | Pseudo Language (Pseudo locale used for localization testing) | `qps-ploc` | | 1534 | `0x05FE` | Pseudo Language | Pseudo Language (Pseudo locale for east Asian/complex script localization testing) | `qps-ploca` | | 2049 | `0x0801` | Arabic | Arabic (Iraq) | `ar-IQ` | | 2051 | `0x0803` | Valencian | Valencian (Spain) | `ca-ES-valencia` | | 2052 | `0x0804` | Chinese (Simplified) | Chinese (Simplified) (People's Republic of China) | `zh-CN` | | 2055 | `0x0807` | German | German (Switzerland) | `de-CH` | | 2057 | `0x0809` | English | English (United Kingdom) | `en-GB` | | 2058 | `0x080A` | Spanish | Spanish (Mexico) | `es-MX` | | 2060 | `0x080C` | French | French (Belgium) | `fr-BE` | | 2064 | `0x0810` | Italian | Italian (Switzerland) | `it-CH` | | 2067 | `0x0813` | Dutch | Dutch (Belgium) | `nl-BE` | | 2068 | `0x0814` | Norwegian (Nynorsk) | Norwegian (Nynorsk) (Norway) | `nn-NO` | | 2070 | `0x0816` | Portuguese | Portuguese (Portugal) | `pt-PT` | | 2072 | `0x0818` | Romanian | Romanian (Moldova) | `ro-MD` | | 2073 | `0x0819` | Russian | Russian (Moldova) | `ru-MD` | | 2074 | `0x081A` | Serbian (Latin) | Serbian (Latin) (Serbia and Montenegro (Former)) | `sr-Latn-CS` | | 2077 | `0x081D` | Swedish | Swedish (Finland) | `sv-FI` | | 2080 | `0x0820` | Urdu | Urdu (India) | `ur-IN` | | 2092 | `0x082C` | Azerbaijani (Cyrillic) | Azerbaijani (Cyrillic) (Azerbaijan) | `az-Cyrl-AZ` | | 2094 | `0x082E` | Lower Sorbian | Lower Sorbian (Germany) | `dsb-DE` | | 2098 | `0x0832` | Setswana | Setswana (Botswana) | `tn-BW` | | 2107 | `0x083B` | Sami (Northern) | Sami (Northern) (Sweden) | `se-SE` | | 2108 | `0x083C` | Irish | Irish (Ireland) | `ga-IE` | | 2110 | `0x083E` | Malay | Malay (Brunei Darussalam) | `ms-BN` | | 2115 | `0x0843` | Uzbek (Cyrillic) | Uzbek (Cyrillic) (Uzbekistan) | `uz-Cyrl-UZ` | | 2117 | `0x0845` | Bangla | Bangla (Bangladesh) | `bn-BD` | | 2118 | `0x0846` | Punjabi | Punjabi (Islamic Republic of Pakistan) | `pa-Arab-PK` | | 2121 | `0x0849` | Tamil | Tamil (Sri Lanka) | `ta-LK` | | 2128 | `0x0850` | Mongolian (Traditional Mongolian) | Mongolian (Traditional Mongolian) (People's Republic of China) | `mn-Mong-CN` | | 2137 | `0x0859` | Sindhi | Sindhi (Islamic Republic of Pakistan) | `sd-Arab-PK` | | 2141 | `0x085D` | Inuktitut (Latin) | Inuktitut (Latin) (Canada) | `iu-Latn-CA` | | 2143 | `0x085F` | Tamazight (Latin) | Tamazight (Latin) (Algeria) | `tzm-Latn-DZ` | | 2144 | `0x0860` | Kashmiri (Devanagari) | Kashmiri (Devanagari) (India) | `ks-Deva-IN` | | 2145 | `0x0861` | Nepali | Nepali (India) | `ne-IN` | | 2151 | `0x0867` | Fulah | Fulah (Senegal) | `ff-Latn-SN` | | 2155 | `0x086B` | Quechua | Quechua (Ecuador) | `quz-EC` | | 2163 | `0x0873` | Tigrinya | Tigrinya (Eritrea) | `ti-ER` | | 2559 | `0x09FF` | Pseudo Language | Pseudo Language (Pseudo locale used for localization testing of mirrored locales) | `qps-plocm` | | 3073 | `0x0C01` | Arabic | Arabic (Egypt) | `ar-EG` | | 3076 | `0x0C04` | Chinese (Traditional) | Chinese (Traditional) (Hong Kong S.A.R.) | `zh-HK` | | 3079 | `0x0C07` | German | German (Austria) | `de-AT` | | 3081 | `0x0C09` | English | English (Australia) | `en-AU` | | 3082 | `0x0C0A` | Spanish | Spanish (Spain) | `es-ES` | | 3084 | `0x0C0C` | French | French (Canada) | `fr-CA` | | 3098 | `0x0C1A` | Serbian (Cyrillic) | Serbian (Cyrillic) (Serbia and Montenegro (Former)) | `sr-Cyrl-CS` | | 3131 | `0x0C3B` | Sami (Northern) | Sami (Northern) (Finland) | `se-FI` | | 3152 | `0x0C50` | Mongolian (Traditional Mongolian) | Mongolian (Traditional Mongolian) (Mongolia) | `mn-Mong-MN` | | 3153 | `0x0C51` | Dzongkha | Dzongkha (Bhutan) | `dz-BT` | | 3179 | `0x0C6B` | Quechua | Quechua (Peru) | `quz-PE` | | 4097 | `0x1001` | Arabic | Arabic (Libya) | `ar-LY` | | 4100 | `0x1004` | Chinese (Simplified) | Chinese (Simplified) (Singapore) | `zh-SG` | | 4103 | `0x1007` | German | German (Luxembourg) | `de-LU` | | 4105 | `0x1009` | English | English (Canada) | `en-CA` | | 4106 | `0x100A` | Spanish | Spanish (Guatemala) | `es-GT` | | 4108 | `0x100C` | French | French (Switzerland) | `fr-CH` | | 4122 | `0x101A` | Croatian (Latin) | Croatian (Latin) (Bosnia and Herzegovina) | `hr-BA` | | 4155 | `0x103B` | Sami (Lule) | Sami (Lule) (Norway) | `smj-NO` | | 5121 | `0x1401` | Arabic | Arabic (Algeria) | `ar-DZ` | | 5124 | `0x1404` | Chinese (Traditional) | Chinese (Traditional) (Macao S.A.R.) | `zh-MO` | | 5127 | `0x1407` | German | German (Liechtenstein) | `de-LI` | | 5129 | `0x1409` | English | English (New Zealand) | `en-NZ` | | 5130 | `0x140A` | Spanish | Spanish (Costa Rica) | `es-CR` | | 5132 | `0x140C` | French | French (Luxembourg) | `fr-LU` | | 5146 | `0x141A` | Bosnian (Latin) | Bosnian (Latin) (Bosnia and Herzegovina) | `bs-Latn-BA` | | 5179 | `0x143B` | Sami (Lule) | Sami (Lule) (Sweden) | `smj-SE` | | 6145 | `0x1801` | Arabic | Arabic (Morocco) | `ar-MA` | | 6153 | `0x1809` | English | English (Ireland) | `en-IE` | | 6154 | `0x180A` | Spanish | Spanish (Panama) | `es-PA` | | 6156 | `0x180C` | French | French (Principality of Monaco) | `fr-MC` | | 6170 | `0x181A` | Serbian (Latin) | Serbian (Latin) (Bosnia and Herzegovina) | `sr-Latn-BA` | | 6203 | `0x183B` | Sami (Southern) | Sami (Southern) (Norway) | `sma-NO` | | 7169 | `0x1C01` | Arabic | Arabic (Tunisia) | `ar-TN` | | 7177 | `0x1C09` | English | English (South Africa) | `en-ZA` | | 7178 | `0x1C0A` | Spanish | Spanish (Dominican Republic) | `es-DO` | | 7180 | `0x1C0C` | French | French (Caribbean) | `fr-029` | | 7194 | `0x1C1A` | Serbian (Cyrillic) | Serbian (Cyrillic) (Bosnia and Herzegovina) | `sr-Cyrl-BA` | | 7227 | `0x1C3B` | Sami (Southern) | Sami (Southern) (Sweden) | `sma-SE` | | 8193 | `0x2001` | Arabic | Arabic (Oman) | `ar-OM` | | 8201 | `0x2009` | English | English (Jamaica) | `en-JM` | | 8202 | `0x200A` | Spanish | Spanish (Bolivarian Republic of Venezuela) | `es-VE` | | 8204 | `0x200C` | French | French (Reunion) | `fr-RE` | | 8218 | `0x201A` | Bosnian (Cyrillic) | Bosnian (Cyrillic) (Bosnia and Herzegovina) | `bs-Cyrl-BA` | | 8251 | `0x203B` | Sami (Skolt) | Sami (Skolt) (Finland) | `sms-FI` | | 9217 | `0x2401` | Arabic | Arabic (Yemen) | `ar-YE` | | 9225 | `0x2409` | English | English (Caribbean) | `en-029` | | 9226 | `0x240A` | Spanish | Spanish (Colombia) | `es-CO` | | 9228 | `0x240C` | French | French (Congo, DRC) | `fr-CD` | | 9242 | `0x241A` | Serbian (Latin) | Serbian (Latin) (Serbia) | `sr-Latn-RS` | | 9275 | `0x243B` | Sami (Inari) | Sami (Inari) (Finland) | `smn-FI` | | 10241 | `0x2801` | Arabic | Arabic (Syria) | `ar-SY` | | 10249 | `0x2809` | English | English (Belize) | `en-BZ` | | 10250 | `0x280A` | Spanish | Spanish (Peru) | `es-PE` | | 10252 | `0x280C` | French | French (Senegal) | `fr-SN` | | 10266 | `0x281A` | Serbian (Cyrillic) | Serbian (Cyrillic) (Serbia) | `sr-Cyrl-RS` | | 11265 | `0x2C01` | Arabic | Arabic (Jordan) | `ar-JO` | | 11273 | `0x2C09` | English | English (Trinidad and Tobago) | `en-TT` | | 11274 | `0x2C0A` | Spanish | Spanish (Argentina) | `es-AR` | | 11276 | `0x2C0C` | French | French (Cameroon) | `fr-CM` | | 11290 | `0x2C1A` | Serbian (Latin) | Serbian (Latin) (Montenegro) | `sr-Latn-ME` | | 12289 | `0x3001` | Arabic | Arabic (Lebanon) | `ar-LB` | | 12297 | `0x3009` | English | English (Zimbabwe) | `en-ZW` | | 12298 | `0x300A` | Spanish | Spanish (Ecuador) | `es-EC` | | 12300 | `0x300C` | French | French (Côte d'Ivoire) | `fr-CI` | | 12314 | `0x301A` | Serbian (Cyrillic) | Serbian (Cyrillic) (Montenegro) | `sr-Cyrl-ME` | | 13313 | `0x3401` | Arabic | Arabic (Kuwait) | `ar-KW` | | 13321 | `0x3409` | English | English (Republic of the Philippines) | `en-PH` | | 13322 | `0x340A` | Spanish | Spanish (Chile) | `es-CL` | | 13324 | `0x340C` | French | French (Mali) | `fr-ML` | | 14337 | `0x3801` | Arabic | Arabic (U.A.E.) | `ar-AE` | | 14346 | `0x380A` | Spanish | Spanish (Uruguay) | `es-UY` | | 14348 | `0x380C` | French | French (Morocco) | `fr-MA` | | 15361 | `0x3C01` | Arabic | Arabic (Bahrain) | `ar-BH` | | 15369 | `0x3C09` | English | English (Hong Kong) | `en-HK` | | 15370 | `0x3C0A` | Spanish | Spanish (Paraguay) | `es-PY` | | 15372 | `0x3C0C` | French | French (Haiti) | `fr-HT` | | 16385 | `0x4001` | Arabic | Arabic (Qatar) | `ar-QA` | | 16393 | `0x4009` | English | English (India) | `en-IN` | | 16394 | `0x400A` | Spanish | Spanish (Bolivia) | `es-BO` | | 17417 | `0x4409` | English | English (Malaysia) | `en-MY` | | 17418 | `0x440A` | Spanish | Spanish (El Salvador) | `es-SV` | | 18441 | `0x4809` | English | English (Singapore) | `en-SG` | | 18442 | `0x480A` | Spanish | Spanish (Honduras) | `es-HN` | | 19465 | `0x4C09` | English | English (United Arab Emirates) | `en-AE` | | 19466 | `0x4C0A` | Spanish | Spanish (Nicaragua) | `es-NI` | | 20490 | `0x500A` | Spanish | Spanish (Puerto Rico) | `es-PR` | | 21514 | `0x540A` | Spanish | Spanish (United States) | `es-US` | | 22538 | `0x580A` | Spanish | Spanish (Latin America) | `es-419` | | 23562 | `0x5C0A` | Spanish | Spanish (Cuba) | `es-CU` | | 25626 | `0x641A` | Bosnian (Cyrillic) | Bosnian (Cyrillic) | `bs-Cyrl` | | 26650 | `0x681A` | Bosnian (Latin) | Bosnian (Latin) | `bs-Latn` | | 27674 | `0x6C1A` | Serbian (Cyrillic) | Serbian (Cyrillic) | `sr-Cyrl` | | 28698 | `0x701A` | Serbian (Latin) | Serbian (Latin) | `sr-Latn` | | 28731 | `0x703B` | Sami (Inari) | Sami (Inari) | `smn` | | 29740 | `0x742C` | Azerbaijani (Cyrillic) | Azerbaijani (Cyrillic) | `az-Cyrl` | | 29755 | `0x743B` | Sami (Skolt) | Sami (Skolt) | `sms` | | 30724 | `0x7804` | Chinese (Simplified) | Chinese (Simplified) | `zh` | | 30740 | `0x7814` | Norwegian (Nynorsk) | Norwegian (Nynorsk) | `nn` | | 30746 | `0x781A` | Bosnian (Latin) | Bosnian (Latin) | `bs` | | 30764 | `0x782C` | Azerbaijani (Latin) | Azerbaijani (Latin) | `az-Latn` | | 30779 | `0x783B` | Sami (Southern) | Sami (Southern) | `sma` | | 30787 | `0x7843` | Uzbek (Cyrillic) | Uzbek (Cyrillic) | `uz-Cyrl` | | 30800 | `0x7850` | Mongolian (Cyrillic) | Mongolian (Cyrillic) | `mn-Cyrl` | | 30813 | `0x785D` | Inuktitut (Syllabics) | Inuktitut (Syllabics) | `iu-Cans` | | 31748 | `0x7C04` | Chinese (Traditional) | Chinese (Traditional) | `zh-Hant` | | 31764 | `0x7C14` | Norwegian (Bokmal) | Norwegian (Bokmal) | `nb` | | 31770 | `0x7C1A` | Serbian (Latin) | Serbian (Latin) | `sr` | | 31784 | `0x7C28` | Tajik (Cyrillic) | Tajik (Cyrillic) | `tg-Cyrl` | | 31790 | `0x7C2E` | Lower Sorbian | Lower Sorbian | `dsb` | | 31803 | `0x7C3B` | Sami (Lule) | Sami (Lule) | `smj` | | 31811 | `0x7C43` | Uzbek (Latin) | Uzbek (Latin) | `uz-Latn` | | 31814 | `0x7C46` | Punjabi | Punjabi | `pa-Arab` | | 31824 | `0x7C50` | Mongolian (Traditional Mongolian) | Mongolian (Traditional Mongolian) | `mn-Mong` | | 31833 | `0x7C59` | Sindhi | Sindhi | `sd-Arab` | | 31836 | `0x7C5C` | Cherokee | Cherokee | `chr-Cher` | | 31837 | `0x7C5D` | Inuktitut (Latin) | Inuktitut (Latin) | `iu-Latn` | | 31839 | `0x7C5F` | Tamazight (Latin) | Tamazight (Latin) | `tzm-Latn` | | 31847 | `0x7C67` | Fulah (Latin) | Fulah (Latin) | `ff-Latn` | | 31848 | `0x7C68` | Hausa (Latin) | Hausa (Latin) | `ha-Latn` | | 31890 | `0x7C92` | Central Kurdish | Central Kurdish | `ku-Arab` | ## The value a language selects The language on an entry decides which spelling its `value` carries. | `language` | `value` | |---|---| | `null` | The merged Latin value: the transliterated spelling | | A language name | The national-script spelling, as printed | A reading whose national-script spelling matches its transliterated one — a document printed only in Latin — produces one entry, not two. ## The identifiers a document most often carries Nothing restricts which identifier appears on which document. The pairs below are the ones a European or Central Asian travel document commonly produces beside its Latin reading. | Script on the page | Identifier | `language` | |---|---|---| | Greek | 1032 | `Greek` | | Cyrillic, Bulgarian | 1026 | `Bulgarian` | | Cyrillic, Kazakh | 1087 | `Kazakh` | | Cyrillic, Serbian | 3098 | `Serbian (Cyrillic)` | | Arabic, Egypt | 3073 | `Arabic` | | Hebrew | 1037 | `Hebrew` | | Georgian | 1079 | `Georgian` | | Armenian | 1067 | `Armenian` | A reading in any of them appears beside the neutral one, not instead of it. A consumer that wants the Latin spelling reads the entry whose `language` is `null`; one that wants the printed spelling reads the entry that names a language. --- # MRZ reference The machine-readable zone (MRZ) is the block of upper-case letters, digits and `<` fillers printed at the bottom of a passport data page. An identity card carries it on the back. It is the one part of a document a caller can re-verify without trusting this service, and it is published verbatim for that reason. ## Where it appears | Key | Carries | |---|---| | `mrz.status` | `passed`, `failed` or `absent` | | `mrz.reason` | One sentence naming what did not check out | | `mrz.lines` | The lines in order, or `null` | | `mrz.text` | The lines run together, one unbroken string, or `null` | | `fields[]` entry `mrz` | The zone's text as a field of the report | ## lines The zone's lines in order, exactly as read. Two lines for a TD3 passport, three for a TD1 card. Nothing is reconstructed. The lines come from the one field the recognition engine reports the zone in, never from the parsed values. A caller re-running the check digits over a reconstructed zone would be checking this service's arithmetic rather than the document's. The filler `<` is kept. It carries the padding the check digits are computed over, so trimming it would make the digits fail. What is removed is whitespace, which the zone's alphabet does not contain. A space or a tab inside a line is the reading equipment, not the document. ## text The same lines run together with **nothing between them**: no newline, no space, one unbroken string. ```json { "lines": [ "P", "reference": "order-1042", "options": { "mode": "full", "expect_country": null, "date_format": "iso", "return_portrait": true, "retain_hours": 0 } } ``` ## The five options | Option | Type | Default | Allowed values | |---|---|---|---| | `mode` | string | `"full"` | `"full"` | | `expect_country` | string or null | `null` | ISO 3166-1 alpha-3, three upper-case letters | | `date_format` | string | `"iso"` | `"iso"` | | `return_portrait` | boolean | `true` | `true`, `false` | | `retain_hours` | integer or null | `null` | `0`–`8760` | `options` is a strict object. A key that is not one of these five is refused with [`validation_failed`](/errors/validation_failed) and a 422, and the message names the offending path. A misspelled option is never ignored. ### mode Selects the recognition mode. `"full"` is the only value the contract declares, and it is the default. ### expect_country The country the caller expects the document to have been issued by, as an ISO 3166-1 alpha-3 code such as `GRC`. It is a hint carried into recognition, not an assertion. A document from another country is still recognized, and `document.country` reports what was read rather than what was expected. `null`, the default, expects nothing. ### date_format The format of every date in the response. `"iso"` is the only value the contract declares. Dates are `YYYY-MM-DD` and timestamps are `YYYY-MM-DDTHH:MM:SSZ`, in UTC. ### return_portrait Whether the holder's photograph is returned with the result. With `false` the crop is absent from the response and `images.main_photo` is `null`. The setting does not change what is stored, because no crop is ever stored. It changes what one response carries. ### retain_hours How many hours the result stays readable through `GET /v1/scans/{id}`. The range is `0` to `8760`, one year. | Value | Effect | |---|---| | `0` | Nothing is written down. No history row, no thumbnail, and no later read | | `1`–`8760` | The result is readable for that many hours from the scan | | `null` (default) | The account's own history-retention setting decides | `0` is not a short window. It writes no row at all, rather than a row that expires at once. Nothing exists in the interval that a read could find. An explicit value always wins over the account's setting, `0` included. The setting applies only when the request names no value. An upload made from the dashboard is the case that names none. The dashboard offers four windows for that setting: 24 hours, 7 days, 1 month and 1 year. A new account carries 1 year. The API accepts any integer in the range, so a value the dashboard does not offer is still a valid `retain_hours`. ## reference is not an option `reference` is a sibling of `options`, not a member of it. It is the caller's own correlation string, at most 128 characters, echoed back unchanged on the result and on every history row. Its default is `null`. ```json { "image": "", "reference": "order-1042", "options": { "retain_hours": 0 } } ``` Putting `reference` inside `options` is refused with [`validation_failed`](/errors/validation_failed), because `options` rejects a key it does not declare. ## What an invalid value answers Every refusal below is a 422 carrying [`validation_failed`](/errors/validation_failed), with the offending path in the message. Nothing reaches the recognition engine, and nothing is charged. | Sent | Refused because | |---|---| | `"mode": "fast"` | Not one of the declared values | | `"expect_country": "de"` | Not three upper-case letters | | `"retain_hours": 9000` | Above the maximum of 8760 | | `"retain_hours": -1` | Below the minimum of 0 | | `"retain_hours": 1.5` | Not an integer | | `"retain_days": 7` | Not a key `options` declares | | `"response_version": "2"` | Not a key `options` declares; this API has one response shape | ## Which options interact | Options | What happens | |---|---| | `retain_hours: 0` and an `Idempotency-Key` | The first result is returned once and not stored. A later retry under the same key answers [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | | `retain_hours` and the account setting | The explicit value wins, `0` included; the setting applies only when the request sends `null` or nothing | | `return_portrait: false` and the rest | `images.main_photo` is null; the other six image slots are unaffected | Nothing an option sets changes what a scan costs. Billing is decided by the outcome alone, which is [what a billed scan is](/concepts/what-a-billed-scan-is). --- # Errors Every error response carries one shape, whatever went wrong. ```json { "error": { "code": "not_found", "message": "No scan with id 01a0af18-cd8d-7a61-9f2d-4c7b8e105da3 exists or it has expired.", "docs_url": "https://doc.cheap/docs/errors/not_found", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` | Key | What it is | |---|---| | `code` | A stable, machine-readable string. Branch on this, not on the HTTP status alone | | `message` | A human-readable explanation. Safe to log, not meant to be parsed | | `docs_url` | The page for this code, which is one of the 21 below | | `request_id` | `req_` and a UUID. Quote it in support: it identifies the exact request in the logs | | `event_id` | Set only for a failure the service recorded as something to look at | Twenty-one codes exist, and every one of them has a page. A code raised only on an internal surface still reaches a caller's logs through support. A `docs_url` that answers 404 is worse than a short page. ## What `event_id` tells you `event_id` is the one key that says whose problem this is, and its value is decided per code rather than per request. | `event_id` | Meaning | Codes | |---|---|---| | Always `null` | A refusal the caller is meant to handle. The service answers and writes one log line; nothing is recorded as a failure | 17 of the 21 | | Present on the first of a window | A dependency is away. Worth recording, but not once per request: an afternoon of downtime would cost more events than a month's quota holds | `engine_unavailable`, `service_unavailable`, `rate_unavailable` | | Always present | The service did something it did not intend | `internal_error` | A non-null `event_id` resolves to that one failure, which is faster in support than a timestamp and a guess. ## The codes you handle These 17 are the API working. Each is a refusal with a reason, and each carries `event_id: null`. | HTTP | Code | Raised when | |---|---|---| | 400 | [`invalid_request`](/errors/invalid_request) | The request could not be read at all | | 401 | [`unauthorized`](/errors/unauthorized) | No usable API key | | 402 | [`insufficient_credits`](/errors/insufficient_credits) | The balance cannot cover one recognition | | 403 | [`registration_required`](/errors/registration_required) | The anonymous free allowance is spent | | 404 | [`not_found`](/errors/not_found) | No scan with that id, or no such route | | 409 | [`idempotency_conflict`](/errors/idempotency_conflict) | The key was used with a different body | | 409 | [`idempotency_in_progress`](/errors/idempotency_in_progress) | The first request under the key is still running | | 409 | [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | The key's result is no longer there to replay | | 409 | [`topup_in_progress`](/errors/topup_in_progress) | A top-up for this asset is open and part-paid | | 413 | [`payload_too_large`](/errors/payload_too_large) | The body is over 36 MiB | | 415 | [`unsupported_media_type`](/errors/unsupported_media_type) | The body was not sent as JSON | | 422 | [`validation_failed`](/errors/validation_failed) | A field failed the schema; the message names it | | 429 | [`document_repeated`](/errors/document_repeated) | The same image, too many times on the free sandbox | | 429 | [`rate_limited`](/errors/rate_limited) | Over the rate limit for this key kind | | 503 | [`maintenance`](/errors/maintenance) | A planned window is open | Two more are refusals as well, and no public call raises them; they are in the last section. ## The codes that mean we recorded something These four are not about the request. Three say a dependency is away, and one says the service failed. | HTTP | Code | Raised when | `event_id` | |---|---|---|---| | 500 | [`internal_error`](/errors/internal_error) | The service did something it did not intend | Always | | 503 | [`engine_unavailable`](/errors/engine_unavailable) | The recognition engine did not answer in time | First of a window | | 503 | [`service_unavailable`](/errors/service_unavailable) | A dependency this service needs is unreachable | First of a window | | 503 | [`rate_unavailable`](/errors/rate_unavailable) | Fewer than two price sources agreed on a rate | First of a window | None of the four charges for the scan it interrupted. The credit is reserved before the engine runs and released when it does not answer, so the balance is unchanged. `internal_error` can arrive under a 5xx other than 500, when the failure carried one of its own. ## The codes no public call can raise Two codes belong to the administrative surface, which is not part of the public API and is not reachable with an API key. They are listed because the contract declares them and because a body carrying one links here. An integration does not have to handle them. | HTTP | Code | Raised when | |---|---|---| | 403 | [`impersonation_read_only`](/errors/impersonation_read_only) | An administrator's view of an account tried to change something | | 501 | [`payment_driver_unavailable`](/errors/payment_driver_unavailable) | A top-up named a payment driver this deployment does not have | A call of your own that answers with either went to the wrong host or the wrong path. ## Branching on the code The HTTP status groups the codes; it does not identify them. Four codes share 409 and two share 429, and what to do differs inside each group. - Branch on `code`. It is stable, and a new code is a change to the contract. - Read `message` for a human, and do not parse it. The wording of a `validation_failed` message is the schema's, and it changes with the schema. - Follow `docs_url` when a person is reading. It is the page for that exact code, and it never moves. Which statuses are shared, and what separates the codes under each, is on [HTTP status codes](/reference/http-status-codes). --- # Limits Every ceiling a caller can meet, the code it answers with, and whether it moves. The figures below are the defaults a deployment ships with. Several are configurable by whoever runs the service. The right number depends on how much recognition capacity sits behind it, which is not a property of the contract. The ones that are not configurable say so. ## The rate limits | Caller | Limit | Window | Keyed by | |---|---|---|---| | `sk_sandbox_public` | 10 requests | 1 hour | The client's IP address | | A registered key | 60 requests | 1 minute | The key | The two tiers are different animals. The public sandbox key is unregistered and shared by everyone, so its bucket is per address and its window is long. It exists to keep one visitor from consuming the demo for the rest. A registered key is metered by its own balance, so its limit only keeps a runaway client from flooding the service. Over either, the answer is 429 [`rate_limited`](/errors/rate_limited). **The sandbox bucket is keyed by address, not by key.** Everyone shares one credential there, so counting per key would count everybody as one caller. ## The free allowances Two separate allowances govern the public sandbox key, and both apply. | Allowance | Figure | Answered with | |---|---|---| | Lifetime free recognitions, per anonymous client | 10 | 403 [`registration_required`](/errors/registration_required) | | The same image, on the free sandbox | 5 submissions per hour | 429 [`document_repeated`](/errors/document_repeated) | The repeat guard keeps only a digest of the image bytes, and only for the window — neither the image nor a durable record of it. It runs before the engine, so a refused repeat costs neither recognition time nor one of the free attempts. A registered account arrives with 20 free documents credited to its balance, and neither of these two walls applies to its keys. ## Sizes | Limit | Figure | Answered with | |---|---|---| | Request body | 36 MiB | 413 [`payload_too_large`](/errors/payload_too_large) | | Image, on either web surface | 25 MiB | Refused in the browser, before any request | | `reference` | 128 characters | 422 [`validation_failed`](/errors/validation_failed) | | `Idempotency-Key` | 1 to 255 characters | 422 [`validation_failed`](/errors/validation_failed) | **The body ceiling is derived, not chosen.** The largest image either web surface accepts is 25 MiB; base64 makes that about 33.4 MiB, and the rest is the JSON envelope. It is sized for the worst case on purpose. Normally the browser re-encodes the photograph first and the body is a few hundred kilobytes. A browser that cannot decode the image sends the original bytes, and a direct API caller sends whatever it likes. The ceiling is enforced **before the body is read into memory**. An oversized request costs nothing and reaches no engine. ## Retention | Limit | Figure | |---|---| | `retain_hours` | 0 to 8760, one year | | The windows the dashboard offers for the account setting | 24 hours, 7 days, 1 month, 1 year | | A new account's setting | 1 year | A value outside the range is 422 [`validation_failed`](/errors/validation_failed). The API accepts any integer inside it, so a window the dashboard does not offer is still a valid `retain_hours`. `0` is not a short window. It writes no row at all, so nothing exists that a later read could find. ## Result images | Slot | Height cap | |---|---| | `document_crop` | 250 px | | Every other slot | 100 px | Scaled by height, proportionally, and never upwards. A crop already inside its cap is published at the size the recognition produced it. The rest of what the re-encode does is on [result images](/reference/images). A retained scan keeps a thumbnail of at most 96 px and 16 KiB, readable in the dashboard rather than through this API. ## Time | Limit | Figure | What happens past it | |---|---|---| | The recognition engine's deadline | 15 s | 503 [`engine_unavailable`](/errors/engine_unavailable); nothing is charged | | The wait for an in-flight idempotent request | 2 s | 409 [`idempotency_in_progress`](/errors/idempotency_in_progress) | | The age at which an unfinished idempotency claim is reclaimable | 10 minutes | The next caller takes the key over | The engine deadline is far longer than a recognition takes, which is about a second. It exists so a request cannot hang on an engine that has stopped answering. ## What is not limited - **The number of scans an account may make.** The balance is the limit, and it is the caller's own. - **The number of API keys**, beyond a per-account ceiling the dashboard enforces. - **Concurrency.** Nothing caps parallel requests beyond the rate limit above. - **Reads.** `GET /v1/scans/{id}` and `GET /v1/usage` are never billed, and are bounded only by the rate limit for the key. ## Which limits move | Limit | Configurable | |---|---| | Both rate limits, and their windows | Yes, per deployment | | The free-recognition allowance | Yes | | The repeat-document threshold and window | Yes | | The request body ceiling | Yes | | The engine deadline and the idempotency waits | Yes | | `retain_hours`' range, `reference`'s length, the key's length | **No** — they are the contract | | The image height caps | **No** | A deployment that changes a configurable figure changes what its own API answers. The figures on this page are the ones `api.doc.cheap` runs. --- # HTTP status codes The status groups a failure; it does not identify one. Four error codes share 409, three share 503 and two share 429, and what to do differs inside each group. Branch on `error.code`, which is stable and named in the contract. The status is what a proxy, a load balancer and a metrics dashboard read. ## The statuses | Status | Codes | Retry? | |---|---|---| | 200 | — | Not an error; `meta.status` says how far recognition got | | 400 | [`invalid_request`](/errors/invalid_request) | No, until the request changes | | 401 | [`unauthorized`](/errors/unauthorized) | No | | 402 | [`insufficient_credits`](/errors/insufficient_credits) | After a top-up | | 403 | [`registration_required`](/errors/registration_required), [`impersonation_read_only`](/errors/impersonation_read_only) | No | | 404 | [`not_found`](/errors/not_found) | No | | 409 | [`idempotency_conflict`](/errors/idempotency_conflict), [`idempotency_in_progress`](/errors/idempotency_in_progress), [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable), [`topup_in_progress`](/errors/topup_in_progress) | Depends on the code | | 413 | [`payload_too_large`](/errors/payload_too_large) | No, until the image is smaller | | 415 | [`unsupported_media_type`](/errors/unsupported_media_type) | No, until the header changes | | 422 | [`validation_failed`](/errors/validation_failed) | No, until the field is fixed | | 429 | [`rate_limited`](/errors/rate_limited), [`document_repeated`](/errors/document_repeated) | After the window | | 500 | [`internal_error`](/errors/internal_error) | Once | | 501 | [`payment_driver_unavailable`](/errors/payment_driver_unavailable) | No | | 503 | [`engine_unavailable`](/errors/engine_unavailable), [`service_unavailable`](/errors/service_unavailable), [`maintenance`](/errors/maintenance), [`rate_unavailable`](/errors/rate_unavailable) | Yes, with backoff | ## 200 is not always a recognition A failure to recognize is a `200`. `meta.status` carries one of `recognized`, `no_document_found`, `unreadable`, `unsupported_document` and `rejected`, and the body is complete in all five cases. A consumer that treats every `200` as a recognized document reports a blank holder where the answer was that nothing was in the frame. Read `meta.status` first. ## The three 503s answer three different questions They share a status because a proxy should treat all three the same way. A caller should not. | Code | What is away | What to do | |---|---|---| | [`engine_unavailable`](/errors/engine_unavailable) | The recognition engine did not answer inside its deadline | Retry with backoff. Nothing was charged | | [`service_unavailable`](/errors/service_unavailable) | A dependency this service needs is unreachable | Retry after `Retry-After`, with backoff. Nothing was charged | | [`maintenance`](/errors/maintenance) | Nothing. An operator closed the service on purpose | Wait out the window. It does not clear on its own, so honor `Retry-After` rather than polling | The difference that matters: the first two clear when something comes back, and a retry loop finds the moment it does. The third clears when a person reopens the service. A fleet polling every second buys nothing there, and arrives all at once when it does. `rate_unavailable` is a fourth 503, and it belongs to crypto top-ups rather than to recognition. Fewer than two price sources agreed, so no amount could be quoted. ## The four 409s | Code | What happened | What to do | |---|---|---| | [`idempotency_conflict`](/errors/idempotency_conflict) | The key was used with a different body | Use a new key | | [`idempotency_in_progress`](/errors/idempotency_in_progress) | The first request under the key is still running | Retry the same key and body in a moment | | [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | The key's first result was not retained | Use a new key | | [`topup_in_progress`](/errors/topup_in_progress) | A crypto top-up for this asset is open and part-paid | Finish it, or wait for its window | Three of the four are about an `Idempotency-Key`. Which one arrives is what says whether to retry the same key or mint a new one. The rules are on [idempotency](/reference/idempotency). ## The two 429s | Code | The limit | Keyed by | |---|---|---| | [`rate_limited`](/errors/rate_limited) | Requests per window | The key, or the client address on the public sandbox | | [`document_repeated`](/errors/document_repeated) | The same image, too many times on the free sandbox | A digest of the image bytes | A caller that meets `document_repeated` while looping over one test image has met the wrong limit for the wrong reason. The request rate is fine; the picture is the problem. The figures are on [limits](/reference/limits). ## Retry-After A response carries `Retry-After`, in seconds, where the service can estimate a wait: the maintenance gate, a store outage, and the sandbox guards. Where it is absent, a few seconds of backoff is the right default. Nothing in this API sends a `Retry-After` in the HTTP-date form. ## What is never returned - **3xx.** The API answers no redirect on any versioned route. - **204.** Every response carries a body. - **418, 451, and the rest.** The list above is the whole set; a status outside it is a proxy in front of the service rather than the service. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). --- # Idempotency `POST /v1/scans` accepts an `Idempotency-Key` request header. A retry under the same key returns the first request's result instead of running recognition and charging a second time. ```bash curl -X POST https://api.doc.cheap/v1/scans \ -H "Authorization: Bearer sk_sandbox_public" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b" \ -d '{"image":""}' ``` ## The header | Property | Value | |---|---| | Name | `Idempotency-Key` | | Length | 1 to 255 characters | | Endpoint | `POST /v1/scans` only | | Scope | The account. Two accounts may use the same string without meeting | | Required | No | A key outside the length is 422 [`validation_failed`](/errors/validation_failed). Nothing constrains the characters. A UUID is the usual choice because it is unique without coordination; an order id works as well when one order is one scan. The header has no effect on a sandbox key that is not billed, because there is nothing to charge twice. ## What makes two requests the same request **The key, and a fingerprint of the whole request body.** The fingerprint is a SHA-256 over the body with its object keys sorted. Two bodies that differ only in the order they were serialized in are therefore the same request. Everything in the body is part of it: the image, the `reference` and every option. Changing `retain_hours` under a reused key is a different request, not the same one twice. The image itself is part of the fingerprint. Sending a different photograph under a reused key is what [`idempotency_conflict`](/errors/idempotency_conflict) exists for. ## The four outcomes | The claim | What happens | Answer | |---|---|---| | The key is new | The scan runs, and the key is claimed before it does | The result, 200 | | The key was used with this body, and its result is stored | The stored result is returned. Recognition does not run, and nothing is charged | The first result, 200 | | The key was used with a different body | Refused | 409 [`idempotency_conflict`](/errors/idempotency_conflict) | | The key was used, and no result was kept | Refused | 409 [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | A fifth case is timing rather than state: the key's first request is **still running**. ## A key whose first request is still in flight The claim is taken before the work runs. A second caller arriving while the first is in the engine therefore finds a claim with no result attached. It must not redo the work: that is the double charge the key exists to prevent. It waits briefly for the first request to land, then replays its result. The wait is 2 seconds by default, re-checked every 100 ms. Past the wait, the answer is 409 [`idempotency_in_progress`](/errors/idempotency_in_progress). A retry in a moment finds the result. A claim whose request died mid-flight would otherwise hold the key forever. Past **10 minutes** with no result attached, it counts as abandoned and the next caller takes it over. That is far longer than any request can legitimately run, so a slow scan is never mistaken for a dead one. ## How long a key is remembered A key is remembered for as long as the result it points at is stored, and no longer. No separate idempotency window exists. That makes `retain_hours` the control: | The first request asked for | A later retry under the same key | |---|---| | `retain_hours: 0` | 409 [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) — the result was returned once and never written down | | A window that is still open | The stored result, 200 | | A window that has since closed | 409 [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | **A zero-retention scan and a replayable key are mutually exclusive.** The key is still recorded as used, so the request is never run twice. Nothing is left to hand back. A retention sweep removes an expired scan and its key together. ## The three codes, and what each one says to do | Code | What it says | What to do | |---|---|---| | [`idempotency_conflict`](/errors/idempotency_conflict) | This key belongs to a different request | Send the new body under a new key | | [`idempotency_in_progress`](/errors/idempotency_in_progress) | The first request has not finished | Retry the same key and body in a moment | | [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) | The key is spent and its result is gone | Send the request under a new key | Two of the three call for a new key and one calls for a retry of this one. Branching on the code is what tells them apart; the shared 409 does not. ## What is not idempotent - **`GET` requests.** They change nothing, so nothing needs a key. - **Crypto top-ups.** A part-paid top-up is arbitrated by [`topup_in_progress`](/errors/topup_in_progress) rather than by a key. - **A scan on a key that is not billed.** The header is accepted and has nothing to protect. ## What a key does not do It does not make a failed request succeed. An engine that did not answer releases the reservation and frees the key. A retry under the same key then runs the scan properly, rather than replaying a failure. It does not make a retry free. A retry that replays a stored result costs nothing because nothing ran. A retry that runs recognition is billed by its own outcome, like any other scan. --- # Versioning Two numbers are published, and they say different things. | Number | Where | What it names | |---|---|---| | `v1` | The path, `https://api.doc.cheap/v1/…` | The API: its endpoints, its authentication, its error shape | | `1.0` | `meta.schema_version` in every result | The revision of the response body | One response shape exists, and no way to ask for another. A request that tries to select one is refused with 422 [`validation_failed`](/errors/validation_failed) naming what it sent. Silently serving the only shape there is would leave the caller believing something else arrived. ## `meta.schema_version` Every result carries it, and its value is `"1.0"`. It is a string, not a number: `"1.0"` and `"1.10"` are different revisions, and arithmetic on them is wrong. A consumer that pins the value fails loudly on a revision it was not written for, which is the point of publishing it. A consumer that reads the body without checking gets whatever the current revision means by each key. ## What counts as breaking A breaking change is one that can turn a working consumer into a broken one without the consumer changing. | Change | Breaking | |---|---| | Removing a key from a response | Yes | | Renaming a key | Yes | | Narrowing a type — a nullable key that stops being nullable is not breaking; one that starts being nullable is | Yes, when it widens | | Adding a value to an enum a consumer branches on | Yes | | Removing a request field | Yes | | Making an optional request field required | Yes | | Changing what an existing key means, at the same type | Yes | | Adding a key to a response | No | | Adding an optional request field | No | | Adding a new endpoint | No | | Adding an error code to an endpoint that already answers errors | No | | Widening an accepted range | No | A removal or a rename moves the **major** segment of `meta.schema_version`. An addition moves the minor segment. ## What a consumer should assume - **Keys may be added.** Ignore what you do not recognize rather than refusing the body. - **`fields` is an open set.** A key the [field catalogue](/reference/fields) does not list is still published; render it from `label` and `category` rather than dropping it. - **A string enum may gain a value.** Branch on the values you know and have a default; `meta.status` is the one to be careful with. - **Null is a value.** Every key is present, and a key whose value is unknown is `null` rather than absent. A consumer built on those four assumptions survives every change in the non-breaking column without a release. ## How a breaking change would be announced Nothing here has broken yet — version 1 is the first production version, and this section describes what would happen rather than what has. A breaking change to the response body would arrive as a new major `meta.schema_version`. The previous revision would keep being served to callers that ask for it, by a mechanism published with the change. A breaking change to the API itself would arrive under a new path segment beside `/v1`, and `/v1` would keep answering. Either would be announced on the [changelog](/changelog), which carries an Atom feed at [`/changelog/feed.xml`](/changelog/feed.xml), before the change lands rather than with it. An address, once published, keeps working. The per-code error pages are the clearest case. `docs_url` in an error body is `/errors/`, and a body already sent cannot be rewritten, so those addresses do not move. ## What is not versioned - **`request_id` and `event_id`.** Both are opaque strings. Their shape is not part of the contract, and code that parses either is reading something that was never promised. - **The scan id.** A UUID version 7 in canonical form, and opaque past that. - **A `message` string.** It is for a person. The `code` beside it is the stable half. - **The recognition engine's own behaviour.** A document that reads better next month reads better without a version moving; recognition quality is not a contract. --- # Service levels The full status page is at ****. It is the page to open when something looks wrong. It carries the current state of each part of the service and ninety days of history, day by day. It also carries recognition processing times, past incidents and anything scheduled. The two tiles at the bottom of this page are a short extract of it. ## The JSON behind it The same numbers the page draws are served as JSON by the API. The routes need no key, no session and no cookie, and they carry nothing about anybody. They answer every origin (`access-control-allow-origin: *`), so a dashboard of your own may read them straight from a browser. | Route | What it returns | |---|---| | `GET https://api.doc.cheap/status/summary.json` | The whole document. The headline state; every component with its 30- and 90-day uptime and a day-by-day strip; recognition percentiles, open incidents and scheduled maintenance. | | `GET https://api.doc.cheap/status/history.json?component=&days=` | One component's daily history: color, uptime, downtime and maintenance minutes, and the day's recognition percentiles. | | `GET https://api.doc.cheap/status/incidents.json?days=` | Incidents that started in the window, with their updates. | An **Atom feed** at **** carries one entry per incident, with its impact, its state and every update newest first. That feed is the subscription channel. Point a feed reader at it and you hear about an incident without anybody holding your email address, and with nothing to unsubscribe from. `component` is one of `api`, `recognition`, `dashboard`, `docs`, `topups` (default `api`); `days` defaults to 90 and is capped at 400. Caching, so polling is cheap: - `summary.json` — `cache-control: public, max-age=30, stale-while-revalidate=300` and a strong `ETag`. The document is rebuilt once a minute, so a poller that sends `If-None-Match` gets `304 Not Modified` most of the time. - `history.json` — `public, max-age=300`, also with an `ETag`. - `incidents.json` — `public, max-age=60`. Every document starts with a `schema` number: branch on it before parsing the rest, because it changes when the shape does. One address may make 60 requests a minute across these routes; past that they answer `429`. If the document cannot be rebuilt, the last good copy is served with `"stale": true` rather than an error. A reader then sees figures that are explicitly out of date, instead of a page that will not load. ## What is measured Five components, each judged by its own evidence. | Component | Judged by | Core | |---|---|---| | Recognition API | The share of `/v1` requests that failed, and whether the instances were in rotation | Yes | | Document recognition | The canary below | Yes | | Dashboard and website | The web app's own probe | Yes | | Documentation | The documentation probe | Yes | | Crypto top-ups | The deposit watch | **No** | A non-core component is hidden while it is healthy. A green row for a path most visitors never take spends attention for nothing. ### The canary Availability of recognition is measured by a **synthetic recognition, once a minute**, not by a port check. A drawn specimen is posted to the service's own `POST /v1/scans` over loopback, with `retain_hours: 0`, under a deadline of the engine's own timeout plus five seconds. It goes through the routing, the maintenance gate, the authentication guard, the rate limiter and the engine — the same path a customer's call takes. Only a scan proves a scan works. Three consecutive failures mark recognition down. The canary carries a credential of its own with its own rate bucket, and it records nothing while a maintenance window is open. **The document it sends is synthetic, and under a real engine it is not recognized.** The figures prove the path is alive; they do not prove a customer's document reads correctly. **Planned maintenance** is announced before it starts and is not counted as downtime. The page lists what is scheduled for the next 30 days, and anything in progress now. ## The two latency figures, and what each one is They are not the same measurement, and neither is a substitute for the other. | Figure | What it is | Over | |---|---|---| | `recognition_ms` | **True** p50 and p95, computed over the canary's individual durations | The last 24 hours | | `engine_typical_slow_minute_ms` | The **median of the per-minute p95 samples** over all customer scans | The published window | `recognition_ms` is a percentile: the raw durations are kept and the percentile is taken over them. The figure beside its `samples` and `window_hours` says how many runs it is a percentile of. `engine_typical_slow_minute_ms` is **not a percentile**, and it is never called one. Each minute's own p95 is sampled, and the median of those samples is published. It answers what a slow minute looks like. A percentile over a whole day cannot answer that: a day's p95 is dominated by whichever minute was worst. The page labels it "a typical slow minute" for that reason. ## Incidents An incident is opened automatically after five minutes of a major outage on a component, with a fixed sentence that names nothing about the cause. A second component that goes down joins that incident rather than starting another one. Fifteen minutes after everything it names is working again it moves to `monitoring`, and it is resolved by a person. The vocabulary is the one other status pages use — `investigating`, `identified`, `monitoring`, `resolved`, with an impact of `none`, `minor`, `major` or `critical`. The headline at the top of the page is derived from the components, never set by hand, and it treats them differently on purpose. An outage of the API, recognition, the dashboard or the documentation is the product not working, and the banner says so. An outage of crypto top-ups is named in the banner and capped at "degraded," while its own row still reports the outage in full. A banner that shouts about a path most readers never take is a banner people learn to ignore. ## How uptime is counted Atlassian Statuspage's formula, carried out unchanged, so the figure means the same thing as on the other status pages you read: ```text counted = total minutes − maintenance minutes downtime = major-outage minutes + 0.3 × partial-outage minutes uptime = (1 − downtime ÷ counted) × 100 ``` Three consequences worth knowing before you compare numbers: - **Degraded is not downtime.** A service that was slow was serving. Counting it as downtime would make "uptime" mean "was never less than perfect." - **Maintenance leaves the denominator.** Announced work is removed from the counted minutes, not moved into the up column: it neither helps nor hurts the figure. - **A partial-outage minute is worth 0.3 of a lost one.** Some callers got through, so the minute was not lost; some did not, so it was not whole. Over a longer period the sums are carried, not the ratios. The 30- and 90-day figures are minute-weighted, never the average of the daily percentages. A day with four minutes of traffic does not get the same say as a day with fourteen hundred. ## The windows and the error budget Three windows are published, and each answers a different question. | Window | What it is for | |---|---| | 24 hours | The recognition percentiles. Recent enough to describe today | | 30 days | The headline uptime figure, and the error budget | | 90 days | The history strip, one column per day | The availability target is published beside the figures, and so is the **error budget**. That is the number of downtime minutes the target allows over 30 days, and the share of them the period has spent. A target of 99.9% over 30 days is about 43 minutes. Where a deployment publishes a speed target, the page also reports the share of recognitions that came in under it. With no target set, the durations are reported and nothing is claimed about them. Daily rows are kept for 400 days, so a 90-day window always has a full year behind it. ## The per-process snapshot `GET https://api.doc.cheap/status.json` is a different and much smaller thing. It carries one API process's request latency — p50, p95 and p99 over a rolling window of recent requests. It also reports how long that process has been up since its last restart. It is useful for looking at a single instance, and it says nothing about the service over time. For that, read the status page above. ## If something looks wrong Retry first: most `500`s are transient. The code in the body says which failure it was, and [errors](/reference/errors) has a page for each. For anything persistent, quote the `request_id` from the failing response, and the `event_id` where the body carries one. Both identify the exact request in the logs. --- # Glossary Every term below is used with one meaning across this documentation, the API and the dashboard. Where a word is commonly used loosely, the entry says what it is **not**. ## The call and its result **Scan.** One call to `POST /v1/scans` and the result it returns. Not a job, not a transaction, not a document check. A scan is one image; a two-sided card is two scans. **Result.** The body a scan returns: eight groups, described key by key on [the response](/reference/response). One shape, whatever the outcome. **Reading.** One source's value for one field — the machine-readable zone's spelling of a surname, or the printed page's. A document printed in two scripts produces two readings of the same field, and they may differ. **Field.** One entry of `fields`. Not an attribute, not a property, not a key. Every field carries a `name`, a `label`, a `category`, a `value`, a `language` and a confidence band; the whole catalogue is on [the field catalogue](/reference/fields). **Status.** `meta.status`, one of `recognized`, `no_document_found`, `unreadable`, `unsupported_document` and `rejected`. A judgement of how far recognition got, never of whether the document itself is genuine. **Recognized.** The `meta.status` value meaning the document type was determined and data came out of it. Not a synonym for successful, valid, verified or accepted, and not a synonym for **billed**. **Confidence band.** `high`, `medium` or `low`. The engine's probability expressed as one of three words, cut at 90 and 60 out of 100. Never a number in the response. **Verdict.** A single conclusion drawn from several checks — `mrz.status` and `quality.overall` are the two. A verdict names what failed where it can. ## The document **MRZ.** The machine-readable zone: the block of upper-case letters, digits and `<` fillers a travel document prints for a machine to read. The formats and the check digits are on [the MRZ reference](/reference/mrz). **Visual zone.** The printed face of the document, as a person reads it. The same facts a travel document also encodes in its MRZ. **Check digit.** A digit the zone carries over one of the fields it protects, computed by ICAO 9303's arithmetic. Its outcome reaches a caller as part of `mrz.status`, not as a digit of its own. **TD1, TD2, TD3.** The three machine-readable-zone layouts: three lines of 30, two of 36, and two of 44. A passport booklet is TD3; an identity card is usually TD1. **Crop.** One of the seven pictures a recognition cuts out of the uploaded image. Capped by height, re-encoded without its metadata, and never stored. **Specimen.** An invented document used in an example or a fixture. Every example in this documentation uses one invented holder. The word `SPECIMEN` printed across a sheet says that the sheet is not a document. ## Money **Credit.** The unit of the balance. One credit is one US cent, and one recognized document draws one. Not a token, a unit or a point. **Billed.** Whether a scan drew a credit. `meta.billed` is the answer, and it is a separate question from **recognized**. A scan can be billed without being recognized; the rule that decides is on [what a billed scan is](/concepts/what-a-billed-scan-is). **Balance.** The credits an account holds. It is also the number of documents left, because one document is one credit. It never goes negative. **Reservation.** A credit held before the engine is called and settled after. A scan that never reached the engine releases it, so nothing is charged. **Top-up.** Adding credits to a balance. A crypto top-up quotes an amount at a price that is locked for the life of the quote. ## Keys and identity **Live key.** `sk_live_…`. A key that bills. Not a production key, a real key or a secret key. **Sandbox key.** An account's own `sk_sandbox_…` key. Never billed, and answered from a fixed synthetic specimen so a client can build against a stable result. Not a test key or a dev key. **Public sandbox key.** `sk_sandbox_public`, the one printed in every example. No account, never billed, and it **runs real recognition on the image it is sent**. A lifetime free allowance and a rate limit per address bound it. Not a demo key, a trial key or an anonymous key. **Session.** How the dashboard identifies a person, as against how an API key identifies an integration. The two surfaces are kept apart on purpose. **Account.** What owns a balance, a set of keys, a history and a retention setting. ## Time and storage **Retention window.** The hours a result stays readable through `GET /v1/scans/{id}`. Set per request with `retain_hours`, or by the account's own setting when the request names none. Not a TTL, a storage period or an expiry. **Zero retention.** `retain_hours: 0`. No history row is written at all, so nothing exists that a later read could find — as against a row that expires immediately. **Thumbnail.** A picture of at most 96 px that a retained scan keeps, readable in the dashboard rather than through this API. It is made from the uploaded bytes, not from a crop. **Idempotency key.** The `Idempotency-Key` header that makes a retried scan return the first result instead of running and charging again. The rules are on [idempotency](/reference/idempotency). ## The interface **The dashboard.** The web application at `doc.cheap` where a person signs in. Not the cabinet, the portal, the console or the panel. **Error code.** The stable string in `error.code`. Twenty-one exist, and branching on the code is what an integration does; the HTTP status groups them. **`request_id`.** `req_` and a UUID, on every error body. It identifies that one request in the service's logs. Opaque. **`event_id`.** Present only when the service recorded the failure as something to look at. `null` on every error a caller is meant to handle. Opaque. **`reference`.** The caller's own correlation string, up to 128 characters, echoed back on the result and on every history row. The service never reads it. **Engine.** The recognition engine behind the API. It is always "the Engine" here — never a vendor's name, a library or a model. --- # Concepts This section explains the reasoning behind the contract. It answers "why does it work that way." That is a different question from "what does this field mean," and it gets a different kind of page. Nothing here is a set of steps. The tasks are in the [guides](/guides), and the exact contract is in the [reference](/reference). These pages are what to read when one of those surprised you. Each page argues for one decision. Why a credit is one US cent and is reserved before the engine runs. Why confidence is three bands and not a number. Why the uploaded image is never written down. ## The recognition itself - [How recognition works](/concepts/how-recognition-works) — what happens between the bytes arriving and the fields coming back, and where our own timing boundaries fall. - [Confidence and readings](/concepts/confidence-and-readings) — why confidence is a band, and why one field can be read several ways at once. - [The MRZ and the visual zone](/concepts/mrz-and-the-visual-zone) — why a document says the same thing twice, and which half to believe. ## Money - [What a billed scan is](/concepts/what-a-billed-scan-is) — the rule that decides whether a call costs a credit, and the outcomes it produces. - [Crypto deposits](/concepts/crypto-deposits) — why an address is permanent, why a locked price has bounds, and why a large deposit waits longer. ## Your data and your identity - [Data retention and privacy](/concepts/data-retention-and-privacy) — what is kept, what is never written down, and what deletion removes. - [API keys and sessions](/concepts/api-keys-and-sessions) — the three kinds of key, the dashboard session beside them, and why the two are kept apart. ## When it does not work - [Reliability](/concepts/reliability) — what is measured, what a headline means, and what happens to your credit when a dependency is away. Each page is bounded on purpose. An explanation that grows a field table has become a reference page, and it belongs in the other section. --- # How recognition works A scan is one HTTP request that goes out with a photograph in it and comes back with the document's contents. Between those two moments the service does five distinct things. Knowing where one ends and the next begins explains most of what the response says. The whole of it happens inside the request. Nothing is queued, nothing is deferred, and the answer a caller reads is the answer that was computed while the connection was open. ## The bytes arrive The image travels base64-encoded inside the JSON body. Base64 is about a third larger than the bytes it carries, which is why the body ceiling is larger than the image ceiling behind it. The ceiling is enforced before the body is read into memory. A request over it is refused without ever being assembled, which costs nothing and reaches no recognition. Then come the gates that decide whether this caller may run a scan at all. The key is resolved, the rate bucket is consulted, and the free allowances on the public sandbox key are claimed. All of it happens before any recognition, because a caller who cannot be served should not consume recognition capacity to find that out. The balance is the last of those gates on a live key. A credit is put on hold before the engine is called. An account that cannot pay is turned away at that point rather than after the work. `meta.timing.upload_ms` covers the first part of that and not the rest. It is taken once the body has arrived and been validated, with the key resolved and the rate bucket consulted. The free allowances and the credit hold are claimed after it, so they fall under `total_ms` instead. The number is therefore the caller's own link, the parsing of their bytes, and the key check. It is reported separately because a caller with a slow integration needs to know which half to attack. ## The engine reads the document The recognition itself is one call to the Engine, and it is the part that takes the time. Four things happen inside it, and each one can be the point at which the answer stops improving. **Locating the document.** The picture is searched for something document-shaped. A data page filling most of the frame is found immediately. A card lying on a patterned desk under a lamp is the case that fails. When nothing is located, no later stage has anything to work on. **Deciding what it is.** A located document is matched against a catalogue of document types. The match fills `document.kind`, `document.country` and `document.type_name`. It carries its own confidence, reported as `document.type_confidence`. Being sure that a page is a passport and being sure of the surname on it are two different kinds of sure. **Reading the zones.** A document carries its contents in more than one place, and each place is read on its own terms. The machine-readable zone is a fixed grid of characters printed to be read by a machine. The visual zone is everything else the page prints, read as text. A barcode, where one exists, is decoded. Every field the engine produces names the zone it came from, and a field printed in two scripts is read once per script. **Checking what it read.** The zone's check digits are recomputed and compared against the digits the zone prints. Where a field appears in two zones, the two readings are compared with each other. The picture itself is assessed for whether it was good enough to read from at all. `meta.timing.processing_ms` is that call and nothing else. It is the figure to watch when comparing our service against another. It is also the figure that moves when a photograph is larger or worse than usual. ## The answer is turned into a response What the engine returns is its own working material: containers, numeric field types, per-zone values and integer check results. The response is a translation of it, and the translation is deliberate rather than mechanical. Field types become our own stable keys, so a consumer reads `surname` rather than an integer. Numeric language identifiers become language names. A probability becomes a band. A pile of per-digit results becomes one verdict with a sentence. Two field types the engine reports are deliberately withheld, because each would hand a reader something they could not act on. The image crops are resized to their caps and re-encoded without any of their metadata. What is published illustrates the result; it is not a copy of the picture that was sent. That work sits **outside** `processing_ms` on purpose. It is our own post-processing, not recognition, and counting it as recognition would make the number say something it does not. It is still inside `total_ms`, which covers the whole request from the first byte to the finished answer. ## The scan is settled On a live key, the credit held before the engine call is now resolved. A billable result commits the hold; anything else releases it and the balance is untouched. The rule that decides which is on [what a billed scan is](/concepts/what-a-billed-scan-is). The result is written down in the same transaction as that ledger move, when the retention window resolved for the request is above zero. When the window is zero, no row is written at all — not a row that expires early, no row. The uploaded image is not part of any of that. It was decoded in memory, handed to the engine, and is gone when the request ends. ## Why the status has five values `meta.status` is one of five strings, and they are not a severity scale. Each one names how far the sequence above got. A scan is `recognized` when the type was determined, at least one zone was read, and the engine reported success. It is `no_document_found` when nothing document-shaped was located, and `unsupported_document` when something was located whose type is not in the catalogue. It is `unreadable` when the type was determined and no zone could be read off the page. It is `rejected` when recognition ran to the end and the engine did not call the result a success. None of these is an HTTP error. All five arrive with a `200`, because the request was well formed and the service did the work it was asked to do. The document did not yield what the caller hoped for, which is a fact about the photograph rather than about the call. That separation is worth holding on to. An HTTP error means the call could not be processed; a status means it was processed and this is what came out. ## Why the call is synchronous A recognition takes well under a second in the ordinary case. That number is what makes the whole design possible. The alternative is a job: the caller posts an image, gets an identifier, and learns the answer later. It buys nothing here and costs a great deal. It costs a delivery contract. A callback has to be registered, authenticated, retried, de-duplicated and monitored, by us and by every caller. An integration that wanted a result now would still poll for it. It costs a second store. A queued job is a row that exists between the request and the answer. That row is a copy of an identity document's contents, sitting somewhere waiting. The synchronous design has no such row. And it costs clarity. A failure inside a job is reported into a channel the caller has to build. A failure inside a request is an HTTP status the caller already handles. What the caller gives up is the ability to fire a call and forget it. That is recovered with a worker of their own, which is where the waiting belongs anyway. ## Why the picture decides the result Almost nothing a caller can configure changes what comes out. `expect_country` carries a hint, and the rest of the options decide what is returned and kept rather than what is read. The photograph decides. A data page that fills the frame, is in focus, is lit without glare and is not cropped through the zone reads correctly. One that is none of those does not, and no option compensates. That is worth knowing because it puts the improvement where it can be made. A poor recognition rate is usually a capture problem, and it is fixed on the device holding the camera rather than in the request body. ## What the service does not do It does not make a second attempt on a bad picture. One call is one recognition of one image, and a retry is a new call the caller decides to make. It does not read more than one document per call. A picture containing two cards is a picture with one document located in it, and the other is ignored. It does not put a person in the path. Nothing is reviewed, corrected or re-typed between the engine and the response. A reading that disagrees with the page therefore arrives as a disagreement rather than as a fix. It does not keep the picture. That is the subject of [data retention and privacy](/concepts/data-retention-and-privacy). --- # Confidence and readings Every value in a recognition result arrived with some amount of doubt attached to it. How that doubt is reported is a decision, and this page is the argument for the decision that was taken. Two things follow from it. Confidence is published as a band and never as a number. A field read more than once is published once per reading, rather than collapsed into a winner. ## Why a band and not a number The engine produces a probability for each value it reads. Publishing that number would be the obvious thing to do, and it would be worse than what the service does instead. A recognition probability is not a calibrated percentage. It is a score a model produced about its own output. It does not mean that 97 out of 100 values scored at 97 are correct. Nothing in the pipeline makes that true, and nothing measures whether it is. A caller who sees `0.97` builds a threshold on it anyway. That is what a number invites: somebody writes `if (confidence > 0.95)` and ships it, and the threshold now carries a claim nobody ever supported. When the model changes, the distribution behind the number moves and the threshold silently starts meaning something else. The result therefore publishes `high`, `medium` or `low`, and the boundaries are fixed: `high` from 90, `medium` from 60, and `low` below that. A value whose probability the engine did not report reads `low` rather than being absent. Absence is the one answer that would let a consumer treat unknown as fine. Three bands are about as much as the underlying number honestly supports. They are enough to sort a queue, to route a document for review, or to color a cell in a report. That is what a caller actually does with confidence. ## What a band is about A band describes **the reading**, not the document and not the holder. A `low` band on a surname says the characters were hard to make out. It does not say the surname is wrong, and it does not say the document is forged. It is not an authenticity signal of any kind. The reverse matters as much. A well-made counterfeit produces a `high` band on every field. Its print is clean, so it reads cleanly. Confidence measures legibility. The document's own type match carries its own band, `document.type_confidence`. It answers a different question again: how sure the engine is that this page is the kind of document it says it is. ## Why one field has several readings A document prints the same fact in more than one place, and sometimes in more than one script. The engine reads each of them. The response keeps them apart. `fields` carries one entry per reading, and each entry names the language it was read under. A Greek passport prints the holder's surname in Greek and again transliterated into Latin, and both entries are published, each with its own band. Collapsing them would throw away the only thing that makes a disagreement visible. Two readings of one name that differ are a signal; one value chosen by us for reasons the caller cannot inspect is not. This is why an entry's `id` and not its `name` is the unique value. `name` repeats across the readings of one field, on purpose, and a consumer keying on it keeps whichever arrived last. The curated blocks beside the list — `holder` and `document` — carry one value each, in Latin. A great many consumers want exactly that, and should not have to walk a list to get it. The list is there when the choice matters. ## Why the picture gets one verdict `quality.overall` is a single word about the uploaded picture, and there is no breakdown beside it. An earlier shape published the engine's own list of checks. Each check was named by an integer, and those integers have no verified map to anything a person can read. A caller was handed `check_7: fail` and could act on none of it. Not on which check it was, not on what would satisfy it, not on whether it mattered. One verdict a caller can act on is worth more than a list they cannot. A scan whose quality reads `fail` is a scan to re-photograph, and that is the whole of what the breakdown would have told them. The same reasoning governs the machine-readable zone. Its verdict is a status and one sentence naming what did not check out, rather than a table of per-digit results. What the sentence names is enough to act on. The zone itself is published verbatim for anyone who wants to re-run the arithmetic. ## Why `not_checked` is not `pass` Both `quality.overall` and `authenticity.overall` can read `not_checked`, and that value exists to avoid a lie. A scan read back from storage carries no engine output. The result is rebuilt from what was written down when the scan ran. The engine's assessment of the picture was not part of it. The picture itself was never kept, so nothing is left to assess. Reporting that as `pass` would vouch for a photograph this process never saw. A consumer reading a stored scan would be told the picture was good enough, on the authority of nobody. `not_checked` says the honest thing: nothing measured this. It is a third answer, distinct from both `pass` and `fail`, and code that branches on quality has to handle it. That is the cost of the honesty, and it is small. `authenticity.overall` reads `not_checked` for a different reason. This service performs recognition, and recognition is not authentication. Nothing here inspects security features, and a field that would report on them says so rather than staying silent. ## What to do with a low band A band is an input to your own policy, and the policy is yours because the consequences are. A document that decides a small refund and a document that opens an account do not deserve the same threshold. No default we could choose would be right for both. What the service can do is report legibility honestly and leave the decision where the risk is. Two things are worth pairing with the band. The machine-readable zone's verdict is an independent check on the same values, and it is arithmetic rather than opinion. And a second reading of the same field, where one exists, is a second opinion the engine already gave you. --- # The MRZ and the visual zone An identity document prints the holder's name twice, the date of birth twice and the document number twice. That looks like redundancy and is not. The two printings are for two different readers, and the difference between them explains several things about the response that otherwise look arbitrary. ## Two readers, two zones The **visual zone** is the document as a person reads it. It is laid out for the eye: labels beside values, a photograph, a signature, whatever typeface and language the issuer chose. It carries everything the issuer wanted on the page. The **machine-readable zone** is a block of upper-case letters, digits and `<` fillers. It sits at the bottom of a data page, or on the back of a card. Its alphabet has 27 characters and nothing else, its lines are a fixed length, and every value sits at a fixed offset. It exists so that a border desk can read a passport in a second. No language, font or layout gets in the way. Its most useful property is arithmetic. The zone carries check digits. One covers the document number, one covers each date, and a final one covers the composite of the protected fields. Each is a weighted sum modulo 10 over characters that are already in the zone. That makes the zone the one part of a recognition result a caller can verify without trusting the service that produced it. It is published verbatim for that reason, lines and all, rather than being reassembled from the parsed values. ## What each zone can hold The zone is small, so it holds only what its format has room for. That is the document type, the issuing state, the name and the number. It is also the date of birth, the sex, the nationality, the date of expiry, and one optional-data field. Everything else is visual only. An address, a place of birth, an issuing authority, a licence category, an eye color: none of them has a slot. None of them can be checked by a check digit. **No format encodes a date of issue.** A date of issue in a result is therefore always a reading of the printed page. It can be absent on a document whose zone passed every digit. The zone was read perfectly and never carried it. The optional-data field is where a personal number travels, when the issuer puts one there. The TD2 layout has no room for it, so a TD2 travel document reports no personal number even when the page prints one. ## How the two are merged The engine reads a field from each zone that carries it. It reports a merged value for the field, alongside the per-zone readings it merged from. The response publishes the merged value, one entry per language. It does not publish the per-zone readings as separate entries. A caller arbitrating between them would need the issuer's own transliteration rules, which is exactly the knowledge the engine already applied. What the response does publish is the **outcome** of the comparison. When a field the two zones both carry disagrees, the zone's verdict becomes `failed`. The sentence names the field: `MRZ does not match the visual zone for: surname`. A caller who never looks at an individual reading still learns that the document contradicted itself. Fields that exist only inside the zone are left out of the field list altogether. Take the zone's own type code, its filler and its structural values. Their content is either the zone's plumbing or a duplicate of the printed page. The verdict on all of it is the `mrz` block. ## Per-field consequences The design above produces a handful of behaviors that surprise people once each. A field can be present and unverifiable. An address read from a licence has no check digit behind it and never will, and its only backing is its confidence band. A field can be verified and still contradicted. A document number whose check digit passes was correctly transcribed into the zone. If the page prints a different one, both facts are true at once and the verdict is `failed`. The zone can be perfect and the page unreadable. A photograph that cut off the top half of a passport can still yield a passed zone, a name and a number. It yields no issue date, no place of birth and no authority. The page can be complete and the zone absent. Most driving licences carry no zone at all, and `mrz.status` reads `absent` with its `reason`, `lines` and `text` all null. Nothing failed. ## Where the formats come from The zone is not our invention, and neither is its arithmetic. Both come from a published international standard for travel documents. That standard defines three layouts. A passport booklet uses two lines of 44 characters. An identity card uses three lines of 30, and an older card or travel document uses two lines of 36. The layouts differ because the documents differ in size, and each one packs the same values into the room it has. A shorter format drops what it cannot fit, which is why one of them carries no optional-data field at all. Two things follow for a caller. The zone on any of the three yields the same set of values once it is read. Code that handles a passport handles a card. And a library that implements the standard can verify our published zone without knowing anything about this service. ## Which one to believe When the two agree, the question does not arise, and that is the ordinary case. When they disagree, the zone is the better-evidenced half. It was printed by the issuer in a fixed format, and it carries its own arithmetic. A transcription error inside it is caught by that arithmetic rather than by anybody's judgement. The page is the richer half. It carries the fields the zone has no room for, and it carries them in the script the issuer actually printed. Neither of those makes the other wrong. A disagreement between two zones of one document is a document to look at, not a value to pick. The two were produced together by one issuer at one moment, and something has to explain why they no longer match. The ordinary explanation is a poor photograph, and a second scan settles it. The interesting explanation is that one of the two was altered. ## Why this is not an authenticity check A zone whose every digit passes says the zone is internally consistent. It says nothing about whether the document is genuine. Check digits are a published algorithm over published data. Anybody making a counterfeit computes them correctly, because getting them wrong is the one mistake that any reader catches for free. What the zone's verdict is good for is catching damage, glare, wear and transcription. Those are the accidents that make a reading wrong without anybody intending it. That is a large share of the bad data an integration meets, and the zone catches it cheaply. Recognition is not verification, and this service reports the first. The `authenticity` block says `not_checked` rather than pretending otherwise, for the reasons on [confidence and readings](/concepts/confidence-and-readings). --- # What a billed scan is **1¢ per document. Same price for everyone — from 1 to 100 million documents.** One credit is one US cent, and one credit buys the recognition of one document. That is the whole price list. No seats, no minimums, no negotiated rate. The interesting question is not what a document costs. It is which calls count as a document, and that is a rule rather than a judgement. ## The predicate A call is charged when **the document type was determined** and **at least one of three kinds of evidence came out of it**. The evidence is any one of three things. A machine-readable zone whose check digits pass, five or more fields read from the visual zone, or a correctly decoded barcode. One of the three is enough, and a document that yields all three is still one credit. Both halves are required. A page the engine could not identify is not a document, however much text came off it. A correctly identified document that yielded nothing is a recognition that produced no value. The five-field floor is the least obvious of the three, and it is a threshold rather than a principle. A visual zone that gave up a surname and nothing else is not a recognized document. One that gave up a name, a number, two dates and a nationality is. Five is where those two cases separate on real documents. Every response carries `meta.billed`, so your own records match ours without a reconciliation step. The figure it sums to is the one `GET /v1/usage` reports for the period. ## Why `billed` and `recognized` are not the same word The response carries two independent facts, and conflating them is the mistake this section exists to prevent. `meta.status` says how far recognition got. `meta.billed` says whether the predicate above was satisfied. They usually agree, and the cases where they do not are real. A scan can be **recognized and not billed**. The type was determined, one zone was read, and the engine called it a success. What came out was two visual fields, no zone and no barcode. The predicate is not met, and nothing is charged. A scan can be **billed and not recognized**. The type was determined and a zone with valid check digits came out, and the engine declined to call the overall result a success. The caller has the document's contents; the credit is drawn. Neither case is a defect. One word is about the process and the other is about what the process produced. A caller reconciling a bill reads the second one. ## The five outcomes Recognition ends in one of five states, and four of them are free. A scan is **recognized** when the type was determined and the data came out. That is the state a charge normally goes with. It is **no document found** when nothing document-shaped was located in the frame. A photograph of a desk, a finger over the lens, an empty page. It is **unreadable** when a document was there and its type was determined, and no zone could be read off it. Glare, motion, a resolution too low for the print. It is **unsupported document** when something was located whose type is not in the catalogue. Nothing about retrying helps; the document is not one the engine reads. It is **rejected** when recognition ran to the end and the engine did not call the result a success. A retry after a free outcome is a new call, and it is free again unless it produces the evidence. Nothing accumulates: a document photographed four times badly and once well costs one credit. ## Reserved before, settled after The charge is not applied at the end of a successful scan. It is held at the beginning and resolved at the end. A credit is **reserved** before the engine is called. The hold is a real movement in the ledger, so the balance reflects it while the scan is running. Two requests cannot both spend the last credit. When the result comes back, the hold is **committed** if the predicate is met and **released** if it is not. A commit that settles a hold moves no further money; the credit left the balance when the hold was placed. If the engine fails or times out, the hold is released and the balance is untouched. The caller gets an error, and the failure costs nothing. The order matters for the case that would otherwise be worst: an account with no credits. Because the reservation comes first, such a call is refused with 402 `insufficient_credits` **before** the image is sent for recognition. Nobody is charged for a call that could not run, and no recognition capacity is spent on a caller who could not have been served. The same order is why a balance cannot go negative. The floor is not a check in the application that could be forgotten on some path. A database constraint refuses the entry that would take a balance below zero, so the overdraft has no code path at all. ## What is never charged **Anything refused before the engine.** A bad key, a body over the ceiling, a rate limit, an unsupported media type, an exhausted allowance. All of them are decided in front of recognition, and none of them costs a credit. **Anything a sandbox key does.** Neither kind of sandbox key draws on a balance. The public one runs real recognition free within its allowance. An account's own sandbox key is answered from a fixed specimen and never reaches the Engine. **A replayed request.** A retry carrying an `Idempotency-Key` that has already been used returns the first result and charges nothing the second time. That is the whole reason the header exists. **A failure on our side.** An engine that could not be reached, a store that was away, an internal error. The hold is released, the balance is whole, and the error body says which it was. ## Why the price is one number A per-document price with no tiers is unusual enough to be worth explaining. Volume pricing exists where volume changes a supplier's costs. Recognition does not work that way here: the hundred-thousandth document costs what the first one did, because each is one pass over one image. A published single number also does something a negotiated one cannot. It can be compared, planned against and put in a spreadsheet before anybody talks to us. A developer costing an integration at two in the morning gets an exact answer. The corollary is that there is nothing to ask for. No rate is available that is not on this page, and no volume unlocks one. ## Where the free documents come from A caller with no account has 10 free recognitions on the public sandbox key, counted per client. Past them the API answers `registration_required`, which is an invitation rather than a wall. An account arrives with 20 free documents credited to its balance. They are ordinary credits, indistinguishable from bought ones once they are there. After that, a balance is topped up and every billable document draws one credit from it. A credit is a cent and a document is a credit, so the balance is also the number of documents left. That is the only arithmetic a caller has to do. --- # Data retention and privacy The payload of a scan is an identity document, which is the most sensitive thing this service will ever be handed. What is kept of it, and for how long, is therefore a design decision rather than an operational detail. This page is the reasoning behind the one that was taken. The short version. The picture is never written down. The extracted result is kept for as long as the request asked for, and a request may ask for nothing. ## The image is never stored The uploaded image lives in memory for the duration of the request. It is decoded, handed to the recognition engine, and gone when the response is written. Nothing writes it to a disk, an object store or a log. No bucket of customer documents exists, no retention tier over one, and no procedure for deleting one. None of those would have anything to operate on. The reasoning is that an image that exists somewhere is an obligation that exists somewhere. A store of identity documents has to be encrypted, access controlled, audited, backed up, and eventually deleted. Every one of those is a thing that can be got wrong. Not having the store is the only version of that work that cannot fail. A consequence worth stating plainly: a scan cannot be re-run on our side. If you need the same document recognized again, the picture has to be sent again, because we do not have it. ## The crops are not stored either A recognition returns crops: the document, the holder's photograph, the signature. They travel in the response of the call that produced them, and nowhere else. A scan read back later through `GET /v1/scans/{id}` comes back with every image slot null, whatever the original call returned. That is not a permission being withheld; the bytes do not exist to return. The same is true of the engine's own assessment of the picture. A stored scan reads `quality.overall: not_checked` rather than `pass`, because the picture it would have been a verdict about was never kept. Whatever your integration needs from a crop, it needs at the moment of the response. ## What a retained scan actually is What is written down, when a retention window is asked for, is the **reading**. That is the extracted values, the document and holder blocks, the zone's lines and verdict, the timing, and the outcome flags. Beside the row sits one picture: a thumbnail at most 96 px on its longest side and at most 16 KiB encoded. It exists so that the operations log in the dashboard shows which document a row is about instead of an opaque identifier. It is small enough to identify a row and not a person, and it is readable in the dashboard rather than through the API. The re-encode drops every metadata block the source carried: no camera, no timestamp, no location. A row is readable by the account that made it, through a live key. A sandbox key of the same account reads nothing. An account's history is a store of identity data, and the key handed to a contractor is not the key that opens it. ## Zero retention writes nothing A request carrying `retain_hours: 0` produces no row at all. That is a stronger statement than a row that expires immediately. Nothing is left to expire, nothing for a sweep to find, nothing in a backup taken that minute, and nothing to appear in an export. The result was computed, returned and forgotten. It costs one thing, and the cost is worth knowing before it surprises anybody. An `Idempotency-Key` sent with a zero-retention scan has no stored result to replay. A retry under that key is refused with `idempotency_replay_unavailable` rather than answered twice. The refusal is the correct one: a replay that invented an answer would be worse. ## The window runs from the scan A retention window is counted from the moment the scan was made, not from the last time it was read. Reading a result does not extend it, and there is no way to extend it. An account chooses a default window, and a request may name its own. An explicit `retain_hours` always wins, including zero. The account setting decides what happens when the request says nothing, which is every upload from the dashboard. **Shortening the window applies to what is already stored.** That is the part people expect least and want most. Choosing a shorter setting re-dates the existing rows in the same step, each measured from its own creation time. A scan made yesterday under a year-long window expires a day after it was made, once the window becomes a day. It does not get a fresh day. Lengthening never resurrects anything. A row already gone stays gone, and a row written under a shorter window keeps the shorter one. The longer window governs what comes after it. ## What deletion removes Expiry is not a filter over reads. A background sweep physically deletes the rows whose window has passed, in bounded batches so the deletion never takes a long lock over a backlog. What goes with the row is everything that was kept of the scan: the extracted result and the thumbnail beside it. A thumbnail held outside the database follows through a tombstone. It is queued in the same statement that deletes the row, so the object does not outlive it. A shortened window is the case that makes the tombstone necessary. A row the change has pushed past its window is deleted now, and its picture is enqueued for deletion now. Neither waits out the tier the object was written under. ## Why the default is long and the override is short The account default is the longest window the service offers, and a request can name a shorter one. That combination looks backwards until the two audiences are separated. The default serves the dashboard. Somebody who uploads a document in a browser opened the dashboard to see their history. A default that threw it away after a day would make that page empty. The override serves the integration. A program knows what it is doing with each result, and it is the only party that can say whether this one needs keeping. An explicit value wins for that reason: the caller who named it knows more than the setting does. The pattern that follows is worth naming. A long account window with `retain_hours: 0` on the traffic that does not need it keeps the dashboard useful while storing almost nothing. ## What never leaves the process A recognition service has two ways of leaking what it was given: its logs and its failure reports. Both are constrained rather than trusted to discipline. Bodies, payloads, files, images, secrets, tokens and personal data do not reach either. A failure reported for diagnosis carries the shape of what went wrong. Where, in which release, under which request identifier, and not the thing it went wrong on. The subject of a report is an internal identifier, and the scrubbing is a hook rather than a rule each caller has to remember. The free sandbox has one more store, and it is the smallest one in the service. To stop one image being replayed endlessly on a free key, a digest of the image bytes is held briefly. A digest is not the image and cannot be turned back into one. It lives in an in-memory store with a short expiry, and no durable row is written for it. ## What this is not This page describes what the service does with your data. It is not a legal document, and it does not say which obligations apply to you. The design above is meant to make those obligations smaller. The fewer copies of an identity document exist, the fewer places have to be described, secured and emptied. Where your own process sits is yours to decide. [Control history retention](/guides/control-history-retention) shows how to configure the part that is ours. --- # API keys and sessions Two different things call this service. A program sends an API key; a person signs in to the dashboard and gets a session. They are not two spellings of one mechanism, and the boundary between them is deliberate. Within the first of the two there are three kinds of key, and the differences between those are what most of this page is about. ## Why keys and sessions are separate An API key identifies a **program**. It is a long-lived bearer credential, configured once into a deployment. It may recognize documents and read that account's own results. A session identifies a **person at a browser**. It is short-lived and bound to a sign-in. The dashboard uses it to manage the account: issuing and revoking keys, choosing a retention window, taking a top-up quote. The two sets of powers do not overlap, and that is the point. A key that could issue keys would turn one leaked credential into an account nobody can take back. The attacker mints a second key before the first is revoked. Keeping key management behind a sign-in means that recovering from a leaked key is always possible, using something the leak did not contain. It works in the other direction too. A browser session is exposed to a class of risk a server-side key is not. It is therefore not the credential that spends money on recognition at scale. The account-management routes are therefore outside the versioned API surface and are not part of the published contract. They are the dashboard's, and the dashboard is their client. ## What a key is made of A key looks like `sk_live_9c41ba2e…`: a kind marker, then a body of random characters carrying 128 bits of entropy. The kind is **in the prefix** rather than looked up anywhere. A credential beginning `sk_sandbox_` is never charged, and that is decided by reading those characters. It cannot drift from a row in a table, because there is no row in a table saying otherwise. Only a hash of the key is stored. The service can tell whether a presented key is one it issued, and it cannot produce the key itself. A key is therefore shown in full exactly once, at creation. Afterwards it is known by its prefix: the marker plus the first 8 characters of the body. That is enough to recognize a key in a list and not enough to use it. A key can be given an end of validity, and a key past it is refused like an unknown one. A revoked key is refused the same way. A caller cannot tell an unknown key from a withdrawn one, which is the answer that reveals least. ## The public sandbox key `sk_sandbox_public` is printed in this documentation, on the home page and in every runnable example. Publishing a credential sounds like a mistake, and here it is the design. It belongs to **no account**. It has no balance to spend, no history to read and no settings to change. `GET /v1/usage` answers it with a null balance and zero counters rather than somebody's figures, and it reads no stored scan. What it can do is recognize. A document sent on the public key reaches the real engine and comes back really read. The alternative is a fixed answer for every image, which makes a product look broken to the person trying it on their own passport. Three walls bound that, and all three are keyed to the client rather than to the key. Everybody shares the credential, so counting per key would count the world as one caller. The rate bucket is per address. The lifetime free allowance is per client. And the same image resubmitted past a small threshold is refused, so one picture cannot be replayed to consume the demo. Those walls fail **closed**. When the store holding the counters cannot be reached, the anonymous path answers a retryable 503 rather than letting unmetered free recognition through. A registered key is unaffected; it is metered by its own balance. Because the key is published, the recognitions it produces are never stored. No account exists for them to belong to. ## An account's own sandbox key The second kind is a sandbox key an account issues for itself, and it does something different from the public one. It is answered from a **fixed synthetic specimen** and never reaches the Engine at all. Every call returns the same invented holder, the same document, the same zone. Nothing about the image that was sent affects the answer beyond being validated and counted. That is what makes it the integration credential. A client built against it can assert on exact values in a test, because the values do not move. It spends no recognition capacity and is never charged. No real identity document is read on the credential an account is most likely to hand around. It reads no stored scan either. `GET /v1/scans/{id}` answers 404 for every sandbox key, including the account's own results. An account's history is a store of identity data, and this is the low-trust key. Its usage figures, on the other hand, are the account's real ones. Usage is an account-level question, and the answer does not change with the key that asked it. ## The live key The third kind is the one that does the work. It reaches the engine and draws credits. Its results are stored under the account's retention window, and it is the only kind that can read them back. Everything a live key can do is a reason to treat it as a secret. It spends money, and it opens a history of identity documents. An account may hold several, which is what makes it possible to replace one without a gap. [Rotate API keys](/guides/rotate-api-keys) is that procedure. ## Why the three exist rather than one A single key with a test-mode flag is the obvious alternative. It fails at the first question a developer asks: how do I try this before I sign up. The three kinds answer three different moments. The public key is for the minute before an account exists, where the only thing that matters is that a real document reads correctly. The account's sandbox key is for the weeks of building against a stable answer. The live key is for production. Each of them is bounded by what its moment justifies. The public key recognizes for real and is walled by client. The sandbox key is free and reads nothing. The live key does everything and is the one worth protecting. A caller who knows which of the three is in a deployment can predict the whole of its behavior. That is what a credential ought to make possible. --- # Crypto deposits Credits are bought with cryptocurrency, and crediting money read off a public chain has no undo. A credit posted from a wrong price is money given away; a payment silently not credited is money a customer lost. Every rule below exists because one of those two failures is worth preventing. The standard the design is held to is that a money error is unacceptable. The price paid for it is latency: a deposit sometimes waits longer than it strictly had to. ## The address is permanent, and nothing here can spend from it Each account has one deposit address per chain, and it does not change between top-ups. A wallet with it saved can keep sending to it, and a payment that arrives with no quote behind it is still credited. The address is derived rather than assigned. A hierarchical deterministic wallet produces a whole tree of addresses from one key, and this service holds only the **public** half of that key. That is the property worth stating carefully. Deriving a customer's address from a public key needs no private key, because the derivation steps involved are the unhardened ones. The process that watches the chain and credits balances therefore has no way to move any of the money it is watching. A compromise of this service does not reach the funds; it reaches a list of addresses that are already public on a chain. The spending key lives outside the service entirely, and nothing in the request path has ever seen it. ## A quote is a price, not a reservation A top-up starts with a quote. It names an asset, a number of credits, and the amount of coin that buys them at the price it was computed at. The price is **locked**, and the lock has three bounds. Each one closes a different way of getting value out of the product for free, and none of them is about the honest payer. **Time.** The lock holds for the quote's 2 hour live window plus a 6 hour grace past it. Without a bound, a payer waits and pays only if the asset has fallen, funding the same credits with less money. A quote whose asset rose is never paid at all. Six hours is far longer than any of these chains needs to confirm a payment. It is far too short to be worth holding an option over. **Amount.** The lock funds the quoted amount plus 2 %, counted across every transfer that quote ever attracts. The tolerance exists for the wallet that takes its network fee out of the amount. It exists for the payer who rounds the figure up while typing it. An unbounded tolerance would be a free option on the market. Send one quote's worth while the price holds, and a hundred times that once it has fallen. **Order.** A transfer whose block is dated before the quote gets no lock at all. The other direction is a look-back option. Pay first, watch the market, then mint a quote at the old price once it has moved your way. A small per-chain tolerance is allowed against it. A block's timestamp is the block producer's opinion and a quote's is a database's, and the two clocks were never synchronized. What falls outside any of the three is still credited, at the corroborated current price. The deposit is flagged for a person to look at. A late payment loses the old price and not the money. ## A price needs corroboration A price is never one source's opinion. With three or more answers, the median decides and the outliers are discarded. With exactly two, the median is meaningless. Both sit the same distance from their own midpoint, so any tolerance keeps both or neither. The pair is used only if the two agree within 2 %. With no agreement there is no price, and a quote that cannot be priced is refused rather than guessed. A deposit that arrives while no price is available waits for the next pass. Half of it is never credited against a row already marked paid. Stablecoins are priced like everything else rather than assumed to be worth a dollar. A stablecoin is a claim, not an identity, and the one moment its price is worth reading is the moment it is not holding. A reading outside a narrow band around a dollar is refused rather than clamped to the edge of the band. Pricing a depegged token at the floor would pay the difference on every top-up for as long as the depeg ran. ## Depth is proportional to what is at stake A transfer is not credited the moment it appears on a chain. It has to be buried under enough later blocks that undoing it would cost more than the credit it would claw back. Each chain has two depths, and the deeper one is required once the account's recent shallow exposure on that chain passes a threshold. A small deposit is credited quickly; a large one waits. That is a deliberate trade of latency for safety, sized by value rather than applied flatly. A flat depth is either too slow for the ordinary payment or too shallow for the large one. Settlements for one account are serialised. Without that, two transfers of one account settle in parallel, each reading the exposure before the other has written its credit. A payment split in two then walks past the depth it should have had to reach. ## Arriving amounts, and what happens to each Three cases cover every amount that is not exactly the quoted one. Less than quoted is credited for what arrived, at the locked price. The difference sent later to the same address is credited the same way. Up to the tolerance more converts wholly at the locked price. More than that splits. The quoted portion converts at the locked price and the excess at the current one, in the same ledger entry and the same commit. One deposit is one movement, whatever it was funded by. Every conversion rounds **down** to a whole credit. A fraction of a cent is not credited. A transfer worth less than one credit after conversion is too small to credit at all. The arithmetic produces nothing, and a credit of zero would be a row saying money arrived and nothing happened. A single transfer worth more than the ledger can hold is recorded as refused with a flag rather than thrown away. A throw would roll back the row that says money arrived, which is the one record that must survive whatever else does not. ## What the watcher does not promise Two limits are worth knowing because no amount of care removes them. **Value moved by a contract call is invisible to these scans.** A native-coin transfer made by a contract during a call does not appear the way an ordinary transfer does. Crediting it is a manual step, and tokens are not affected. **An endpoint that lies consistently is eventually believed.** The watcher refuses a chain reading that outruns wall-clock time or falls too far behind what it last saw. It re-anchors when a run of consistent readings agrees. That bounds a mistaken or transient answer. It is deliberately not a defense against a sustained hostile one. A check that can never change its mind wedges the watch on a chain whose anchor is wrong. ## Where a deposit goes when automation stops A quote's live window is 2 hours, and the addresses keep being re-read by a daily pass for 30 days after it. Past those 30 days the automation stops, and that is all that stops. The address stays valid, the funds are not lost, and a deposit that arrives afterwards is a support conversation rather than a loss. Knowing that boundary is the point of publishing it. --- # Reliability A service that recognizes documents synchronously has to answer a hard question honestly: what happens when the part that does the recognizing is not there. This page is about what is measured, what the measurement is allowed to claim, and what a failure costs a caller. The organizing idea is that a number nobody can check is worth less than a smaller number that came from somewhere. ## Availability is measured by doing the thing Uptime here is not a port check, and it is not the share of requests that happened to succeed while traffic existed. A synthetic recognition runs once a minute. A drawn specimen is posted to the service's own scan endpoint over loopback. It travels the whole path a customer's call travels: the routing, the maintenance gate, the authentication guard, the rate limiter and the engine. Only a scan proves a scan works. Everything short of it can be true while recognition is broken. A process that is alive, a port that accepts a connection, a health endpoint that answers 200. Each has been somebody's green dashboard during an outage. Two consequences follow from doing it this way. Availability is measured on a quiet night as well as a busy afternoon, because the measurement supplies its own traffic. The figure is also honest about a partial failure. A specimen that does not come back recognized is a failure, whatever the process list says. The synthetic document is not a real one, and under a real engine it is not recognized. The run proves the path is alive; it does not prove that a customer's document reads correctly. ## What a headline is allowed to say Five components are published, and four of them are core: the recognition API, document recognition itself, the dashboard and website, and this documentation. Crypto top-ups is the fifth and is not core. A core component is one whose failure means the thing a customer bought does not work. When any of the four is down, the banner says so in the plainest sentence available. A non-core component's contribution to that banner is **capped** at degraded performance, and the sentence names it. Its own row still tells the whole truth: its state, its uptime, its ninety days. Nothing about the cap can make a row look better than it was. The reason is not politeness. A customer whose deposits are stuck still recognizes documents with the credit they already hold. A page that shouts "major outage" over a path most readers never take teaches them to discount the banner. That banner is the one thing on the page that has to be believed the single time it matters. The same reasoning hides the non-core row entirely while it is healthy. A green row for a path a visitor never takes spends their attention on something that was never going to affect them. It appears the moment it is worth the space. ## Why maintenance leaves the denominator Planned work is announced before it starts and is not counted as downtime. That much is ordinary. What is less ordinary is that it is not counted as uptime either. A day given over to planned work reports no value at all, rather than a hundred per cent. Counting a maintenance window as uptime would let a service improve its published figure by taking itself down. That is an incentive nobody should build into a number they publish. Counting it as downtime would punish the announced work that keeps the service healthy. Leaving it out of both halves of the fraction is the only arithmetic that neither rewards nor punishes it. ## Why one figure is a percentile and the other is not Two latency figures are published, and they answer different questions. Calling both of them percentiles would make one of them a lie. The recognition figure is a true percentile. The individual durations of the synthetic runs are kept, and the p50 and p95 are taken over them. The number of samples is published beside them. The other figure is the median of each minute's own p95, and it answers what a slow minute looks like. A percentile taken over a whole day cannot answer that. A day's p95 is dominated by whichever minute was worst, so one bad minute makes the day look uniformly slow. It is labelled as a typical slow minute for exactly that reason, and it is never called a percentile anywhere. ## Why the per-process status is a flat string Each API process publishes a small snapshot of itself. It carries the recent p50, p95 and p99, how long the process has been up, and a `status` of `ok` or `degraded`. That status is a **flat string**, not an object, and the flatness is the useful part. The thing reading it is a monitoring check, and a monitoring check wants one value to alert on. An object forces every consumer to re-derive the verdict from the parts, which means every consumer derives it slightly differently and the alerts disagree. What the string means is fixed and narrow. The process is `degraded` when its recent p95 is above the latency target published beside the figure. The target travels in the same document, so a reader never has to know a number that is not in front of them. This snapshot is one process, over a rolling window of recent requests, and it is cleared by a restart. It is a good way to look at one instance and a bad way to judge the service over time. The status page is for the second question. ## What a failure costs you Nothing, in the ordinary case, and the mechanism is the reservation. A credit is held before the engine is called and resolved after. When the engine cannot be reached or does not answer in time, the hold is released and the balance is untouched. The caller gets an error, and the failure is free. Failures in front of the engine are free for a simpler reason: the engine never ran. A refused key, a body over the ceiling, an exhausted allowance, a rate limit — each of them is decided before any recognition capacity is spent. The gates that protect the free sandbox fail **closed**. When the store holding their counters is unreachable, the anonymous path answers a retryable 503 rather than letting unmetered free recognition through. Refusing a caller who still had attempts left is recoverable; handing out uncounted free recognition is not. ## How to tell a retry is worth making Three separate 503 codes exist rather than one, and the separation is what makes the answer readable. One says recognition itself is unreachable. One says a store the request needed is away. One says the service has been deliberately closed for planned work. They arrive with `Retry-After`, and they are the codes to requeue on rather than fail on. An error the caller caused is a different class, and it does not improve with time. A key that is refused stays refused, and a body over the ceiling is over it on the second attempt too. A validation failure names the field that has to change. The two classes are separated in the error catalogue rather than left to judgement. [Handle errors](/guides/handle-errors) sorts all 21 codes into retry, fix, or stop. ## What is promised, and what is only measured The numbers on the status page are measurements. They say what the service did over the last thirty and ninety days, day by day. The evidence is the same document the page itself draws from. Nothing on this page is a contractual guarantee, and describing a measurement as a guarantee is the one thing a reliability page must not do. What is offered instead is a measurement taken over the real path. It is published in full, including the bad days, and computed by arithmetic that is written down. --- # API reference Document Recognition API — every endpoint, parameter and response, generated from `openapi.yaml`, which is the contract this service is built from and validated against. The base URL is `https://api.doc.cheap`, the one host the contract's `servers` block names. Every endpoint takes and returns `application/json`, and every call is authenticated with `Authorization: Bearer ` — `sk_live_…` for an account, or the public `sk_sandbox_public` for trying the API without registering. Recognition is synchronous. `POST /v1/scans` runs the engine while the request is open and returns the extracted data in the same response, so there is no job to poll and no webhook to register. There is one response shape, the same for every scan and every caller: eight groups under a `meta.schema_version` of `1.0`, with nothing to negotiate and no header or option that selects a different body. Every failure carries one body — a stable `code`, a human `message`, a `docs_url` pointing at that code's own page, a `request_id` and an `event_id` that is non-null only for a failure the service recorded against itself. The interactive reference below renders the whole contract and runs calls from the page. Each endpoint also has a plain page that needs no JavaScript: - [Create a scan](/reference/endpoints/create-a-scan) — `POST /v1/scans` - [Retrieve a scan](/reference/endpoints/retrieve-a-scan) — `GET /v1/scans/{id}` - [Get usage](/reference/endpoints/get-usage) — `GET /v1/usage` The contract itself is at [`/openapi.yaml`](/openapi.yaml), and the pages that state it in prose are the [reference section](/reference). --- # POST /v1/scans Recognize a document Runs recognition synchronously on one image and returns the extracted data. The balance is charged only when the scan is billable: the document type was determined and at least one of an MRZ with valid checksums, five or more visual-zone fields, or a decoded barcode was read. Sandbox keys are never charged: `sk_sandbox_public` recognizes the image it is sent, free, until its allowance runs out, while a registered account's own `sk_sandbox_...` key is answered from a fixed synthetic specimen. The result is written down only when the retention window resolved for the request is above zero: `options.retain_hours` when the request names one, otherwise the account's own history-retention setting. Send `retain_hours: 0` to have nothing stored at all, so there is nothing to read back later. The call is synchronous: the recognition runs while the request is open and the extracted data comes back in the same response. There is no job id and no callback. The image travels inside the JSON body as base64, which is about a third larger than the file on disk. The body ceiling is 36 MiB. A failure to recognize is not an error. `status` carries `no_document_found`, `unreadable`, `unsupported_document` or `rejected` under a `200`; the codes below are the cases where no result was produced at all. ## Request | | | |---|---| | Method | `POST` | | Path | `/v1/scans` | | Authentication | `Authorization: Bearer ` | ### Headers | Header | Type | Required | Description | |---|---|---|---| | `idempotency-key` | string | no | Caller-chosen key that makes a retried request return the first result instead of running and charging again. | ### Body fields | Field | Type | Required | Default | Description | |---|---|---|---|---| | `image` | string | yes | — | The document image, base64-encoded (JPEG or PNG). | | `options` | ScanOptions | no | `{}` | | | `reference` | string \| null | no | `null` | Caller's own correlation string, echoed back in the response. | ### Example request body ```json { "image": "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==", "options": { "mode": "full", "expect_country": null, "date_format": "iso", "return_portrait": true, "retain_hours": 0 }, "reference": "order-1042" } ``` ## Responses ### 200 The scan result. `status` says how far recognition got. Body: `Scan`. | Field | Type | Description | |---|---|---| | `meta` | ScanMeta | | | `document` | ScanDocument \| null | | | `holder` | ScanHolder \| null | | | `fields` | ScanField[] | Every field the engine extracted off the printed document, re-keyed to our vocabulary — the open set. Always present; empty when nothing was extracted. A field read in more than one language appears once per language, so `name` repeats and only `id` is unique. | | `mrz` | ScanMrz | | | `images` | ScanImages | Image crops, returned in the recognition response only. Each is scaled down by height, proportionally and never upwards, to at most 250 px for `document_crop` and 100 px for every other crop, then re-encoded with every metadata block dropped. | | `quality` | ScanQuality | | | `authenticity` | ScanAuthenticity | | ```json { "meta": { "schema_version": "1.0", "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", "status": "recognized", "billed": true, "confidence": "high", "timing": { "upload_ms": 198, "processing_ms": 812, "total_ms": 1024 }, "created_at": "2026-09-17T10:15:00Z", "reference": "order-1042" }, "document": { "kind": "passport", "country": "GRC", "country_name": "Greece", "issuing_state": "GRC", "type_name": "Greece - Passport", "type_confidence": "high", "is_expired": false, "days_remaining": 2001 }, "holder": { "given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA", "birth_date": "1994-03-08", "sex": "F", "nationality": "GRC" }, "fields": [ { "id": "surname@0", "name": "surname", "label": "Surname", "category": "identity", "value": "PARADEIGMA", "language": null, "confidence": "high" }, { "id": "surname@1032", "name": "surname", "label": "Surname", "category": "identity", "value": "ΠΑΡΑΔΕΙΓΜΑ", "language": "Greek", "confidence": "high" }, { "id": "document_number@0", "name": "document_number", "label": "Document number", "category": "document", "value": "AM7304518", "language": null, "confidence": "high" }, { "id": "days_to_expire@0", "name": "days_to_expire", "label": "Days to expire", "category": "dates", "value": "2001", "language": null, "confidence": "high" }, { "id": "mrz@0", "name": "mrz", "label": "MRZ", "category": "document", "value": "P` header, or an unknown key. Body: `ErrorResponse`. ```json { "error": { "code": "unauthorized", "message": "Send your API key as `Authorization: Bearer `.", "docs_url": "https://doc.cheap/docs/errors/unauthorized", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 402 The account has no credits left; the scan was not run. Body: `ErrorResponse`. ```json { "error": { "code": "insufficient_credits", "message": "The balance is 0 credits; top up to continue.", "docs_url": "https://doc.cheap/docs/errors/insufficient_credits", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 403 The public sandbox key has used up its free recognitions; register for an API key to continue. Body: `ErrorResponse`. ```json { "error": { "code": "registration_required", "message": "The free trial without an account is used up; register for your own API key to keep scanning.", "docs_url": "https://doc.cheap/docs/errors/registration_required", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 409 The `Idempotency-Key` cannot be honoured. `idempotency_conflict`: it was already used with a different request body. `idempotency_in_progress`: the first request under this key is still running, so retry in a moment to receive its result. `idempotency_replay_unavailable`: that request finished, but its result was not stored (the request asked for `retain_hours: 0`) or its retention window has passed, so it cannot be replayed — send a new key. Body: `ErrorResponse`. ```json { "error": { "code": "idempotency_conflict", "message": "Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b was already used with a different body.", "docs_url": "https://doc.cheap/docs/errors/idempotency_conflict", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 413 The request body is larger than the endpoint accepts. Send a smaller or more compressed image. Body: `ErrorResponse`. ```json { "error": { "code": "payload_too_large", "message": "The request body is larger than this endpoint accepts.", "docs_url": "https://doc.cheap/docs/errors/payload_too_large", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 415 The request body must be sent as `Content-Type: application/json`. Body: `ErrorResponse`. ```json { "error": { "code": "unsupported_media_type", "message": "Send the request body as `Content-Type: application/json`.", "docs_url": "https://doc.cheap/docs/errors/unsupported_media_type", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 422 The body is well-formed JSON but does not satisfy the request schema. Body: `ErrorResponse`. ```json { "error": { "code": "validation_failed", "message": "/options/mode: Invalid option: expected one of \"full\"", "docs_url": "https://doc.cheap/docs/errors/validation_failed", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 429 Rate limit exceeded for this key or IP. Body: `ErrorResponse`. ```json { "error": { "code": "rate_limited", "message": "Sandbox limit of 10 requests per hour per IP reached.", "docs_url": "https://doc.cheap/docs/errors/rate_limited", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 500 Unexpected failure on the server; the scan was not charged. Body: `ErrorResponse`. ```json { "error": { "code": "internal_error", "message": "Unexpected error.", "docs_url": "https://doc.cheap/docs/errors/internal_error", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 503 Temporarily unable to serve the request; nothing was charged and it may be retried, after the `Retry-After` seconds where that header is present. `engine_unavailable`: the recognition engine did not answer within its timeout. `service_unavailable`: a store this service depends on is unreachable, so the request cannot be served now. Body: `ErrorResponse`. ```json { "error": { "code": "engine_unavailable", "message": "The recognition engine did not answer in time; nothing was charged. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/engine_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ## Error codes reachable here | Code | When | Page | |---|---|---| | `engine_unavailable` | The recognition engine did not answer within its timeout. Nothing was charged. | [engine_unavailable](/errors/engine_unavailable) | | `idempotency_conflict` | The `Idempotency-Key` was used before with a different request body. | [idempotency_conflict](/errors/idempotency_conflict) | | `idempotency_in_progress` | The first request under this `Idempotency-Key` is still running. | [idempotency_in_progress](/errors/idempotency_in_progress) | | `idempotency_replay_unavailable` | The key's first result was not retained, so there is nothing left to replay. | [idempotency_replay_unavailable](/errors/idempotency_replay_unavailable) | | `insufficient_credits` | The balance cannot cover one recognition. Checked before the engine. | [insufficient_credits](/errors/insufficient_credits) | | `internal_error` | The service failed in a way it does not model. Nothing was charged. | [internal_error](/errors/internal_error) | | `invalid_request` | The body is not readable as JSON, or the request was refused before it. | [invalid_request](/errors/invalid_request) | | `payload_too_large` | The body is over 36 MiB. Refused before it is read into memory. | [payload_too_large](/errors/payload_too_large) | | `rate_limited` | The caller is over the rate limit for its key kind. | [rate_limited](/errors/rate_limited) | | `registration_required` | The public sandbox key's lifetime free allowance is used up. | [registration_required](/errors/registration_required) | | `service_unavailable` | A dependency the service needs is unreachable. Nothing was charged. | [service_unavailable](/errors/service_unavailable) | | `unauthorized` | The `Authorization` header is missing, malformed or names an unknown key. | [unauthorized](/errors/unauthorized) | | `unsupported_media_type` | The body was sent as something other than `application/json`. | [unsupported_media_type](/errors/unsupported_media_type) | | `validation_failed` | A field or option failed the schema — a wrong type, an out-of-range value, an unknown option key. | [validation_failed](/errors/validation_failed) | Every error body carries the same shape; the full list is on [Errors](/reference/errors). --- # GET /v1/scans/{id} Fetch a previous scan Returns a scan made earlier with a live key and kept under a non-zero retention window, while that window lasts; anything else is `404`. The image crops are never stored, so every image slot comes back null, and neither is the engine's own output, so `quality` reads `not_checked`. Reads back a result the service still holds. It never re-runs recognition and is never billed. A scan is readable only while its retention window is open: the `retain_hours` the creating request asked for, or the account's own history-retention setting when it asked for none. A scan created with `retain_hours: 0` was never written down. Image crops are not stored. A result read back here carries `images: null` whatever the creating response returned. ## Request | | | |---|---| | Method | `GET` | | Path | `/v1/scans/{id}` | | Authentication | `Authorization: Bearer ` | ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | Scan identifier: a UUID version 7 (RFC 9562), canonical lower-case `8-4-4-4-12`. Its leading 48 bits are the millisecond the scan was made, so ids sort in the order the scans happened — but treat the value as opaque: nothing else about it is part of the contract. | ## Responses ### 200 The scan as it was returned when it was created. Body: `Scan`. | Field | Type | Description | |---|---|---| | `meta` | ScanMeta | | | `document` | ScanDocument \| null | | | `holder` | ScanHolder \| null | | | `fields` | ScanField[] | Every field the engine extracted off the printed document, re-keyed to our vocabulary — the open set. Always present; empty when nothing was extracted. A field read in more than one language appears once per language, so `name` repeats and only `id` is unique. | | `mrz` | ScanMrz | | | `images` | ScanImages | Image crops, returned in the recognition response only. Each is scaled down by height, proportionally and never upwards, to at most 250 px for `document_crop` and 100 px for every other crop, then re-encoded with every metadata block dropped. | | `quality` | ScanQuality | | | `authenticity` | ScanAuthenticity | | ```json { "meta": { "schema_version": "1.0", "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3", "status": "recognized", "billed": true, "confidence": "high", "timing": { "upload_ms": 198, "processing_ms": 812, "total_ms": 1024 }, "created_at": "2026-09-17T10:15:00Z", "reference": "order-1042" }, "document": { "kind": "passport", "country": "GRC", "country_name": "Greece", "issuing_state": "GRC", "type_name": "Greece - Passport", "type_confidence": "high", "is_expired": false, "days_remaining": 2001 }, "holder": { "given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA", "birth_date": "1994-03-08", "sex": "F", "nationality": "GRC" }, "fields": [ { "id": "surname@0", "name": "surname", "label": "Surname", "category": "identity", "value": "PARADEIGMA", "language": null, "confidence": "high" }, { "id": "surname@1032", "name": "surname", "label": "Surname", "category": "identity", "value": "ΠΑΡΑΔΕΙΓΜΑ", "language": "Greek", "confidence": "high" }, { "id": "document_number@0", "name": "document_number", "label": "Document number", "category": "document", "value": "AM7304518", "language": null, "confidence": "high" }, { "id": "days_to_expire@0", "name": "days_to_expire", "label": "Days to expire", "category": "dates", "value": "2001", "language": null, "confidence": "high" }, { "id": "mrz@0", "name": "mrz", "label": "MRZ", "category": "document", "value": "P` header, or an unknown key. Body: `ErrorResponse`. ```json { "error": { "code": "unauthorized", "message": "Send your API key as `Authorization: Bearer `.", "docs_url": "https://doc.cheap/docs/errors/unauthorized", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 404 No scan with this id exists, or its retention window has passed. Body: `ErrorResponse`. ```json { "error": { "code": "not_found", "message": "No scan with id 01a0af18-cd8d-7a61-9f2d-4c7b8e105da3 exists or it has expired.", "docs_url": "https://doc.cheap/docs/errors/not_found", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 429 Rate limit exceeded for this key or IP. Body: `ErrorResponse`. ```json { "error": { "code": "rate_limited", "message": "Sandbox limit of 10 requests per hour per IP reached.", "docs_url": "https://doc.cheap/docs/errors/rate_limited", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 500 Unexpected failure on the server; the scan was not charged. Body: `ErrorResponse`. ```json { "error": { "code": "internal_error", "message": "Unexpected error.", "docs_url": "https://doc.cheap/docs/errors/internal_error", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 503 Temporarily unable to serve the request: a store this service depends on is unreachable. Nothing was charged and it may be retried, after the `Retry-After` seconds where that header is present. Body: `ErrorResponse`. ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unable to handle this request; nothing was charged. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/service_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ## Error codes reachable here | Code | When | Page | |---|---|---| | `internal_error` | The service failed in a way it does not model. | [internal_error](/errors/internal_error) | | `not_found` | No scan with that id was ever stored — it was created with `retain_hours: 0`, or it belongs to another account — or it was stored and its retention window has since passed. The two cases are not distinguished. | [not_found](/errors/not_found) | | `rate_limited` | The caller is over the rate limit for its key kind. | [rate_limited](/errors/rate_limited) | | `service_unavailable` | A store this endpoint reads from is unreachable, so the answer could not be produced. Nothing was charged; retry after the `Retry-After` seconds. | [service_unavailable](/errors/service_unavailable) | | `unauthorized` | The `Authorization` header is missing, malformed or names an unknown key. | [unauthorized](/errors/unauthorized) | Every error body carries the same shape; the full list is on [Errors](/reference/errors). --- # GET /v1/usage Balance and usage for the current period The credits available to the key's account and the scans made in the current usage period, counted by status. Every settled scan is counted, whether or not its result was stored, so the figures do not decay as retention windows pass. A key with no account behind it (the public sandbox key) reports a null balance and zero counters. Reports the balance in credits and the counters for the current period. One credit is one US cent, and one recognized document draws one, so the balance is also the number of documents left. A sandbox key reads its own account's real figures. Its own scans are never charged, so they move the counters and not the balance. ## Request | | | |---|---| | Method | `GET` | | Path | `/v1/usage` | | Authentication | `Authorization: Bearer ` | ## Responses ### 200 Balance and counters. Body: `Usage`. | Field | Type | Description | |---|---|---| | `balance_credits` | integer \| null | Credits currently available to the account; null for keys without a balance. | | `period` | object | Bounds of the current usage period (UTC calendar month). | | `scans` | object | | | `credits_spent` | integer | Credits charged within the period. | ```json { "balance_credits": 11, "period": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z" }, "scans": { "total": 12, "billed": 9, "by_status": { "recognized": 9, "no_document_found": 1, "unreadable": 1, "unsupported_document": 1, "rejected": 0 } }, "credits_spent": 9 } ``` ### 401 Missing or malformed `Authorization: Bearer ` header, or an unknown key. Body: `ErrorResponse`. ```json { "error": { "code": "unauthorized", "message": "Send your API key as `Authorization: Bearer `.", "docs_url": "https://doc.cheap/docs/errors/unauthorized", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 429 Rate limit exceeded for this key or IP. Body: `ErrorResponse`. ```json { "error": { "code": "rate_limited", "message": "Sandbox limit of 10 requests per hour per IP reached.", "docs_url": "https://doc.cheap/docs/errors/rate_limited", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 500 Unexpected failure on the server; the scan was not charged. Body: `ErrorResponse`. ```json { "error": { "code": "internal_error", "message": "Unexpected error.", "docs_url": "https://doc.cheap/docs/errors/internal_error", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ### 503 Temporarily unable to serve the request: a store this service depends on is unreachable. Nothing was charged and it may be retried, after the `Retry-After` seconds where that header is present. Body: `ErrorResponse`. ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unable to handle this request; nothing was charged. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/service_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` ## Error codes reachable here | Code | When | Page | |---|---|---| | `internal_error` | The service failed in a way it does not model. | [internal_error](/errors/internal_error) | | `rate_limited` | The caller is over the rate limit for its key kind. | [rate_limited](/errors/rate_limited) | | `service_unavailable` | A store this endpoint reads from is unreachable, so the answer could not be produced. Nothing was charged; retry after the `Retry-After` seconds. | [service_unavailable](/errors/service_unavailable) | | `unauthorized` | The `Authorization` header is missing, malformed or names an unknown key. | [unauthorized](/errors/unauthorized) | Every error body carries the same shape; the full list is on [Errors](/reference/errors). --- # document_repeated (429) The same image was sent too many times on the free sandbox. ## Cause On the unauthenticated sandbox path the API refuses an image it has already seen past a small threshold. Only a digest of the image bytes is kept, and only for a short lifetime. The guard stores neither the image nor a durable record of it. The check runs before the engine is called, so a refused repeat costs neither recognition time nor one of the free attempts. Registered keys are exempt. They draw on their own metered balance, which bounds them already. ## The message ```text This document has been submitted too many times on the free sandbox; register for an API key or wait before retrying. ``` ## The fix Send a different document, or wait for the window to pass. The response carries a `Retry-After` header with the number of seconds. A loop under test against one image belongs on a registered key, which this guard does not apply to. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`rate_limited`](/errors/rate_limited) — over the rate limit. - [`registration_required`](/errors/registration_required) — the anonymous allowance is spent. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "document_repeated", "message": "This document has been submitted too many times on the free sandbox; register for an API key or wait before retrying.", "docs_url": "https://doc.cheap/docs/errors/document_repeated", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # engine_unavailable (503) The recognition engine did not answer within its timeout. Nothing was charged. ## Cause A scan is billed around the engine call. A credit is reserved before the image is sent, and committed only when a billable result comes back. When the engine does not answer in time the reservation is released. The balance is unchanged and no result was produced. ## The message ```text The recognition engine did not answer in time; nothing was charged. Retry shortly. ``` ## The fix Retry. The condition is transient, so an ordinary retry with backoff is the right response. Wait a second or two, then a few seconds, then surface the failure. Sending an `Idempotency-Key` on the original request makes the retry safe, even if the first call turns out to have landed. ## event_id `event_id` is present on the first occurrence in a window and `null` on the ones that follow. The failure is worth recording; one event per request is not. An afternoon of upstream downtime would cost more events than a month's quota holds. The rate is what is recorded, not each request. ## Related codes - [`service_unavailable`](/errors/service_unavailable) — a dependency is unreachable. - [`internal_error`](/errors/internal_error) — the service failed unexpectedly. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "engine_unavailable", "message": "The recognition engine did not answer in time; nothing was charged. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/engine_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # idempotency_conflict (409) The idempotency key was used before, with a different request. ## Cause A key is matched against a fingerprint of the body it was first used with. Two different bodies under one key is the one case a key cannot arbitrate. Returning the first result would answer a question nobody asked. Running the second would charge twice for a key that promised it would not. ## The message ```text Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b was already used with a different body. ``` ## The fix Send the new body under a new key. A key belongs to one request, not to one retry loop. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`idempotency_in_progress`](/errors/idempotency_in_progress) — the first request is still running. - [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) — nothing left to replay. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "idempotency_conflict", "message": "Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b was already used with a different body.", "docs_url": "https://doc.cheap/docs/errors/idempotency_conflict", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # idempotency_in_progress (409) The first request under this key is still running. ## Cause A key whose first request is still in flight is waited on, not raced. Running the work a second time is the double charge the key exists to prevent. The wait is short. A request that outlasts it is told to retry rather than served twice. ## The message ```text Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b is still being processed; retry in a moment to receive that request's result. ``` ## The fix Retry in a moment with the same key and the same body. Once the first request finishes, the retry receives its result and not a second charge. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`idempotency_conflict`](/errors/idempotency_conflict) — the key was used with another body. - [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) — nothing left to replay. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "idempotency_in_progress", "message": "Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b is still being processed; retry in a moment to receive that request's result.", "docs_url": "https://doc.cheap/docs/errors/idempotency_in_progress", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # idempotency_replay_unavailable (409) The key was used, and its result is no longer there to replay. ## Cause Replaying a key returns the stored result of its first request. A result is written down only when the retention window resolved for that request was above zero. A request that asked for `retain_hours: 0` therefore returns its result once and never stores it. A result that was stored also ages out when its window elapses. Either way the key is known to have been used, so the request is not run again and nothing is left to hand back. ## The message ```text Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b was already used and its result is no longer stored, so it cannot be replayed. Send the request with a new key, or ask for retention with options.retain_hours. ``` ## The fix Send the request under a new key. To have a retry replay a stored result, ask for retention on the original request with `options.retain_hours` above zero and retry inside that window. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`idempotency_conflict`](/errors/idempotency_conflict) — the key was used with another body. - [`not_found`](/errors/not_found) — no scan, or no route. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "idempotency_replay_unavailable", "message": "Idempotency-Key 8f3c2a1b-5d4e-4f60-9a7b-3c2d1e0f9a8b was already used and its result is no longer stored, so it cannot be replayed. Send the request with a new key, or ask for retention with options.retain_hours.", "docs_url": "https://doc.cheap/docs/errors/idempotency_replay_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # impersonation_read_only (403) A session opened by an administrator onto somebody else's account tried to change something. ## Cause Such a session may read the account and nothing else. An action taken through it would be recorded as the account holder's own, and nothing afterwards would show that somebody else pressed the button. ## The message ```text You are viewing this account as an administrator, so it can only be read. Stop viewing as this account to make changes. ``` ## The fix **An API caller does not meet this code.** It is raised only on an administrative surface, which is not part of the public API and is not reachable with an API key. A call of your own that returns it went to the wrong host or the wrong path. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`unauthorized`](/errors/unauthorized) — no usable API key. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "impersonation_read_only", "message": "You are viewing this account as an administrator, so it can only be read. Stop viewing as this account to make changes.", "docs_url": "https://doc.cheap/docs/errors/impersonation_read_only", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # insufficient_credits (402) The balance cannot cover one recognition. ## Cause A credit is reserved before the image reaches the engine. When the balance cannot cover the reservation the call is refused there, so nothing is recognized and nothing is charged. A balance can never go negative: the refusal is the only outcome. ## The message ```text The balance is too low to run this scan; top up to continue. ``` ## The fix Top up, then retry. `GET /v1/usage` reports the balance, and the balance is also the number of documents left — one credit is one US cent, and one recognized document draws one. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`registration_required`](/errors/registration_required) — the anonymous allowance is spent. - [`topup_in_progress`](/errors/topup_in_progress) — a top-up is open and part-paid. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "insufficient_credits", "message": "The balance is too low to run this scan; top up to continue.", "docs_url": "https://doc.cheap/docs/errors/insufficient_credits", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # internal_error (500) The service did something it did not intend. ## Cause The one code that means a bug rather than a refusal. Two paths reach it: a failure nobody modelled, and a response that did not match its own schema. It is the only code in the catalogue that is always recorded as a failure to look at. The status is 500, or another 5xx where the failure carried one. ## The message The wording depends on which of the cases above it was. ```text Unexpected error. The response could not be produced. ``` ## The fix Retry once — most are transient. Quote the `event_id` if the response carries one, and the `request_id` otherwise, when you report it. Nothing is charged for a scan that ended here: the reservation is released before the error leaves. ## event_id `event_id` is **always present** on this code. It is the one code that means a bug, so every occurrence is recorded. Quote the value in a support request and it resolves to that one failure. ## Related codes - [`service_unavailable`](/errors/service_unavailable) — a dependency is unreachable. - [`engine_unavailable`](/errors/engine_unavailable) — the engine did not answer. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "internal_error", "message": "Unexpected error.", "docs_url": "https://doc.cheap/docs/errors/internal_error", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": "e3b0c44298fc1c14" } } ``` --- # invalid_request (400) The request could not be read at all. ## Cause The web framework refused the request before any handler ran, and the refusal was not one of the cases with a code of its own. A body that is not valid JSON is the common one; a method the route does not serve is another. The message is deliberately fixed, and what threw stays in the service's own log. Echoing a framework's internal phrasing back to a caller says something about this service's plumbing, not about their request. ## The message ```text The request could not be read. Check the method, headers and body. ``` ## The fix Check three things, in this order: the method and the path, the `Content-Type` header, the body. A body that parses but fails the schema is [`validation_failed`](/errors/validation_failed), and that code names the field. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`validation_failed`](/errors/validation_failed) — a field failed the schema. - [`unsupported_media_type`](/errors/unsupported_media_type) — the body was not JSON. - [`payload_too_large`](/errors/payload_too_large) — the body is over the ceiling. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "invalid_request", "message": "The request could not be read. Check the method, headers and body.", "docs_url": "https://doc.cheap/docs/errors/invalid_request", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # maintenance (503) The service is closed for planned maintenance. Nothing was charged. ## Cause An operator has opened a maintenance window. While one is open, a gate in front of every route answers before the request reaches its endpoint. The answer is therefore the same at every address, including one that routes nowhere. A window has two settings. In the stricter one every request is refused. In the lighter one reads are still served and anything that would change something is refused. A caller that only fetches results carries on working. ## The message ```text The service is closed for maintenance; nothing was charged. Retry shortly. ``` ## The fix Wait and retry. The response carries a `Retry-After` header with the number of seconds. Honor it rather than retrying on a tighter loop. A maintenance window does not clear on its own, and a fleet retrying every second arrives all at once when it does. Planned windows are announced ahead of time on the [status page](https://doc.cheap/status). ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`service_unavailable`](/errors/service_unavailable) — a dependency is unreachable. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "maintenance", "message": "The service is closed for maintenance; nothing was charged. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/maintenance", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # not_found (404) No resource matched the request. ## Cause Three cases share this code. - No scan was ever stored with that id. It was created with `retain_hours: 0`, or it belongs to another account. - A scan was stored and its retention window has since passed. - The path is not a route this API serves. The first two are not distinguished. Telling a caller that an id existed but has gone is telling them about somebody else's history. A path parameter that cannot be a valid id answers here rather than 422. An id that could never exist names a resource that cannot exist. ## The message The wording depends on which of the cases above it was. ```text No scan with id 01a0af18-cd8d-7a61-9f2d-4c7b8e105da3 exists or it has expired. No route for GET /v1/scan/1. ``` ## The fix Check the id and the path. A result is readable only while its retention window lasts. That window is the `retain_hours` the creating request asked for, or the account's own setting when it asked for none. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`idempotency_replay_unavailable`](/errors/idempotency_replay_unavailable) — nothing left to replay. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "not_found", "message": "No scan with id 01a0af18-cd8d-7a61-9f2d-4c7b8e105da3 exists or it has expired.", "docs_url": "https://doc.cheap/docs/errors/not_found", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # payload_too_large (413) The request body is over the ceiling, and was refused before it was read. ## Cause A scan carries its image inline as base64, which is about a third larger than the file on disk. The ceiling is 36 MiB, enforced before the body is read into memory and before the engine is called. ## The message ```text The request body is larger than this endpoint accepts. ``` ## The fix Send a smaller image. A document photograph does not need full sensor resolution. Re-encode it as JPEG at a lower quality, or downscale it so the document occupies about 1500–2000 pixels on its long edge. Recognition quality depends on how many pixels cover the document itself, not on the size of the frame around it. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`unsupported_media_type`](/errors/unsupported_media_type) — the body was not JSON. - [`invalid_request`](/errors/invalid_request) — the request could not be read. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "payload_too_large", "message": "The request body is larger than this endpoint accepts.", "docs_url": "https://doc.cheap/docs/errors/payload_too_large", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # payment_driver_unavailable (501) A top-up was asked for through a payment driver this deployment does not have enabled. ## Cause The request was well formed. What is missing is a driver able to take the money, which is deployment configuration rather than anything about the request. ## The message ```text Payment driver stripe is not enabled. ``` ## The fix **An API caller does not meet this code.** It is raised only on an administrative surface, which is not part of the public API and is not reachable with an API key. A call of your own that returns it went somewhere it was not meant to go. Credits are topped up from the dashboard. Crypto deposits go through no payment driver at all. Each account has its own permanent deposit address, and a transfer to it becomes credits on its own. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`topup_in_progress`](/errors/topup_in_progress) — a top-up is open and part-paid. - [`rate_unavailable`](/errors/rate_unavailable) — no corroborated exchange rate. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "payment_driver_unavailable", "message": "Payment driver stripe is not enabled.", "docs_url": "https://doc.cheap/docs/errors/payment_driver_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # rate_limited (429) The caller is over the rate limit for its key kind. ## Cause Two tiers, and they are different animals. The public sandbox key is unregistered and shared, so its bucket is keyed by client address over a long window. It exists to keep one visitor from consuming the demo for everybody else. A registered key is metered by its own balance, so its limit only keeps a runaway client from flooding the service. That one is per key, over a short window. The figures are on [limits](/reference/limits). ## The message The wording depends on which of the cases above it was. ```text Rate limit exceeded; retry later. Sandbox limit of 10 requests per hour per IP reached. ``` ## The fix Wait for the window to pass, then retry. Honor the `Retry-After` header where the response carries one, rather than retrying on a tighter loop. A registered key has its own, much shorter window than the public sandbox. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`registration_required`](/errors/registration_required) — the anonymous allowance is spent. - [`document_repeated`](/errors/document_repeated) — the same image, too many times. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "rate_limited", "message": "Rate limit exceeded; retry later.", "docs_url": "https://doc.cheap/docs/errors/rate_limited", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # rate_unavailable (503) No corroborated exchange rate could be produced, so nothing was quoted. ## Cause A crypto top-up is quoted in dollars and paid in coin, so an exchange rate has to be chosen and stood behind. The rate is taken from three independent price sources, and is used only when at least two of them agree. A quote that disagrees with the median by more than the configured tolerance is dropped. Fewer than two survivors means no rate. Quoting a price nobody corroborates would fix an amount of money against a number that may be wrong. A crypto payment cannot be recalled, so the request is refused instead. ## The message ```text No corroborated exchange rate is available right now, so no amount can be quoted. Retry shortly. ``` ## The fix Retry with backoff. Nothing partial was created. No intent needs cleaning up and no address was handed out, so a retry creates exactly one top-up when it succeeds. ## event_id `event_id` is present on the first occurrence in a window and `null` on the ones that follow. The failure is worth recording; one event per request is not. An afternoon of upstream downtime would cost more events than a month's quota holds. The rate is what is recorded, not each request. ## Related codes - [`topup_in_progress`](/errors/topup_in_progress) — a top-up is open and part-paid. - [`service_unavailable`](/errors/service_unavailable) — a dependency is unreachable. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "rate_unavailable", "message": "No corroborated exchange rate is available right now, so no amount can be quoted. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/rate_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # registration_required (403) The anonymous free allowance on the public sandbox is used up. ## Cause The public sandbox key runs on a lifetime allowance of free recognitions per anonymous client. It is separate from the per-window rate limit, and both apply. An allowance the service cannot count is treated as spent. Refusing a caller who still had attempts left is recoverable; handing out uncounted free recognition is not. ## The message ```text The free trial without an account is used up; register for your own API key to keep scanning. ``` ## The fix Register and use your own key instead of `sk_sandbox_public`. An account draws on its own balance rather than the shared anonymous allowance, so this wall does not apply to it. The allowance is not refilled by waiting. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`unauthorized`](/errors/unauthorized) — no usable API key. - [`rate_limited`](/errors/rate_limited) — over the rate limit. - [`document_repeated`](/errors/document_repeated) — the same image, too many times. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "registration_required", "message": "The free trial without an account is used up; register for your own API key to keep scanning.", "docs_url": "https://doc.cheap/docs/errors/registration_required", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # service_unavailable (503) A dependency this service needs is unreachable. Nothing was charged. ## Cause Something the API depends on internally is not answering. It is not a problem with the request: the same call works again once the dependency is back. Which dependency is not named. Which of this service's internals is down is not the caller's business, and naming it is free reconnaissance. It can also appear on the public sandbox key while an internal counter is unreadable. The sandbox allowance and the repeated-document guard keep that key free to publish. A request that cannot consult them is refused rather than waved through. ## The message ```text The service is temporarily unable to handle this request; nothing was charged. Retry shortly. ``` ## The fix Retry after the `Retry-After` header says, with backoff; treat its absence as a few seconds. Nothing was charged and no scan was run, so a retry is safe. ## event_id `event_id` is present on the first occurrence in a window and `null` on the ones that follow. The failure is worth recording; one event per request is not. An afternoon of upstream downtime would cost more events than a month's quota holds. The rate is what is recorded, not each request. ## Related codes - [`engine_unavailable`](/errors/engine_unavailable) — the engine did not answer. - [`maintenance`](/errors/maintenance) — a planned window is open. - [`internal_error`](/errors/internal_error) — the service failed unexpectedly. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unable to handle this request; nothing was charged. Retry shortly.", "docs_url": "https://doc.cheap/docs/errors/service_unavailable", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # topup_in_progress (409) A top-up for this asset is already open and part-paid. ## Cause A crypto top-up quotes an amount at a price that is locked for the life of that quote. Whatever the market does while the transaction confirms, the payment is credited at the price shown when it was made. A deposit address is permanent and belongs to the account. Two open quotes for one asset would leave a payment arriving there with two locked prices claiming it. While a quote has nothing against it, asking for a new one replaces it. Once part of the payment has arrived, the open quote is carrying money settled at its price, and it is not replaced. ## The message ```text A top-up for this asset has already been paid in part; it is settled at the price it locked. ``` ## The fix Finish the open top-up, or wait for its window to close and then create a new quote. Sending the rest of the quoted amount to the same address credits it at the same price. Money already sent is never lost by waiting: a payment that arrives late is still credited, at the price the quote locked. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`rate_unavailable`](/errors/rate_unavailable) — no corroborated exchange rate. - [`insufficient_credits`](/errors/insufficient_credits) — the balance cannot cover a recognition. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "topup_in_progress", "message": "A top-up for this asset has already been paid in part; it is settled at the price it locked.", "docs_url": "https://doc.cheap/docs/errors/topup_in_progress", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # unauthorized (401) The request carried no usable API key. ## Cause One of three things: no `Authorization` header, a header that is not `Bearer `, or a key this service does not know. A revoked key is an unknown key. Which of the three it was is not distinguished in the code. Telling an unauthenticated caller whether a key exists is telling them something about somebody else's account. ## The message The wording depends on which of the cases above it was. ```text Send your API key as `Authorization: Bearer `. Unknown API key. ``` ## The fix Send the header as `Authorization: Bearer sk_live_…`, or `Bearer sk_sandbox_public` to call without an account. A key is shown once when it is created; a key nobody has any more is replaced rather than recovered. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`registration_required`](/errors/registration_required) — the anonymous allowance is spent. - [`impersonation_read_only`](/errors/impersonation_read_only) — an administrator's view may only read. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "unauthorized", "message": "Send your API key as `Authorization: Bearer `.", "docs_url": "https://doc.cheap/docs/errors/unauthorized", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # unsupported_media_type (415) The body was sent under a content type this API does not accept. ## Cause Every endpoint takes JSON. A request sent as `text/plain`, `multipart/form-data`, `application/x-www-form-urlencoded` or with no `Content-Type` at all is refused here, before the body is parsed. The image travels as a base64 string inside a JSON object, never as a file upload. ## The message ```text Send the request body as `Content-Type: application/json`. ``` ## The fix Send `Content-Type: application/json` and put the base64 image in the `image` field of the JSON body. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`invalid_request`](/errors/invalid_request) — the request could not be read. - [`payload_too_large`](/errors/payload_too_large) — the body is over the ceiling. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "unsupported_media_type", "message": "Send the request body as `Content-Type: application/json`.", "docs_url": "https://doc.cheap/docs/errors/unsupported_media_type", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # validation_failed (422) The body parsed as JSON, and a field failed the schema. ## Cause Every request is validated against the published contract before anything runs. A field of the wrong type, a value outside its range, or a key `options` does not declare is refused here. `options` is a strict object, so a misspelled option is never silently ignored. The message names the offending path and what was expected. ## The message The wording depends on which of the cases above it was. ```text /options/mode: Invalid option: expected one of "full" The accept-version header is not part of this API. There is one response shape; remove the header. ``` ## The fix Read the path in the message and fix that field. The types, the defaults and the ranges are on [scan options](/reference/scan-options) and in the generated [endpoint reference](/reference/endpoints/create-a-scan). Several issues are reported at once, joined by `; `. ## event_id `event_id` is always `null` on this code. It is a refusal the caller is meant to handle, so nothing is recorded as a failure to look at. The service answers and writes one log line. ## Related codes - [`invalid_request`](/errors/invalid_request) — the request could not be read. - [`payload_too_large`](/errors/payload_too_large) — the body is over the ceiling. The whole catalogue, grouped by what a caller does with it, is on [errors](/reference/errors). ## The response ```json { "error": { "code": "validation_failed", "message": "/options/mode: Invalid option: expected one of \"full\"", "docs_url": "https://doc.cheap/docs/errors/validation_failed", "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642", "event_id": null } } ``` --- # Changelog Every change to the recognition API and to this documentation, dated and public. Subscribe with the [Atom feed](/changelog/feed.xml). ## [September 2026](/changelog/2026-09) - 2026-09-17 — [1.0.0, the first production release](/changelog/2026-09#2026-09-17-1-0-0-the-first-production-release) --- # Changelog: September 2026 Changes to the API and to this documentation published in September 2026, newest first. ## 2026-09-17 — 1.0.0, the first production release The document-recognition API, at version 1. **Recognition.** `POST /v1/scans` takes one image as base64 and returns the extracted data in the same response. The call is synchronous: no job to poll, no callback to register. Passports, identity cards, travel documents and driving licences are read off the printed page, the machine-readable zone and a barcode where one is printed. **One response shape.** Eight groups — `meta`, `document`, `holder`, `fields`, `mrz`, `images`, `quality` and `authenticity` — described key by key on [the response](/reference/response). Every key is present; an absent value is `null` and an absent collection is empty. `meta.schema_version` is `"1.0"`. **Fields in every script.** Every field the engine reads is re-keyed to a stable vocabulary and published once per language, resolved from 418 assigned language identifiers. A document printed in two scripts carries both spellings, neither chosen for you. **The machine-readable zone, verbatim.** `mrz` is a verdict with the zone beside it: the lines exactly as read, and one unbroken `text` a check-digit routine takes unchanged. **Result images.** Seven crops, each capped by height — 250 px for the document crop, 100 px for the rest — returned by the call that produced them and never stored. **Billing.** One credit is one US cent, charged only for a recognition that produced data. A credit is reserved before the engine runs and settled after. `GET /v1/usage` reports the balance and the period's counters. **Retention is the caller's choice.** `retain_hours` decides how long a result stays readable through `GET /v1/scans/{id}`; `0` writes nothing down at all. **Safe retries.** An `Idempotency-Key` on a scan request returns the first result instead of running and charging again. **Errors.** One error shape everywhere, 21 stable codes, each with a page of its own at `/errors/` that every error body links to. **Three ways in.** The public sandbox key `sk_sandbox_public` recognizes real documents without an account; a registered account's own sandbox key answers from a fixed specimen; a live key bills.