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.
- Crop to the document. A data page filling most of the frame reads better than one in the corner of a desk.
- 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.
document—kind,country,country_name,issuing_state,type_name,type_confidence,is_expiredanddays_remaining.holder—given_names,surname,full_name,birth_date,sexandnationality.
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"
}
]namerepeats across the two entries;iddoes not. Key your own list onid.languageisnullon the neutral, transliterated reading and names the language on the other.confidenceis a band —high,mediumorlow— never a number.
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.
mrz.statusispassed,failedorabsent.mrz.reasonnames what did not check out, and isnullunless the status isfailed.mrz.linesis the lines in order, exactly as read, each one free of whitespace.mrz.textis those lines run together with nothing between them: one unbroken string, no newlines and no spaces. The zone's alphabet isA-Z,0-9and the filler<, so nothing is lost by joining them. Hand it to a check-digit routine unchanged.
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¶
- Photograph the front and the back as separate images.
- Send each as its own
POST /v1/scans. Give both the samereferenceso your own records can join them. - Merge in your code. The side carrying the zone fills
mrz; the other side usually carries the holder's photograph and the printed fields. - Reconcile
meta.billedper 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¶
- Work with result images — the crops this call returns and their sizes.
- Control history retention — reading a scan back later, and keeping nothing at all.