Recognize a passport

The whole task is one POST /v1/scans. This guide covers the image to send, the options worth setting, and the blocks you read the answer out of. The ID-card layouts are at the end.

Prepare the image

The endpoint takes JPEG or PNG, base64-encoded in the JSON body. Before you encode, do two things to the picture.

  1. Crop to the document. A data page filling most of the frame reads better than one in the corner of a desk.
  2. Resize the long edge to about 1600 px and re-encode as JPEG at quality 85. That is what our own upload pages send, and it keeps the print well above the size the engine needs.

The body ceiling is 36 MiB by default. A body over it answers payload_too_large before it is read into memory, and before the engine is called. Base64 makes a file about a third larger, so that ceiling covers a 25 MiB photograph sent unchanged. Resizing first keeps an ordinary call in the hundreds of kilobytes.

Send it

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.document.kind, scan.holder.full_name, scan.mrz.status);

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["document"]["kind"], scan["holder"]["full_name"], scan["mrz"]["status"])

Set the options that matter

Send an options object when a default does not suit you. Unknown keys are rejected rather than ignored, so a typo answers validation_failed with the offending path in the message.

Option Values Default Use it when
expect_country ISO 3166-1 alpha-3, or null null You already know the issuer and want the hint carried in
return_portrait boolean true You do not want the holder's face in the response at all
retain_hours 0–8760, or null null This one result must be kept, or must not be
reference string up to 128 characters null You want your own order id echoed back

retain_hours: 0 writes no row at all, rather than a row that expires early. The full list, with every default, is on scan options.

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')\",
       \"reference\": \"order-1042\",
       \"options\": {\"expect_country\": \"GRC\", \"return_portrait\": false, \"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,
    reference: "order-1042",
    options: { expect_country: "GRC", return_portrait: false, retain_hours: 0 },
  }),
});

const scan = await response.json();
console.log(scan.meta.reference, scan.images.main_photo);

Python

import base64
import json
import urllib.request

with open("document.jpg", "rb") as file:
    image = base64.b64encode(file.read()).decode()

body = {
    "image": image,
    "reference": "order-1042",
    "options": {"expect_country": "GRC", "return_portrait": False, "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"]["reference"], scan["images"]["main_photo"])

Read the result

document and holder carry the curated values, and each is null as a whole when the scan produced nothing for it.

Everything the engine read off the printed page is in fields, one entry per reading. The document number and the personal number are entries there, under document_number and personal_number. The whole shape is laid out on the response.

Read the same fact in two scripts

A passport printed in a national script carries the holder's name twice: once in that script and once transliterated into Latin. fields publishes both, one entry each, rather than choosing for you.

[
  {
    "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"
  }
]

holder.surname carries the Latin reading. Read the fields entry when you want the spelling the document prints.

Check the machine-readable zone

A passport data page carries the zone at the bottom, and the response publishes it verbatim beside a verdict.

Re-running the digits yourself is check an MRZ.

ID cards and the three layouts

POST /v1/scans reads whatever document is in the frame, so an ID card needs no different call. What differs is the layout of its zone, and how many lines come back.

Layout Lines Where you meet it
TD1 3 lines of 30 characters ID cards, residence permits
TD2 2 lines of 36 characters Older ID cards and some travel documents
TD3 2 lines of 44 characters Passport booklets

Whichever layout it is, the zone fills the same values once it is read: holder.surname, holder.given_names, holder.birth_date, holder.sex, holder.nationality, document.issuing_state, the date of expiry behind document.days_remaining, and the document_number entry of fields. The personal_number entry comes from the zone's optional-data field, which TD2 does not carry.

No layout encodes a date of issue. The issue_date field is therefore a reading of the printed page. It is absent on a document whose visual zone was not read, even when the zone's check digits passed.

A card prints its zone on the back, and this endpoint takes one image per call. Send each side as its own scan and merge the two results in your own code: recognize an ID card walks that.

Sending both sides

  1. Photograph the front and the back as separate images.
  2. Send each as its own POST /v1/scans. Give both the same reference so your own records can join them.
  3. Merge in your code. The side carrying the zone fills mrz; the other side usually carries the holder's photograph and the printed fields.
  4. Reconcile meta.billed per call. Two calls on a live key are two credits when both produce a billable result.

When it does not come back recognized

meta.status says how far the engine got, and a failure to recognize is a 200 rather than an error. Re-photograph and retry on no_document_found or unreadable; there is nothing to retry on unsupported_document. An HTTP error is a different thing entirely, and handle errors has the table.

Next