Trasformare la foto di un passaporto in dati strutturati è uno di quei lavori che sembrano "chiamare un'API OCR" e che, appena arrivano in produzione, diventano tre domande in più. Cosa succede quando la foto è sfocata? Cosa succede quando la richiesta va in timeout e riprovi: hai appena pagato due volte? E come fanno i tuoi archivi a sapere quali chiamate costano denaro?

Questo tutorial risponde a queste tre domande con Node.js 18+ e il fetch integrato, senza SDK. Usa doc.cheap, e questo è il blog di doc.cheap, quindi prendi le scelte di prodotto con il giusto sospetto. I pattern (chiavi di idempotenza, diramazioni su un codice di errore stabile, un flag di costo per ogni chiamata) valgono per qualsiasi API a pagamento.

La prima chiamata, senza account

L'API ha una chiave sandbox pubblica stampata nella documentazione, sk_sandbox_public. Esegue lo stesso riconoscimento di una chiave a pagamento e non richiede registrazione: 10 documenti riconosciuti gratis per indirizzo IP in totale, e al massimo 10 richieste all'ora, qualunque sia la risposta. Basta per provare tutto ciò che segue. Registrandoti in seguito ottieni 20 crediti gratuiti, senza carta.

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);

Salvalo come first.mjs ed esegui node first.mjs. La chiamata è sincrona: niente id di job, niente polling, niente webhook. Il riconoscimento avviene dentro la richiesta e i campi tornano nella sua risposta.

Per i test usa un facsimile sintetico, non il tuo passaporto. Molti emittenti pubblicano pagine di esempio, e i documenti fittizi di "Utopia" dell'ICAO esistono proprio per questo.

La dimensione dell'immagine conta più di quanto pensi. Una foto scattata col telefono può pesare diversi megabyte, e il base64 la gonfia di circa un terzo. La documentazione consiglia circa 1600 px sul lato lungo con qualità JPEG 85. Se una prima chiamata ti sembra lenta, guarda meta.timing.upload_ms prima di dare la colpa ai server di qualcuno.

Cosa ricevi

Una sola forma JSON, otto gruppi, e ogni chiave è sempre presente. Un valore sconosciuto è null, mai una chiave mancante. Una risposta riconosciuta, abbreviata, ha questo aspetto:

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

(Il titolare è un facsimile inventato preso dalla documentazione; fields e images sono accorciati.) Le parti da conoscere:

  • document e holder sono i valori già consolidati. Le date sono in ISO 8601, i paesi in ISO 3166-1 alpha-3. Ogni gruppo è null per intero quando la scansione non ha prodotto nulla per quel gruppo, ed è per questo che lo snippet qui sopra usa ?..
  • fields[] contiene ogni singola lettura, ciascuna con la propria fascia di confidence (high, medium, low, non una percentuale di falsa precisione). Un nome stampato in greco torna due volte, una in greco e una traslitterata, e la lettura in greco è etichettata con la sua lingua.
  • mrz.status vale passed, failed o absent, e mrz.text è la zona grezza, così puoi ricalcolare tu le cifre di controllo.
  • authenticity.overall vale not_checked. Questo è riconoscimento, non rilevamento delle falsificazioni. Non venderlo al tuo team compliance come verifica dell'identità.

Una foto sfocata non è un'eccezione

La cosa più utile da interiorizzare: una foto che non è stato possibile leggere è un 200, non un errore. meta.status è una di esattamente cinque stringhe:

status Significato Cosa fare
recognized Letto correttamente Usa i dati
no_document_found Nell'inquadratura non c'è nulla che somigli a un documento Chiedi all'utente di rifare l'inquadratura
unreadable Un documento, ma nessun testo utilizzabile Luce migliore, messa a fuoco, angolazione
unsupported_document Trovato, ma non è un tipo che l'API conosce Fermati, un nuovo tentativo legge la stessa cosa
rejected Fallito lato servizio Riprova una volta

Quindi il tuo codice si dirama due volte: sullo status HTTP per gli errori e su meta.status per gli esiti. Tratta no_document_found come un'eccezione e riproverai una foto che non verrà mai letta. Trattalo come un successo e salverai un documento senza campi.

Chi paga la foto sfocata

Ogni risposta contiene meta.billed. Con una chiave live vale true quando il tipo di documento è stato determinato e i dati sono stati effettivamente estratti: una MRZ le cui cifre di controllo risultano corrette, almeno cinque campi della zona stampata o un codice a barre decodificato correttamente. Tutto il resto (nessun documento trovato, immagine illeggibile, tipo non supportato, errore interno, timeout) non costa nulla. Il prezzo di un documento addebitato è $0.01, fisso, a qualsiasi volume.

Con l'una o l'altra chiave sandbox non viene addebitato nulla. Con la chiave pubblica, sk_sandbox_public, billed indica comunque se la stessa scansione sarebbe stata addebitata con una chiave live, ed è questo che la rende utile per testare il flag. La chiave sandbox di un account non legge la tua immagine: risponde a ogni chiamata con un unico esemplare incorporato, quindi lì billed descrive quell'esemplare.

Il flag è per chiamata, quindi salvalo accanto al risultato. Con una chiave live, il tuo conteggio delle righe con billed: true in un mese di calendario (UTC) è allora lo stesso numero che GET /v1/usage riporta come scans.billed per quel mese, senza alcuna riconciliazione.

Retry che non possono addebitare due volte

Il retry pericoloso è quello dopo un timeout: non sai se la prima richiesta è arrivata. L'API accetta un header Idempotency-Key su POST /v1/scans (da 1 a 255 caratteri). Con una chiave live, un retry con la stessa chiave e lo stesso body restituisce il primo risultato salvato (senza i ritagli delle immagini) invece di rieseguire il riconoscimento e addebitare di nuovo.

Tre dettagli della reference che cambiano il modo in cui scrivi il client:

  • La chiave viene confrontata insieme a un'impronta dell'intero body. Stessa chiave con un'immagine diversa dà 409 idempotency_conflict, non una ripetizione silenziosa.
  • Una chiave viene ricordata finché il risultato resta salvato. Se invii retain_hours: 0 (non conservare nulla), la chiave viene comunque ricordata per 24 ore: un retry con quella chiave in quel periodo riceve 409 idempotency_replay_unavailable, quindi la scansione non viene eseguita due volte, ma non c'è nulla da restituire. Trascorse quelle 24 ore, un retry con la stessa chiave è una nuova scansione. Conservazione zero e retry ripetibili si escludono a vicenda, quindi scegli uno dei due per ogni caso d'uso.
  • Con la chiave sandbox l'header viene accettato ma non ha effetto, perché lì non si addebita nulla. Puoi comunque scrivere e testare quel percorso di codice.

Il client completo

Questa è la versione che metteremmo in un servizio. Genera una chiave per immagine e la riusa a ogni retry, ripete solo i codici di errore che la documentazione indica come ripetibili, rispetta Retry-After fino a un minuto e rinuncia invece di aspettare di più, e restituisce la scansione grezza così conservi ogni campo.

// 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(); // una chiave per questa immagine, riusata a ogni 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;
    }

    // Un proxy intermedio può rispondere con HTML; lo trattiamo come un errore di rete.
    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);
    // Il limite orario della sandbox chiede di aspettare fino a un'ora; aspettare tanto
    // dentro una sola chiamata non aiuta nessuno, quindi oltre un minuto è un errore.
    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;
  }
}

Eseguilo con node scan.mjs specimen.jpg. Ecco cosa ha stampato per un passaporto di test generato, con la chiave sandbox pubblica, il 24 settembre 2026. I valori letti dal documento sono sostituiti da ; tutto il resto è esattamente come è stato stampato:

{
  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 con una chiave sandbox non è un addebito: dice che questa scansione sarebbe costata un credito con una chiave live.

Alcune scelte che vale la pena spiegare:

  • fetch non lancia eccezioni su un 4xx o un 5xx. Le lancia solo in caso di errore di rete, quindi il client controlla response.ok e legge in ogni caso il body JSON dell'errore.
  • Diramati su error.code, mai sullo status HTTP. Su POST /v1/scans tre codici di idempotenza condividono il 409 e tre diversi disservizi condividono il 503, e vanno gestiti in modo diverso. Ogni body di errore ha la stessa forma: code, message, docs_url, request_id, event_id. Registra nei log il request_id: è ciò che serve al supporto. La tabella completa è in handle errors (in inglese).
  • Non tutto è ripetibile. validation_failed, payload_too_large, unauthorized e insufficient_credits richiedono una correzione, non un ciclo. In sandbox incontrerai anche registration_required (la quota gratuita è esaurita; aspettare non la ricarica) e document_repeated (la stessa immagine inviata troppe volte in un'ora).
  • Non registrare nei log il body della richiesta. È un documento d'identità.
  • reference ti viene restituito come meta.reference (fino a 128 caratteri), ed è il modo semplice per collegare una scansione al tuo ordine o al tuo record utente. I due lati di una carta d'identità sono due chiamate: dai a entrambe lo stesso reference.

Conservare meno dati

L'immagine caricata resta in memoria per la durata della richiesta e non viene mai scritta su uno storage persistente. Il risultato è un'altra storia: viene conservato perché tu possa rileggerlo con GET /v1/scans/{id}, per un periodo che scegli tu. L'impostazione dell'account offre 24 ore, 7 giorni, 30 giorni o un anno, e il valore predefinito per un nuovo account è un anno. Per singola richiesta, options.retain_hours accetta da 0 a 8760; 0 non scrive alcuna riga. Se il JSON ti serve una volta sola, invia retain_hours: 0 e accetta il compromesso sull'idempotenza descritto sopra. L'elaborazione avviene nell'UE.

return_portrait: false, usato nel client qui sopra, esclude images.main_photo, il ritaglio della foto del titolare: una cosa in meno da trattare con cura nei tuoi log e nel tuo storage. Il ritaglio dell'intera pagina viene comunque restituito.

Passare in produzione

Sostituisci sk_sandbox_public con la tua chiave tramite DOC_CHEAP_API_KEY e non cambia nient'altro: stesso endpoint, stessa forma. Una chiave registrata consente 60 richieste al minuto. I crediti si acquistano in criptovaluta (BTC, ETH, TRX o USDT su Ethereum o Tron) con un minimo di $1; oggi non esiste un pagamento con carta, ed è bene saperlo prima di preparare una demo per un team finance. La pagina dei prezzi riporta il prezzo unico e la regola dell'addebito solo in caso di successo, e la pagina dell'API OCR gratuita per passaporti mostra la prima chiamata senza registrazione in un solo comando curl.

Se lo provi e qualcosa nella forma della risposta è scomodo da gestire in Node, scrivi a admin@doc.cheap. È proprio il feedback che cerchiamo.

Il client qui sopra è stato eseguito sulla sandbox reale e il suo output è riportato così come è stato stampato; ogni affermazione su doc.cheap è stata verificata sul suo codice.