Convertir la foto de un pasaporte en datos estructurados es uno de esos trabajos que parecen “llamar a una API de OCR” y que se transforman en tres preguntas en cuanto llegan a producción. ¿Qué pasa cuando la foto está borrosa? ¿Qué pasa cuando la petición agota el tiempo de espera y reintentas: acabas de pagar dos veces? ¿Y cómo saben tus propios registros qué llamadas cuestan dinero?
Este tutorial responde a esas tres preguntas con Node.js 18+ y el fetch integrado, sin SDK. Usa doc.cheap, y este es el blog de doc.cheap, así que mira las decisiones de producto con la desconfianza que corresponde. Los patrones (claves de idempotencia, ramificar según un código de error estable, guardar un indicador de coste por llamada) valen para cualquier API de pago.
La primera llamada, sin cuenta
La API tiene una clave de sandbox pública publicada en su documentación, sk_sandbox_public. Ejecuta el mismo reconocimiento que una clave de pago y no requiere registro: 10 documentos reconocidos gratis por dirección IP en total, y como máximo 10 peticiones por hora, sea cual sea su respuesta. Basta para probar todo lo que sigue. Si te registras después, recibes 20 créditos gratis, sin tarjeta.
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);
Guárdalo como first.mjs y ejecuta node first.mjs. La llamada es síncrona: sin id de tarea, sin sondeo, sin webhook. El reconocimiento ocurre dentro de la petición y los campos vuelven en su respuesta.
Para las pruebas, usa un espécimen sintético, no tu propio pasaporte. Muchos emisores publican páginas de espécimen, y los documentos ficticios de “Utopía” de la OACI existen precisamente para esto.
El tamaño de la imagen importa más de lo que parece. Una foto de móvil recién salida de la cámara puede ocupar varios megabytes, y base64 la infla aproximadamente un tercio. La documentación recomienda unos 1600 px en el lado largo con calidad JPEG 85. Si una primera llamada te parece lenta, mira meta.timing.upload_ms antes de culpar a los servidores de nadie.
Qué recibes
Una sola forma de JSON, ocho grupos, y todas las claves están siempre presentes. Un valor desconocido es null, nunca una clave ausente. Una respuesta reconocida, abreviada, tiene este aspecto:
{
"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": [] }
}
(El titular es un espécimen inventado de la documentación; fields e images están recortados). Lo que conviene saber:
documentyholderson los valores depurados. Las fechas van en ISO 8601 y los países en ISO 3166-1 alfa-3. Cada grupo esnullentero cuando el escaneo no produjo nada para él, y por eso el fragmento de arriba usa?..fields[]es cada lectura individual, con su propia banda deconfidence(high,medium,low, no un porcentaje de falsa precisión). Un nombre impreso en griego vuelve dos veces, una en griego y otra transliterado, y la lectura en griego lleva la etiqueta de su idioma.mrz.statusespassed,failedoabsent, ymrz.textes la zona en bruto para que puedas recalcular tú mismo los dígitos de control.authenticity.overallesnot_checked. Esto es reconocimiento, no detección de falsificaciones. No se lo presentes a tu equipo de cumplimiento como verificación de identidad.
Una foto borrosa no es una excepción
Lo más útil que puedes interiorizar: una foto que no se pudo leer es un 200, no un error. meta.status es exactamente una de estas cinco cadenas:
status |
Significado | Qué hacer |
|---|---|---|
recognized |
Leído correctamente | Usa los datos |
no_document_found |
Nada con forma de documento en el encuadre | Pide al usuario que vuelva a encuadrar |
unreadable |
Hay un documento, pero sin texto utilizable | Mejor luz, enfoque, ángulo |
unsupported_document |
Encontrado, pero de un tipo que la API no conoce | Detente: un reintento lee lo mismo |
rejected |
Falló en el lado del servicio | Reintenta una vez |
Así que tu código ramifica dos veces: por el estado HTTP para los errores y por meta.status para los resultados. Si tratas no_document_found como una excepción, reintentarás una foto que nunca se podrá leer. Si lo tratas como un éxito, guardarás un documento sin campos.
Quién paga la foto borrosa
Cada respuesta lleva meta.billed. Con una clave de producción (live) es true cuando se determinó el tipo de documento y realmente se extrajeron datos: una MRZ cuyos dígitos de control son correctos, al menos cinco campos de la zona impresa o un código de barras decodificado correctamente. Todo lo demás (ningún documento encontrado, imagen ilegible, tipo no soportado, error interno, tiempo de espera agotado) no cuesta nada. El precio de un documento facturado es $0.01, fijo, a cualquier volumen.
Con cualquiera de las dos claves de sandbox no se cobra nada. Con la clave pública, sk_sandbox_public, billed sigue indicando si el mismo escaneo se habría facturado con una clave live, y eso es lo que la hace útil para probar el indicador. La clave de sandbox propia de una cuenta no lee tu imagen: responde a cada llamada con un único espécimen incorporado, así que allí billed describe ese espécimen.
El indicador es por llamada, así que guárdalo junto al resultado. Con una clave live, tu propio recuento de filas con billed: true en un mes natural (UTC) es entonces el mismo número que GET /v1/usage devuelve como scans.billed para ese mes, sin ningún paso de conciliación.
Reintentos que no pueden cobrar dos veces
El reintento peligroso es el que sigue a un tiempo de espera agotado: no sabes si la primera petición llegó. La API acepta una cabecera Idempotency-Key en POST /v1/scans (de 1 a 255 caracteres). Con una clave live, un reintento con la misma clave y el mismo cuerpo devuelve el primer resultado guardado (sin los recortes de imagen) en lugar de volver a ejecutar el reconocimiento y cobrar otra vez.
Tres detalles de la referencia que cambian cómo escribes el cliente:
- La clave se compara junto con una huella de todo el cuerpo. La misma clave con otra imagen es un
409 idempotency_conflict, no una repetición silenciosa. - Una clave se recuerda mientras se conserva el resultado. Si envías
retain_hours: 0(no conservar nada), la clave se recuerda igualmente durante 24 horas: un reintento con ella en ese tiempo recibe409 idempotency_replay_unavailable, así que el escaneo no se ejecuta dos veces, pero no hay nada que devolver. Pasadas esas 24 horas, un reintento con la misma clave es un escaneo nuevo. La retención cero y los reintentos repetibles se excluyen mutuamente, así que elige uno por caso de uso. - Con la clave de sandbox la cabecera se acepta pero no tiene efecto, porque allí no se factura nada. Aun así puedes escribir y probar esa parte del código.
El cliente completo
Esta es la versión que pondríamos en un servicio. Genera una clave por imagen y la reutiliza en cada reintento, reintenta solo los códigos de error que la documentación marca como reintentables, respeta Retry-After hasta un minuto y desiste antes que esperar más, y devuelve el escaneo en bruto para que conserves todos los campos.
// 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 clave para esta imagen, reutilizada en cada reintento
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 puede responder con HTML; se trata como un error de red.
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);
// El límite por hora del sandbox pide esperar hasta una hora; esperar tanto
// dentro de una llamada no ayuda a nadie, así que más de un minuto es un 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;
}
}
Ejecútalo con node scan.mjs specimen.jpg. Esto es lo que imprimió para un pasaporte de prueba generado, con la clave pública de sandbox, el 24 de septiembre de 2026. Los valores leídos del documento se han sustituido por …; todo lo demás es exactamente lo que se imprimió:
{
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 clave de sandbox no es un cargo: indica que este escaneo habría costado un crédito con una clave live.
Algunas decisiones que conviene explicar:
fetchno lanza una excepción ante un 4xx o un 5xx. Solo la lanza si falla la red, así que el cliente compruebaresponse.oky lee el cuerpo de error JSON en ambos casos.- Ramifica según
error.code, nunca según el estado HTTP. EnPOST /v1/scans, tres códigos de idempotencia comparten el409y tres caídas distintas comparten el503, y cada uno necesita un tratamiento distinto. Todos los cuerpos de error tienen la misma forma:code,message,docs_url,request_id,event_id. Registra elrequest_id; es lo que necesita el soporte. La tabla completa está en la guía handle errors de la documentación (en inglés). - No todo es reintentable.
validation_failed,payload_too_large,unauthorizedeinsufficient_creditsnecesitan una corrección, no un bucle. En el sandbox también te encontrarás conregistration_required(la cuota gratuita se ha agotado; esperar no la recarga) ydocument_repeated(la misma imagen enviada demasiadas veces en una hora). - No registres el cuerpo de la petición en los logs. Es un documento de identidad.
referencevuelve tal cual comometa.reference(hasta 128 caracteres), que es la forma fácil de enlazar un escaneo con tu propio pedido o registro de usuario. Las dos caras de una tarjeta de identidad son dos llamadas; dales la mismareference.
Guardar menos datos
La imagen subida se mantiene en memoria durante la petición y nunca se escribe en almacenamiento persistente. El resultado es otra cuestión: se conserva para que puedas volver a leerlo con GET /v1/scans/{id}, durante el periodo que tú elijas. El ajuste de la cuenta ofrece 24 horas, 7 días, 30 días o un año, y el valor por defecto de una cuenta nueva es un año. Por petición, options.retain_hours acepta de 0 a 8760; 0 no escribe ninguna fila. Si solo necesitas el JSON una vez, envía retain_hours: 0 y acepta la contrapartida de idempotencia descrita arriba. El procesamiento se realiza en la UE.
return_portrait: false, que usa el cliente de arriba, omite images.main_photo, el recorte de la foto del titular, que es una cosa menos que manejar con cuidado en tus propios logs y en tu almacenamiento. El recorte de la página completa sigue incluyéndose.
Pasar a producción
Cambia sk_sandbox_public por tu propia clave mediante DOC_CHEAP_API_KEY y nada más cambia: el mismo endpoint, la misma forma. Una clave registrada admite 60 peticiones por minuto. Los créditos se compran con criptomonedas (BTC, ETH, TRX, o USDT en Ethereum o Tron) con un mínimo de $1; hoy no hay pago con tarjeta, algo que conviene saber antes de preparar una demo para un equipo de finanzas. En Precios tienes el precio único y la regla de pagar solo lo reconocido, y la página de la API gratuita de OCR de pasaportes tiene la primera llamada sin registro como un único curl.
Si lo pruebas y algo de la forma de la respuesta resulta incómodo de manejar en Node, escribe a admin@doc.cheap. Esa es justo la opinión que buscamos.
El cliente de arriba se ejecutó contra el sandbox real y su salida se reproduce tal como se imprimió; cada afirmación sobre doc.cheap se comprobó contra su código.