Membaca foto paspor menjadi data terstruktur adalah salah satu pekerjaan yang tampak seperti sekadar "memanggil API OCR", lalu memunculkan tiga pertanyaan lanjutan begitu berjalan di produksi. Apa yang terjadi jika fotonya buram? Apa yang terjadi jika request kehabisan waktu dan Anda mencoba ulang: apakah Anda baru saja membayar dua kali? Dan bagaimana catatan Anda sendiri tahu panggilan mana yang memakan biaya?

Tutorial ini menjawab ketiganya dengan Node.js 18+ biasa dan fetch bawaan, tanpa SDK. Tutorial ini memakai doc.cheap, dan ini adalah blog doc.cheap sendiri, jadi sikapi pilihan produknya dengan kecurigaan yang wajar. Polanya (kunci idempotensi, percabangan berdasarkan kode galat yang stabil, menyimpan flag biaya per panggilan) berlaku untuk API berbayar mana pun.

Panggilan pertama, tanpa akun

API ini punya kunci sandbox publik yang tercetak di dokumentasinya, sk_sandbox_public. Kunci ini menjalankan pengenalan yang sama dengan kunci berbayar dan tidak memerlukan pendaftaran: total 10 dokumen dikenali gratis per alamat IP, dan paling banyak 10 request per jam, apa pun jawabannya. Itu cukup untuk mencoba semua yang ada di bawah. Mendaftar belakangan menambahkan 20 kredit gratis, tanpa kartu.

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

Simpan sebagai first.mjs dan jalankan node first.mjs. Panggilannya sinkron: tanpa job id, tanpa polling, tanpa webhook. Pengenalan terjadi di dalam request, dan field-fieldnya kembali di respons request itu.

Gunakan spesimen sintetis untuk pengujian, bukan paspor Anda sendiri. Banyak penerbit memublikasikan halaman spesimen, dan dokumen fiktif "Utopia" milik ICAO memang ada untuk tujuan ini.

Ukuran gambar lebih berpengaruh daripada yang Anda kira. Foto ponsel langsung dari kamera bisa berukuran beberapa megabyte, dan base64 menggembungkannya sekitar sepertiga. Dokumentasi menyarankan sekitar 1600 px di sisi terpanjang dengan kualitas JPEG 85. Jika panggilan pertama terasa lambat, lihat meta.timing.upload_ms sebelum menyalahkan server siapa pun.

Apa yang dikembalikan

Satu bentuk JSON, delapan grup, dan setiap kunci selalu ada. Nilai yang tidak diketahui adalah null, tidak pernah berupa kunci yang hilang. Respons yang dikenali, dalam versi ringkas, tampak seperti ini:

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

(Pemegangnya adalah spesimen rekaan dari dokumentasi; fields dan images dipangkas.) Bagian yang perlu diketahui:

  • document dan holder adalah nilai yang sudah dikurasi. Tanggal memakai ISO 8601, negara memakai ISO 3166-1 alpha-3. Setiap grup bernilai null secara keseluruhan jika pindaian tidak menghasilkan apa pun untuknya, dan itulah alasan potongan kode di atas memakai ?..
  • fields[] berisi setiap pembacaan individual, masing-masing dengan pita confidence sendiri (high, medium, low, bukan persentase yang pura-pura presisi). Nama yang tercetak dalam aksara Yunani kembali dua kali, sekali dalam aksara Yunani dan sekali dalam bentuk transliterasi, dan pembacaan berbahasa Yunani ditandai dengan bahasanya.
  • mrz.status bernilai passed, failed atau absent, dan mrz.text adalah zona mentahnya agar Anda bisa menjalankan ulang pemeriksaan digit pemeriksa sendiri.
  • authenticity.overall bernilai not_checked. Ini adalah pengenalan, bukan deteksi pemalsuan. Jangan tawarkan ini kepada tim kepatuhan Anda sebagai verifikasi identitas.

Foto buram bukanlah exception

Satu hal paling berguna untuk dipahami: foto yang tidak bisa dibaca menghasilkan 200, bukan galat. meta.status selalu salah satu dari tepat lima string:

status Arti Apa yang dilakukan
recognized Berhasil dibaca Gunakan datanya
no_document_found Tidak ada yang berbentuk dokumen di bingkai Minta pengguna membingkai ulang
unreadable Ada dokumen, tetapi tidak ada teks yang bisa dipakai Cahaya, fokus, sudut yang lebih baik
unsupported_document Ditemukan, tetapi bukan jenis yang dikenal API Berhenti, percobaan ulang membaca hal yang sama
rejected Gagal di sisi layanan Coba ulang sekali

Jadi kode Anda bercabang dua kali: berdasarkan status HTTP untuk galat, dan berdasarkan meta.status untuk hasil. Perlakukan no_document_found sebagai exception, dan Anda akan mencoba ulang foto yang tidak akan pernah terbaca. Perlakukan sebagai sukses, dan Anda menyimpan dokumen tanpa field.

Siapa yang membayar foto buram

Setiap respons membawa meta.billed. Pada kunci live, nilainya true ketika jenis dokumen berhasil ditentukan dan data benar-benar diekstrak: MRZ yang digit pemeriksanya lolos, setidaknya lima field dari zona cetak, atau barcode yang terdekode dengan benar. Selain itu (tidak ada dokumen, gambar tidak terbaca, jenis tidak didukung, galat internal, timeout) tidak dikenai biaya. Harga dokumen yang ditagih adalah $0.01, tetap, berapa pun volumenya.

Pada kunci sandbox mana pun tidak ada yang ditagih sama sekali. Pada kunci publik, sk_sandbox_public, billed tetap menyatakan apakah pindaian yang sama akan ditagih pada kunci live, dan itulah yang membuatnya berguna untuk menguji flag tersebut. Kunci sandbox milik akun sendiri tidak membaca gambar Anda: kunci itu menjawab setiap panggilan dengan satu spesimen bawaan, jadi di sana billed menggambarkan spesimen itu.

Flag ini berlaku per panggilan, jadi simpan di samping hasilnya. Untuk kunci live, hitungan baris billed: true Anda sendiri dalam satu bulan kalender (UTC) akan sama dengan angka yang dilaporkan GET /v1/usage sebagai scans.billed untuk bulan itu, tanpa langkah rekonsiliasi.

Percobaan ulang yang tidak bisa menagih dua kali

Percobaan ulang yang berbahaya adalah yang dilakukan setelah timeout: Anda tidak tahu apakah request pertama sampai. API menerima header Idempotency-Key pada POST /v1/scans (1 sampai 255 karakter). Pada kunci live, percobaan ulang dengan kunci yang sama dan body yang sama mengembalikan hasil pertama yang tersimpan (tanpa potongan gambar), alih-alih menjalankan pengenalan dan menagih lagi.

Tiga detail dari referensi yang mengubah cara Anda menulis klien:

  • Kunci dicocokkan bersama sidik jari seluruh body. Kunci sama dengan gambar berbeda menghasilkan 409 idempotency_conflict, bukan pemutaran ulang diam-diam.
  • Kunci diingat selama hasilnya disimpan. Jika Anda mengirim retain_hours: 0 (tidak menyimpan apa pun), kunci tetap diingat selama 24 jam: percobaan ulang dengan kunci itu dalam waktu tersebut mendapat 409 idempotency_replay_unavailable, jadi pindaian tidak dijalankan dua kali, tetapi tidak ada yang bisa dikembalikan. Setelah 24 jam itu, percobaan ulang dengan kunci yang sama adalah pindaian baru. Retensi nol dan percobaan ulang yang bisa diputar ulang saling meniadakan, jadi pilih salah satu untuk setiap kasus penggunaan.
  • Pada kunci sandbox, header ini diterima tetapi tidak berpengaruh, karena di sana tidak ada yang ditagih. Anda tetap bisa menulis dan menguji jalur kodenya.

Klien lengkapnya

Inilah versi yang akan kami pasang di sebuah layanan. Klien ini membuat satu kunci per gambar dan memakainya ulang pada setiap percobaan ulang, hanya mencoba ulang kode galat yang menurut dokumentasi bisa dicoba ulang, mematuhi Retry-After hingga satu menit dan menyerah alih-alih menunggu lebih lama, serta mengembalikan pindaian mentah agar Anda menyimpan setiap field.

// 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(); // satu kunci untuk gambar ini, dipakai ulang di setiap percobaan ulang

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

    // Proxy di tengah jalan bisa menjawab dengan HTML; perlakukan seperti galat jaringan.
    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);
    // Batas per jam sandbox bisa meminta tunggu hingga satu jam; menunggu selama itu
    // di dalam satu panggilan tidak membantu siapa pun, jadi lebih dari satu menit dianggap galat.
    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;
  }
}

Jalankan dengan node scan.mjs specimen.jpg. Inilah yang dicetaknya untuk paspor uji hasil generator pada kunci sandbox publik, 24 September 2026. Nilai yang dibaca dari dokumen diganti dengan ; selebihnya persis seperti yang tercetak:

{
  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 pada kunci sandbox bukanlah tagihan: nilai itu menyatakan bahwa pindaian ini akan memakan satu kredit pada kunci live.

Beberapa pilihan yang perlu dijelaskan:

  • fetch tidak melempar exception pada 4xx atau 5xx. Ia hanya melempar saat jaringan gagal, jadi klien memeriksa response.ok dan tetap membaca body galat JSON dalam kedua kasus.
  • Bercabanglah berdasarkan error.code, jangan pernah berdasarkan status HTTP. Pada POST /v1/scans, tiga kode idempotensi sama-sama memakai 409 dan tiga jenis gangguan layanan yang berbeda sama-sama memakai 503, padahal penanganannya berbeda. Setiap body galat punya bentuk yang sama: code, message, docs_url, request_id, event_id. Catat request_id di log; itulah yang dibutuhkan tim dukungan. Tabel lengkapnya ada di menangani galat.
  • Tidak semuanya bisa dicoba ulang. validation_failed, payload_too_large, unauthorized dan insufficient_credits butuh perbaikan, bukan perulangan. Di sandbox Anda juga akan menjumpai registration_required (jatah gratis sudah habis; menunggu tidak akan mengisinya kembali) dan document_repeated (gambar yang sama dikirim terlalu sering dalam satu jam).
  • Jangan catat body request di log. Isinya adalah dokumen identitas.
  • reference dikembalikan sebagai meta.reference (hingga 128 karakter), cara mudah untuk menghubungkan pindaian dengan pesanan atau catatan pengguna Anda sendiri. Dua sisi kartu identitas berarti dua panggilan; beri keduanya reference yang sama.

Menyimpan lebih sedikit data

Gambar yang diunggah disimpan di memori selama request berlangsung dan tidak pernah ditulis ke penyimpanan permanen. Hasilnya lain cerita: hasil disimpan agar Anda bisa membacanya kembali dengan GET /v1/scans/{id}, selama jangka waktu yang Anda pilih. Pengaturan akun menawarkan 24 jam, 7 hari, 30 hari atau satu tahun, dan bawaan untuk akun baru adalah satu tahun. Per request, options.retain_hours menerima 0 hingga 8760; 0 tidak menulis baris apa pun. Jika Anda hanya butuh JSON-nya sekali, kirim retain_hours: 0, dan terima konsekuensi idempotensi di atas. Pemrosesan dilakukan di UE.

return_portrait: false, yang dipakai di klien di atas, menghilangkan images.main_photo, potongan foto pemegang dokumen, sehingga berkurang satu hal yang harus ditangani dengan hati-hati di log dan penyimpanan Anda. Potongan seluruh halaman tetap dikembalikan.

Beralih ke live

Ganti sk_sandbox_public dengan kunci Anda sendiri lewat DOC_CHEAP_API_KEY, dan tidak ada hal lain yang berubah: endpoint sama, bentuk sama. Kunci terdaftar mengizinkan 60 request per menit. Kredit dibeli dengan mata uang kripto (BTC, ETH, TRX, atau USDT di Ethereum atau Tron) dengan minimum $1; saat ini belum ada pembayaran dengan kartu, hal yang perlu Anda ketahui sebelum merencanakan demo untuk tim keuangan. Harga memuat satu-satunya harga dan aturan "ditagih hanya jika berhasil", dan halaman API OCR paspor gratis memuat panggilan pertama tanpa pendaftaran sebagai satu perintah curl.

Jika Anda mencobanya dan ada bagian dari bentuk respons yang merepotkan untuk dipakai di Node, tulis ke admin@doc.cheap. Masukan seperti itulah yang kami cari.

Klien di atas telah dijalankan terhadap sandbox yang sesungguhnya dan keluarannya ditempel persis seperti yang tercetak; setiap pernyataan tentang doc.cheap telah diperiksa terhadap kodenya.