Recognize an ID card

An ID card prints its data on two sides, and POST /v1/scans takes one image per call. This guide covers the workflow that follows: two calls, how to match them, and what each side gives you.

The request itself is the same call a passport takes, with the same options. If you have not made one yet, read recognize a passport first. The TD1, TD2 and TD3 layouts are described there and are not repeated here.

Photograph both sides

Take two pictures, one per side, each cropped to the card. Resize the long edge to about 1600 px and re-encode as JPEG at quality 85, which is what the upload pages send.

Do not stack the two sides into one image. The engine locates one document in the frame, so a collage of two cards reads as one card photographed badly.

Send each side as its own call

Give both calls the same reference. It is echoed back in meta.reference, and it is the value your own records join on.

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\": \"card-7781-front\"}" --output front.json

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\": \"card-7781-back\"}"

JavaScript

import { readFileSync } from "node:fs";

const send = (image, reference) =>
  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.

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.
{
  "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.

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.

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.

Next