Reading a passport photo into structured data is one of those jobs that looks like "call an OCR API" and turns into three follow-up questions the moment it runs in production. What happens when the photo is blurry? What happens when the request times out and you retry: did you just pay twice? And how do your own records know which calls cost money?

This tutorial answers those three with plain Node.js 18+ and the built-in fetch, no SDK. It uses doc.cheap, and this is doc.cheap's own blog, so treat the product choices with appropriate suspicion. The patterns (idempotency keys, branching on a stable error code, keeping a per-call cost flag) apply to any paid API.

The first call, with no account

The API has a public sandbox key printed in its docs, sk_sandbox_public. It runs the same recognition as a paid key and needs no signup: 10 free recognised documents per IP address in total, and at most 10 requests an hour, whatever their answer. That is enough to try everything below. Registering later adds 20 free credits, with no card.

import { readFileSync } from "node:fs";

const image = readFileSync("specimen.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, scan.mrz.status);

Save it as first.mjs and run node first.mjs. The call is synchronous: no job id, no polling, no webhook. The recognition happens inside the request and the fields come back in its response.

Use a synthetic specimen for testing, not your own passport. Many issuers publish specimen pages, and ICAO's fictitious "Utopia" documents exist for exactly this.

Image size matters more than you would think. A phone photo straight off the camera can be several megabytes, which base64 inflates by about a third. The docs recommend about 1600 px on the long edge at JPEG quality 85. If a first call feels slow, look at meta.timing.upload_ms before blaming anyone's servers.

What comes back

One JSON shape, eight groups, and every key is always present. A value that is not known is null, never a missing key. An abridged, recognised response looks like this:

{
  "meta": {
    "schema_version": "1.0",
    "id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3",
    "status": "recognized",
    "billed": true,
    "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",
    "number": "AM7304518", "issue_date": "2022-03-10", "expiry_date": "2032-03-10",
    "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<GRC…", "AM7304518…"], "text": "P<GRC…" },
  "images": { "document_crop": "data:image/jpeg;base64,…", "main_photo": "data:image/jpeg;base64,…" },
  "quality": { "overall": "pass" },
  "authenticity": { "overall": "not_checked", "checks": [] }
}

(The holder is an invented specimen from the docs; fields and images are trimmed.) The parts worth knowing:

  • document and holder are the curated values. Dates are ISO 8601, countries ISO 3166-1 alpha-3. Each group is null as a whole when the scan produced nothing for it, which is why the snippet above uses ?..
  • fields[] is every individual reading, with its own confidence band (high, medium, low, not a fake-precise percentage). A name printed in Greek comes back twice, once in Greek and once transliterated, and the Greek reading is tagged with its language.
  • mrz.status is passed, failed or absent, and mrz.text is the raw zone so you can re-run the check digits yourself.
  • authenticity.overall is not_checked. This is recognition, not forgery detection. Do not sell it to your compliance team as ID verification.

A blurry photo is not an exception

The single most useful thing to internalise: a photo that could not be read is a 200, not an error. meta.status is one of exactly five strings:

status Meaning What to do
recognized Read successfully Use the data
no_document_found Nothing document-shaped in the frame Ask the user to re-frame
unreadable A document, but no usable text Better light, focus, angle
unsupported_document Found, but not a type the API knows Stop, a retry reads the same
rejected Failed on the service side Retry once

So your code branches twice: on HTTP status for errors, and on meta.status for outcomes. Treat no_document_found as an exception and you retry a photo that will never read. Treat it as success and you store a document with no fields.

Who pays for the blurry photo

Every response carries meta.billed. On a live key it is true when the document type was determined and data was actually extracted: an MRZ whose check digits pass, at least five fields of the printed zone, or a correctly decoded barcode. Everything else (no document found, unreadable image, unsupported type, internal error, timeout) costs nothing. The price for a billed document is $0.01, flat, at any volume.

On either sandbox key nothing is charged at all. billed still says whether the same scan would have been billed on a live key, which is what makes the sandbox useful for testing the flag.

The flag is per call, so store it next to the result. For a live key, your own count of billed: true rows in a calendar month (UTC) is then the same number GET /v1/usage reports as scans.billed for that month, with no reconciliation step.

Retries that cannot double-charge

The dangerous retry is the one after a timeout: you do not know whether the first request landed. The API accepts an Idempotency-Key header on POST /v1/scans (1 to 255 characters). On a live key, a retry under the same key with the same body returns the stored first result (without the image crops) instead of running recognition and charging again.

Three details from the reference that change how you write the client:

  • The key is matched together with a fingerprint of the whole body. Same key, different image is a 409 idempotency_conflict, not a silent replay.
  • A key is remembered only as long as the result is stored. If you send retain_hours: 0 (keep nothing), a later retry under the same key gets 409 idempotency_replay_unavailable: the scan is never run twice, but there is nothing to hand back. Zero retention and replayable retries are mutually exclusive, so pick one per use case.
  • On the sandbox key the header is accepted but has no effect, because nothing is billed there. You can still write and test the code path.

The whole client

This is the version we would put in a service. It generates one key per image and reuses it on every retry, retries only the error codes the docs say are retryable, honours Retry-After up to a minute and gives up rather than wait longer, and returns the raw scan so you keep every field.

// scan.mjs
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const API = "https://api.doc.cheap/v1/scans";
const KEY = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public";
const RETRY = new Set([
  "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
  "service_unavailable", "maintenance", "idempotency_in_progress",
]);

export class ScanError extends Error {
  constructor(status, error) {
    super(`${error.code} (${status}): ${error.message}`);
    this.code = error.code;
    this.docsUrl = error.docs_url;
    this.requestId = error.request_id;
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function scan(path, { reference = null, attempts = 4 } = {}) {
  const image = (await readFile(path)).toString("base64");
  const body = JSON.stringify({ image, reference, options: { return_portrait: false } });
  const idempotencyKey = randomUUID(); // one key for this image, reused on every retry

  for (let attempt = 1; ; attempt++) {
    let response;
    try {
      response = await fetch(API, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body,
        signal: AbortSignal.timeout(60_000),
      });
    } catch (networkError) {
      if (attempt >= attempts) throw networkError;
      await sleep(1000 * 2 ** attempt);
      continue;
    }

    // A proxy in the way can answer with HTML; treat that like a network error.
    const payload = await response.json().catch(() => null);
    if (payload === null) {
      if (attempt >= attempts) throw new Error(`HTTP ${response.status} without a JSON body`);
      await sleep(1000 * 2 ** attempt);
      continue;
    }
    if (response.ok) return payload;

    const { error } = payload;
    if (!RETRY.has(error.code) || attempt >= attempts) throw new ScanError(response.status, error);
    // The sandbox's hourly limit asks for up to an hour; waiting that long
    // inside one call helps nobody, so anything over a minute is an error.
    const retryAfter = Number(response.headers.get("Retry-After"));
    const wait = retryAfter > 0 ? retryAfter : 2 ** attempt;
    if (wait > 60) throw new ScanError(response.status, error);
    await sleep(1000 * wait);
  }
}

export function summarise({ meta, document, holder, mrz }) {
  if (meta.status !== "recognized") {
    return { ok: false, status: meta.status, billed: meta.billed };
  }
  return {
    ok: true,
    billed: meta.billed,
    confidence: meta.confidence,
    kind: document?.kind ?? null,
    country: document?.country ?? null,
    number: document?.number ?? null,
    expiryDate: document?.expiry_date ?? null,
    isExpired: document?.is_expired ?? null,
    surname: holder?.surname ?? null,
    givenNames: holder?.given_names ?? null,
    birthDate: holder?.birth_date ?? null,
    mrz: mrz.status,
    mrzReason: mrz.reason,
  };
}

if (import.meta.url === `file://${process.argv[1]}`) {
  try {
    const result = await scan(process.argv[2], { reference: "demo-1" });
    console.log(summarise(result), result.meta.timing);
  } catch (err) {
    if (err instanceof ScanError) console.error(err.message, err.docsUrl, err.requestId);
    else throw err;
    process.exitCode = 1;
  }
}

Run it with node scan.mjs specimen.jpg. This is what it printed for a generated test passport on the public sandbox key on 24 September 2026. The values read off the document are replaced with ; everything else is exactly as printed:

{
  ok: true,
  billed: true,
  confidence: 'medium',
  kind: 'passport',
  country: '…',
  number: '…',
  expiryDate: '…',
  isExpired: false,
  surname: '…',
  givenNames: '…',
  birthDate: '…',
  mrz: 'passed',
  mrzReason: null
} { upload_ms: 271, processing_ms: 410, total_ms: 691 }

billed: true on a sandbox key is not a charge: it says this scan would have cost one credit on a live key.

A few choices worth explaining:

  • fetch does not throw on a 4xx or 5xx. It throws only on network failure, so the client checks response.ok and reads the JSON error body either way.
  • Branch on error.code, never on the HTTP status. On POST /v1/scans, three idempotency codes share 409 and three different outages share 503, and they need different handling. Every error body has the same shape: code, message, docs_url, request_id, event_id. Log the request_id; it is what support needs. The full table is in handle errors.
  • Not everything is retryable. validation_failed, payload_too_large, unauthorized and insufficient_credits need a fix, not a loop. On the sandbox you will also meet registration_required (the free allowance is spent; waiting does not refill it) and document_repeated (the same image sent too often in an hour).
  • Do not log the request body. It is an identity document.
  • reference is echoed back as meta.reference (up to 128 characters), which is the easy way to join a scan to your own order or user record. Two sides of an ID card are two calls; give them the same reference.

Keeping less data

The uploaded image is held in memory for the request and never written to durable storage. The result is a different matter: it is kept so you can read it back with GET /v1/scans/{id}, for a window you choose. The account setting offers 24 hours, 7 days, 30 days or one year, and the default for a new account is one year. Per request, options.retain_hours takes 0 to 8760; 0 writes no row at all. If you only need the JSON once, send retain_hours: 0, and accept the idempotency trade-off above. Processing happens in the EU.

return_portrait: false, used in the client above, leaves out images.main_photo, the crop of the holder's photo, which is one less thing to handle carefully in your own logs and storage. The crop of the whole page still comes back.

Going live

Swap sk_sandbox_public for your own key through DOC_CHEAP_API_KEY and nothing else changes: same endpoint, same shape. A registered key allows 60 requests a minute. Credits are bought with cryptocurrency (BTC, ETH, TRX, or USDT on Ethereum or Tron) with a $1 minimum; there is no card checkout today, which is worth knowing before you plan a demo for a finance team. Pricing has the one price and the billed-only-on-success rule, and the free passport OCR API page has the no-signup first call as a single curl.

If you try it and something in the response shape is awkward to work with in Node, write to admin@doc.cheap. That is the feedback we are after.

Written with the help of AI. The client above was run against the live sandbox and its output is pasted as printed; every statement about doc.cheap was checked against its code.