Transformer la photo d'un passeport en données structurées fait partie de ces tâches qui ressemblent à « appeler une API d'OCR » et qui soulèvent trois questions dès qu'elles passent en production. Que se passe-t-il quand la photo est floue ? Que se passe-t-il quand la requête expire et que vous relancez : venez-vous de payer deux fois ? Et comment vos propres enregistrements savent-ils quels appels ont coûté de l'argent ?
Ce tutoriel répond à ces trois questions avec Node.js 18+ et le fetch intégré, sans SDK. Il utilise doc.cheap, et ceci est le blog de doc.cheap : accueillez donc les choix de produit avec la méfiance qui s'impose. Les techniques (clés d'idempotence, branchement sur un code d'erreur stable, indicateur de coût conservé pour chaque appel) valent pour n'importe quelle API payante.
Le premier appel, sans compte
L'API dispose d'une clé sandbox publique imprimée dans sa documentation, sk_sandbox_public. Elle exécute la même reconnaissance qu'une clé payante et ne demande aucune inscription : 10 documents reconnus gratuits par adresse IP au total, et au plus 10 requêtes par heure, quelle que soit leur réponse. C'est assez pour tout essayer ci-dessous. S'inscrire ensuite ajoute 20 crédits gratuits, sans carte.
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);
Enregistrez-le sous first.mjs et lancez node first.mjs. L'appel est synchrone : pas d'identifiant de tâche, pas de polling, pas de webhook. La reconnaissance a lieu pendant la requête et les champs reviennent dans sa réponse.
Pour vos tests, utilisez un spécimen synthétique, pas votre propre passeport. De nombreux émetteurs publient des pages de spécimen, et les documents fictifs « Utopia » de l'OACI existent précisément pour cela.
La taille de l'image compte plus qu'on ne le croit. Une photo de téléphone brute peut peser plusieurs mégaoctets, que le base64 gonfle encore d'environ un tiers. La documentation recommande environ 1600 px sur le grand côté, en JPEG qualité 85. Si un premier appel vous semble lent, regardez meta.timing.upload_ms avant d'accuser les serveurs de qui que ce soit.
Ce qui revient
Une seule forme de JSON, huit groupes, et chaque clé est toujours présente. Une valeur inconnue vaut null, jamais une clé absente. Une réponse reconnue, abrégée, ressemble à ceci :
{
"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": [] }
}
(Le titulaire est un spécimen inventé tiré de la documentation ; fields et images sont tronqués.) Ce qu'il faut savoir :
documentetholdersont les valeurs consolidées. Les dates sont en ISO 8601, les pays en ISO 3166-1 alpha-3. Chaque groupe vautnullen entier quand le scan n'a rien produit pour lui, d'où le?.dans l'extrait ci-dessus.fields[]contient chaque lecture individuelle, avec sa propre tranche deconfidence(high,medium,low, et non un pourcentage faussement précis). Un nom imprimé en grec revient deux fois, une fois en grec et une fois translittéré, et la lecture grecque porte l'indication de sa langue.mrz.statusvautpassed,failedouabsent, etmrz.textest la zone brute, pour que vous puissiez recalculer vous-même les chiffres de contrôle.authenticity.overallvautnot_checked. C'est de la reconnaissance, pas de la détection de faux. Ne la vendez pas à votre équipe conformité comme une vérification d'identité.
Une photo floue n'est pas une exception
Le point le plus utile à retenir : une photo illisible renvoie un 200, pas une erreur. meta.status prend exactement l'une de cinq valeurs :
status |
Signification | Que faire |
|---|---|---|
recognized |
Lecture réussie | Utiliser les données |
no_document_found |
Rien qui ressemble à un document dans le cadre | Demander à l'utilisateur de recadrer |
unreadable |
Un document, mais aucun texte exploitable | Meilleure lumière, mise au point, angle |
unsupported_document |
Trouvé, mais d'un type que l'API ne connaît pas | Arrêter, une relance lira la même chose |
rejected |
Échec côté service | Relancer une fois |
Votre code se ramifie donc deux fois : sur le statut HTTP pour les erreurs, et sur meta.status pour les résultats. Traitez no_document_found comme une exception et vous relancerez une photo qui ne sera jamais lisible. Traitez-le comme un succès et vous enregistrerez un document sans aucun champ.
Qui paie la photo floue
Chaque réponse contient meta.billed. Avec une clé live, il vaut true quand le type de document a été déterminé et que des données ont réellement été extraites : une MRZ dont les chiffres de contrôle sont valides, au moins cinq champs de la zone imprimée, ou un code-barres correctement décodé. Tout le reste (aucun document trouvé, image illisible, type non pris en charge, erreur interne, délai dépassé) ne coûte rien. Le prix d'un document facturé est de $0.01, fixe, quel que soit le volume.
Avec l'une ou l'autre clé sandbox, rien n'est jamais facturé. Avec la clé publique, sk_sandbox_public, billed indique malgré tout si le même scan aurait été facturé avec une clé live, ce qui la rend utile pour tester l'indicateur. La clé sandbox propre à un compte ne lit pas votre image : elle répond à chaque appel avec un unique spécimen intégré, et là billed décrit ce spécimen.
L'indicateur est propre à chaque appel : stockez-le à côté du résultat. Pour une clé live, votre propre décompte des lignes billed: true sur un mois calendaire (UTC) est alors exactement le nombre que GET /v1/usage renvoie comme scans.billed pour ce mois, sans étape de rapprochement.
Des relances qui ne peuvent pas facturer deux fois
La relance dangereuse est celle qui suit un délai dépassé : vous ne savez pas si la première requête est arrivée. L'API accepte un en-tête Idempotency-Key sur POST /v1/scans (de 1 à 255 caractères). Avec une clé live, une relance sous la même clé avec le même corps renvoie le premier résultat stocké (sans les recadrages d'image) au lieu de relancer la reconnaissance et de facturer à nouveau.
Trois détails de la référence qui changent la façon d'écrire le client :
- La clé est comparée avec une empreinte du corps entier. Même clé, image différente donne un
409 idempotency_conflict, pas un rejeu silencieux. - Une clé est mémorisée aussi longtemps que le résultat est conservé. Si vous envoyez
retain_hours: 0(ne rien garder), la clé reste tout de même mémorisée 24 heures : une relance sous cette clé pendant ce délai reçoit409 idempotency_replay_unavailable, donc le scan n'est pas exécuté deux fois, mais il n'y a rien à renvoyer. Passées ces 24 heures, une relance sous la même clé est un nouveau scan. Conservation nulle et relances rejouables s'excluent mutuellement : choisissez l'une ou l'autre selon le cas d'usage. - Avec la clé sandbox, l'en-tête est accepté mais sans effet, puisque rien n'y est facturé. Vous pouvez tout de même écrire et tester ce chemin de code.
Le client complet
Voici la version que nous mettrions dans un service. Elle génère une clé par image et la réutilise à chaque relance, ne relance que les codes d'erreur que la documentation déclare relançables, respecte Retry-After jusqu'à une minute et abandonne plutôt que d'attendre davantage, et renvoie le scan brut pour que vous gardiez tous les champs.
// 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(); // une clé pour cette image, réutilisée à chaque relance
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 intermédiaire peut répondre en HTML ; on le traite comme une erreur réseau.
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);
// La limite horaire de la sandbox demande d'attendre jusqu'à une heure ; attendre
// aussi longtemps dans un seul appel n'aide personne, donc au-delà d'une minute, c'est une erreur.
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;
}
}
Lancez-le avec node scan.mjs specimen.jpg. Voici ce qu'il a affiché pour un passeport de test généré, avec la clé sandbox publique, le 24 septembre 2026. Les valeurs lues sur le document sont remplacées par … ; tout le reste est exactement tel qu'il s'est affiché :
{
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 avec une clé sandbox n'est pas une facturation : cela indique que ce scan aurait coûté un crédit avec une clé live.
Quelques choix qui méritent une explication :
fetchne lève pas d'exception sur un 4xx ou un 5xx. Il n'en lève qu'en cas de panne réseau : le client vérifie doncresponse.oket lit le corps d'erreur JSON dans tous les cas.- Branchez sur
error.code, jamais sur le statut HTTP. SurPOST /v1/scans, trois codes d'idempotence partagent le409et trois indisponibilités différentes partagent le503, et chacun demande un traitement différent. Tous les corps d'erreur ont la même forme :code,message,docs_url,request_id,event_id. Journalisez lerequest_id: c'est ce dont le support a besoin. Le tableau complet se trouve dans le guide handle errors (en anglais). - Tout n'est pas relançable.
validation_failed,payload_too_large,unauthorizedetinsufficient_creditsappellent une correction, pas une boucle. Sur la sandbox, vous croiserez aussiregistration_required(le quota gratuit est épuisé ; attendre ne le recharge pas) etdocument_repeated(la même image envoyée trop souvent en une heure). - Ne journalisez pas le corps de la requête. C'est un document d'identité.
referenceest renvoyé tel quel dansmeta.reference(jusqu'à 128 caractères) : c'est le moyen simple de rattacher un scan à votre propre commande ou fiche utilisateur. Les deux faces d'une carte d'identité font deux appels ; donnez-leur la mêmereference.
Conserver moins de données
L'image envoyée est gardée en mémoire le temps de la requête et n'est jamais écrite sur un stockage durable. Le résultat, c'est autre chose : il est conservé pour que vous puissiez le relire avec GET /v1/scans/{id}, pendant une durée que vous choisissez. Le réglage du compte propose 24 heures, 7 jours, 30 jours ou un an, et la valeur par défaut d'un nouveau compte est d'un an. Par requête, options.retain_hours accepte de 0 à 8760 ; 0 n'écrit aucune ligne. Si vous n'avez besoin du JSON qu'une fois, envoyez retain_hours: 0 et acceptez le compromis sur l'idempotence décrit plus haut. Le traitement a lieu dans l'UE.
return_portrait: false, utilisé dans le client ci-dessus, omet images.main_photo, le recadrage de la photo du titulaire : une chose de moins à manipuler avec précaution dans vos propres journaux et votre stockage. Le recadrage de la page entière, lui, revient toujours.
Passer en production
Remplacez sk_sandbox_public par votre propre clé via DOC_CHEAP_API_KEY, et rien d'autre ne change : même endpoint, même forme. Une clé enregistrée autorise 60 requêtes par minute. Les crédits s'achètent en cryptomonnaie (BTC, ETH, TRX, ou USDT sur Ethereum ou Tron) avec un minimum de $1 ; il n'y a pas de paiement par carte aujourd'hui, ce qu'il vaut mieux savoir avant de préparer une démo pour une équipe finance. La page Tarifs présente le prix unique et la règle de facturation au seul succès, et la page de l'API gratuite d'OCR de passeport propose le premier appel sans inscription en une seule commande curl.
Si vous l'essayez et que quelque chose dans la forme de la réponse est malcommode à manipuler en Node, écrivez à admin@doc.cheap. C'est exactement ce retour que nous cherchons.
Le client ci-dessus a été exécuté contre la vraie sandbox et sa sortie est reproduite telle qu'elle s'est affichée ; chaque affirmation sur doc.cheap a été vérifiée dans son code.