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:
documenteholdersono i valori già consolidati. Le date sono in ISO 8601, i paesi in ISO 3166-1 alpha-3. Ogni gruppo ènullper 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 diconfidence(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.statusvalepassed,failedoabsent, emrz.textè la zona grezza, così puoi ricalcolare tu le cifre di controllo.authenticity.overallvalenot_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 riceve409 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:
fetchnon lancia eccezioni su un 4xx o un 5xx. Le lancia solo in caso di errore di rete, quindi il client controllaresponse.oke legge in ogni caso il body JSON dell'errore.- Diramati su
error.code, mai sullo status HTTP. SuPOST /v1/scanstre codici di idempotenza condividono il409e tre diversi disservizi condividono il503, 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 ilrequest_id: è ciò che serve al supporto. La tabella completa è in handle errors (in inglese). - Non tutto è ripetibile.
validation_failed,payload_too_large,unauthorizedeinsufficient_creditsrichiedono una correzione, non un ciclo. In sandbox incontrerai ancheregistration_required(la quota gratuita è esaurita; aspettare non la ricarica) edocument_repeated(la stessa immagine inviata troppe volte in un'ora). - Non registrare nei log il body della richiesta. È un documento d'identità.
referenceti viene restituito comemeta.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 stessoreference.
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.