Transformar a foto de um passaporte em dados estruturados é um daqueles trabalhos que parecem "chamar uma API de OCR" e viram três perguntas no momento em que vão para produção. O que acontece quando a foto está borrada? O que acontece quando a requisição estoura o tempo limite e você tenta de novo: você acabou de pagar duas vezes? E como os seus próprios registros sabem quais chamadas custaram dinheiro?

Este tutorial responde às três com Node.js 18+ puro e o fetch nativo, sem SDK. Ele usa o doc.cheap, e este é o blog do próprio doc.cheap, então encare as escolhas de produto com a desconfiança adequada. Os padrões (chaves de idempotência, desvio por um código de erro estável, um flag de custo por chamada) valem para qualquer API paga.

A primeira chamada, sem conta

A API tem uma chave de sandbox pública impressa na documentação, sk_sandbox_public. Ela roda o mesmo reconhecimento de uma chave paga e não exige cadastro: 10 documentos reconhecidos grátis por endereço IP no total, e no máximo 10 requisições por hora, seja qual for a resposta. É o suficiente para testar tudo o que vem a seguir. Ao se cadastrar depois, você ganha mais 20 créditos grátis, sem cartão.

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

Salve como first.mjs e rode node first.mjs. A chamada é síncrona: sem id de job, sem polling, sem webhook. O reconhecimento acontece dentro da requisição e os campos voltam na resposta.

Para testar, use um espécime sintético, não o seu próprio passaporte. Muitos emissores publicam páginas de espécime, e os documentos fictícios de "Utopia" da OACI existem exatamente para isso.

O tamanho da imagem importa mais do que parece. Uma foto de celular recém-saída da câmera pode ter vários megabytes, e o base64 aumenta isso em cerca de um terço. A documentação recomenda cerca de 1600 px no lado maior, com qualidade JPEG 85. Se a primeira chamada parecer lenta, olhe meta.timing.upload_ms antes de culpar o servidor de alguém.

O que volta

Um único formato de JSON, oito grupos, e todas as chaves estão sempre presentes. Um valor desconhecido é null, nunca uma chave ausente. Uma resposta reconhecida, resumida, fica assim:

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

(O titular é um espécime inventado da documentação; fields e images foram cortados.) O que vale saber:

  • document e holder são os valores já consolidados. Datas em ISO 8601, países em ISO 3166-1 alfa-3. Cada grupo inteiro é null quando a leitura não produziu nada para ele, e é por isso que o trecho acima usa ?..
  • fields[] traz cada leitura individual, com sua própria faixa de confidence (high, medium, low, e não uma porcentagem de falsa precisão). Um nome impresso em grego volta duas vezes, uma em grego e outra transliterada, e a leitura em grego vem marcada com o idioma.
  • mrz.status é passed, failed ou absent, e mrz.text é a zona bruta, para você recalcular os dígitos verificadores por conta própria.
  • authenticity.overall é not_checked. Isto é reconhecimento, não detecção de falsificação. Não venda isso ao seu time de compliance como verificação de identidade.

Foto borrada não é exceção

A coisa mais útil para internalizar: uma foto que não pôde ser lida é um 200, não um erro. meta.status é exatamente uma de cinco strings:

status Significado O que fazer
recognized Lido com sucesso Use os dados
no_document_found Nada com cara de documento no quadro Peça ao usuário para reenquadrar
unreadable Há um documento, mas nenhum texto aproveitável Mais luz, foco, outro ângulo
unsupported_document Encontrado, mas não é um tipo que a API conhece Pare, uma nova tentativa lê a mesma coisa
rejected Falhou do lado do serviço Tente de novo uma vez

Então o seu código tem dois desvios: pelo status HTTP para erros e por meta.status para resultados. Trate no_document_found como exceção e você vai ficar repetindo uma foto que nunca será lida. Trate como sucesso e você vai guardar um documento sem nenhum campo.

Quem paga pela foto borrada

Toda resposta traz meta.billed. Numa chave de produção ele é true quando o tipo do documento foi determinado e dados foram de fato extraídos: uma MRZ cujos dígitos verificadores batem, pelo menos cinco campos da zona impressa ou um código de barras decodificado corretamente. Todo o resto (nenhum documento encontrado, imagem ilegível, tipo não suportado, erro interno, timeout) não custa nada. O preço de um documento cobrado é $0.01, fixo, em qualquer volume.

Em qualquer uma das chaves de sandbox nada é cobrado. Na chave pública, sk_sandbox_public, billed ainda diz se a mesma leitura teria sido cobrada numa chave de produção, e é isso que a torna útil para testar o flag. A chave de sandbox da própria conta não lê a sua imagem: ela responde a toda chamada com um único espécime embutido, então ali billed descreve esse espécime.

O flag é por chamada, então guarde-o junto com o resultado. Numa chave de produção, a sua própria contagem de linhas com billed: true num mês do calendário (UTC) é o mesmo número que GET /v1/usage informa como scans.billed para aquele mês, sem nenhuma etapa de conciliação.

Retentativas que não cobram duas vezes

A retentativa perigosa é a que vem depois de um timeout: você não sabe se a primeira requisição chegou. A API aceita um cabeçalho Idempotency-Key em POST /v1/scans (de 1 a 255 caracteres). Numa chave de produção, uma retentativa com a mesma chave e o mesmo corpo devolve o primeiro resultado armazenado (sem os recortes de imagem), em vez de rodar o reconhecimento e cobrar de novo.

Três detalhes da referência que mudam o jeito de escrever o cliente:

  • A chave é comparada junto com uma impressão digital (fingerprint) do corpo inteiro. Mesma chave com outra imagem dá 409 idempotency_conflict, e não uma repetição silenciosa.
  • Uma chave é lembrada enquanto o resultado estiver armazenado. Se você envia retain_hours: 0 (não guardar nada), a chave ainda é lembrada por 24 horas: uma retentativa com ela nesse período recebe 409 idempotency_replay_unavailable, então a leitura não roda duas vezes, mas não há nada para devolver. Passadas essas 24 horas, uma retentativa com a mesma chave é uma nova leitura. Retenção zero e retentativas repetíveis são mutuamente exclusivas, então escolha uma por caso de uso.
  • Na chave de sandbox o cabeçalho é aceito, mas não tem efeito, porque lá nada é cobrado. Você ainda pode escrever e testar esse caminho do código.

O cliente completo

Esta é a versão que colocaríamos num serviço. Ela gera uma chave por imagem e a reutiliza em cada retentativa, tenta de novo só nos códigos de erro que a documentação diz serem repetíveis, respeita Retry-After até um minuto e desiste em vez de esperar mais, e devolve a leitura bruta para você não perder nenhum 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(); // uma chave para esta imagem, reutilizada em cada retentativa

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

    // Um proxy no caminho pode responder com HTML; trate isso como erro de rede.
    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);
    // O limite por hora do sandbox pede até uma hora de espera; esperar tanto
    // dentro de uma chamada não ajuda ninguém, então mais de um minuto é erro.
    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;
  }
}

Rode com node scan.mjs specimen.jpg. Isto é o que ele imprimiu para um passaporte de teste gerado, na chave pública de sandbox, em 24 de setembro de 2026. Os valores lidos do documento foram trocados por ; todo o resto está exatamente como foi impresso:

{
  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 numa chave de sandbox não é uma cobrança: indica que essa leitura teria custado um crédito numa chave de produção.

Algumas escolhas que merecem explicação:

  • fetch não lança exceção em 4xx ou 5xx. Ele só lança em falha de rede, então o cliente verifica response.ok e lê o corpo de erro em JSON de qualquer forma.
  • Desvie por error.code, nunca pelo status HTTP. Em POST /v1/scans, três códigos de idempotência compartilham o 409 e três indisponibilidades diferentes compartilham o 503, e cada um pede um tratamento diferente. Todo corpo de erro tem o mesmo formato: code, message, docs_url, request_id, event_id. Registre o request_id no log; é o que o suporte precisa. A tabela completa está no guia handle errors da documentação (em inglês).
  • Nem tudo pode ser repetido. validation_failed, payload_too_large, unauthorized e insufficient_credits pedem uma correção, não um loop. No sandbox você também vai encontrar registration_required (a cota grátis acabou; esperar não a recarrega) e document_repeated (a mesma imagem enviada vezes demais em uma hora).
  • Não registre o corpo da requisição no log. É um documento de identidade.
  • reference volta ecoado como meta.reference (até 128 caracteres), que é o jeito fácil de ligar uma leitura ao seu próprio pedido ou cadastro de usuário. Os dois lados de uma carteira de identidade são duas chamadas; dê a elas a mesma reference.

Guardar menos dados

A imagem enviada fica na memória durante a requisição e nunca é gravada em armazenamento persistente. O resultado é outra história: ele é guardado para você poder lê-lo de novo com GET /v1/scans/{id}, por um período que você escolhe. A configuração da conta oferece 24 horas, 7 dias, 30 dias ou um ano, e o padrão de uma conta nova é um ano. Por requisição, options.retain_hours aceita de 0 a 8760; 0 não grava linha nenhuma. Se você só precisa do JSON uma vez, envie retain_hours: 0 e aceite a troca com a idempotência descrita acima. O processamento acontece na UE.

return_portrait: false, usado no cliente acima, deixa de fora images.main_photo, o recorte da foto do titular, que é uma coisa a menos para tratar com cuidado nos seus logs e no seu armazenamento. O recorte da página inteira continua vindo.

Indo para produção

Troque sk_sandbox_public pela sua própria chave via DOC_CHEAP_API_KEY e nada mais muda: mesmo endpoint, mesmo formato. Uma chave registrada permite 60 requisições por minuto. Os créditos são comprados com criptomoeda (BTC, ETH, TRX ou USDT na Ethereum ou na Tron), com mínimo de $1; hoje não há pagamento com cartão, o que vale saber antes de planejar uma demo para o time financeiro. Preços traz o preço único e a regra de cobrar só quando dá certo, e a página da API gratuita de OCR de passaporte traz a primeira chamada sem cadastro num único curl.

Se você testar e alguma coisa no formato da resposta for incômoda de usar no Node, escreva para admin@doc.cheap. É exatamente esse retorno que buscamos.

O cliente acima foi executado contra o sandbox real e a saída está colada exatamente como foi impressa; cada afirmação sobre o doc.cheap foi conferida com o código dele.