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.

curl

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.

{
  "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<GRCPARADEIGMA<<ELENI<SOFIA<<<<<<<<<<<<<<<<",
      "AM73045184GRC9403084F3203101PN48291630<<<<72"
    ],
    "text": "P<GRCPARADEIGMA<<ELENI<SOFIA<<<<<<<<<<<<<<<<AM73045184GRC9403084F3203101PN48291630<<<<72"
  },
  "images": {
    "document_crop": "data:image/jpeg;base64,…",
    "rear": null,
    "main_photo": "data:image/jpeg;base64,…",
    "signature": "data:image/jpeg;base64,…",
    "watermark_face": null,
    "barcode": null,
    "chip": null
  },
  "quality": { "overall": "pass" },
  "authenticity": { "overall": "not_checked", "checks": [] }
}

Eight groups, and every key inside them is always present. A value that is not known is null, never an absent key, so your parser never has to ask whether a field exists.

fields is elided above and is not empty in a real answer. It carries every field the engine read off the printed page, one entry per language. The response is where that list is worth opening.

The outcome

Three keys of meta say how the call went.

recognized and billed are not the same question, and a scan can be one without the other. What a billed scan is holds the rule.

The scan id

meta.id is a UUID version 7 in canonical lower-case form. Its leading 48 bits are the millisecond the scan was made. A column of ids therefore sorts in the order the scans happened, and an index on it stays compact.

Treat the value as opaque otherwise. Nothing else about it is part of the contract, and it is the id GET /v1/scans/{id} takes.

meta.reference is whatever string you sent, echoed back, up to 128 characters. meta.created_at is an ISO-8601 timestamp in UTC.

The data

document and holder carry the curated values, and each is null as a whole when the scan produced nothing for it, rather than an object of nulls.

Dates are ISO 8601 (YYYY-MM-DD). Countries are ISO 3166-1 alpha-3, with country_name beside the code for display. document.days_remaining counts from the day of the scan and goes negative once the document has expired.

Everything the engine read off the page is in fields, one entry per reading. A document printed in two scripts yields one entry per script, so a Greek passport carries both PARADEIGMA and ΠΑΡΑΔΕΙΓΜΑ.

The machine-readable zone

mrz is a verdict with the zone beside it. status is passed, failed or absent; reason names what did not check out when it failed.

lines is the lines in order, exactly as read — two for a passport, three for an ID card. text is those lines run together with nothing between them: one unbroken string, with no newlines and no spaces. The zone's alphabet is A-Z, 0-9 and the filler <, so nothing is lost by joining them. Hand the value to a check-digit routine unchanged. Check an MRZ walks that.

What the call took

meta.timing splits the wait into the half you control and the half we do.

Key Measures Who can shrink it
upload_ms The request arriving and being validated, with your key resolved and its rate limit checked You, by sending a smaller picture from closer by
processing_ms The recognition itself, and nothing else Us
total_ms The whole request, from arrival to a complete result Both

total_ms is at least upload_ms + processing_ms. The remainder is the allowance and credit gates, preparing the result images, and mapping the engine's output into this body. That work is deliberately outside processing_ms, so the number stays a measurement of recognition rather than of our own bookkeeping.

A first call over a slow link is often more upload_ms than processing_ms. Shrink the image before you conclude the API is slow.

The crops

images carries seven slots of pictures the engine cut out of your photograph, each a data: URL or null. They are returned by this call and never written down, so a scan read back later carries every slot null. Send "options": {"return_portrait": false} to leave the holder's photograph out.

If it did not recognize anything

A failure to read is a 200, not an error. status says how far the engine got, and the body is complete either way.

status What happened What to do
no_document_found Nothing document-shaped was in the frame Re-frame and photograph again
unreadable A document was there, and no usable text came off it Improve the light, the focus or the angle
unsupported_document The document was found, and its type is not one we know Stop; a retry reads the same
rejected Recognition failed on our side Retry once

None of the four is charged on a live key. An HTTP error is a different thing, and handle errors has the table for those.

Check the account

GET /v1/usage reports the balance and the counters for the current calendar month.

curl

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.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["scans"]["total"])

The public sandbox key carries no account, so balance_credits comes back null and the counters stay at zero. A key of your own reports real figures.

Next