Bir pasaport fotoğrafını yapılandırılmış veriye çevirmek, "bir OCR API'si çağır" gibi görünen ama üretime çıktığı anda üç yeni soruya dönüşen işlerden biridir. Fotoğraf bulanıksa ne olur? İstek zaman aşımına uğrar ve yeniden denerseniz ne olur: iki kez mi ödediniz? Peki kendi kayıtlarınız hangi çağrıların para tuttuğunu nereden bilecek?
Bu eğitim, bu üç soruyu SDK kullanmadan, düz Node.js 18+ ve yerleşik fetch ile yanıtlıyor. doc.cheap kullanıyor ve burası doc.cheap'in kendi blogu; ürün tercihlerine bu yüzden uygun bir şüpheyle yaklaşın. Kalıplar (idempotency anahtarları, sabit bir hata koduna göre dallanma, çağrı başına bir maliyet bayrağı tutma) ücretli her API için geçerlidir.
Hesapsız ilk çağrı
API'nin belgelerinde basılı, herkese açık bir sandbox anahtarı var: sk_sandbox_public. Ücretli bir anahtarla aynı tanımayı çalıştırır ve kayıt gerektirmez: IP adresi başına toplamda 10 ücretsiz tanınmış belge ve yanıtı ne olursa olsun saatte en fazla 10 istek. Aşağıdaki her şeyi denemek için bu yeterli. Sonradan kayıt olmak, kart gerektirmeden 20 ücretsiz kredi ekler.
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);
Bunu first.mjs olarak kaydedin ve node first.mjs ile çalıştırın. Çağrı eşzamanlıdır: iş kimliği yok, yoklama (polling) yok, webhook yok. Tanıma isteğin içinde gerçekleşir ve alanlar yanıtla birlikte döner.
Test için kendi pasaportunuzu değil, sentetik bir örnek belge kullanın. Birçok belge düzenleyici kurum örnek sayfalar yayımlar ve ICAO'nun hayali "Utopia" belgeleri tam da bunun için vardır.
Görüntü boyutu sandığınızdan daha önemlidir. Kameradan doğrudan çıkan bir telefon fotoğrafı birkaç megabayt olabilir ve base64 bunu yaklaşık üçte bir büyütür. Belgeler, uzun kenarda yaklaşık 1600 px ve JPEG kalitesi 85 önerir. İlk çağrı yavaş geliyorsa, birinin sunucularını suçlamadan önce meta.timing.upload_ms değerine bakın.
Ne döner
Tek bir JSON şekli, sekiz grup ve her anahtar her zaman mevcut. Bilinmeyen bir değer null olur, asla eksik bir anahtar olmaz. Kısaltılmış, tanınmış bir yanıt şöyle görünür:
{
"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": [] }
}
(Belge sahibi, belgelerdeki uydurma bir örnektir; fields ve images kısaltılmıştır.) Bilmeye değer kısımlar:
documentveholderderlenmiş değerlerdir. Tarihler ISO 8601, ülkeler ISO 3166-1 alpha-3 biçimindedir. Tarama bir grup için hiçbir şey üretmediğinde grubun tamamınullolur; yukarıdaki kod parçasının?.kullanmasının nedeni budur.fields[]her bir okumayı, kendiconfidencebandıyla (high,medium,low; sahte hassasiyetli bir yüzde değil) içerir. Yunanca basılmış bir ad iki kez döner: bir kez Yunanca, bir kez transliterasyonla; Yunanca okuma kendi diliyle etiketlenir.mrz.statusdeğeripassed,failedya daabsentolur;mrz.textise ham bölgedir, böylece kontrol hanelerini kendiniz yeniden hesaplayabilirsiniz.authenticity.overalldeğerinot_checked'tir. Bu tanımadır, sahtecilik tespiti değil. Uyum (compliance) ekibinize bunu kimlik doğrulama diye sunmayın.
Bulanık fotoğraf bir istisna değildir
İçselleştirilmesi en yararlı tek şey: okunamayan bir fotoğraf 200 döner, hata değildir. meta.status tam olarak beş dizeden biridir:
status |
Anlamı | Ne yapmalı |
|---|---|---|
recognized |
Başarıyla okundu | Veriyi kullanın |
no_document_found |
Karede belgeye benzer bir şey yok | Kullanıcıdan yeniden çerçevelemesini isteyin |
unreadable |
Belge var ama kullanılabilir metin yok | Daha iyi ışık, odak, açı |
unsupported_document |
Bulundu ama API'nin bildiği bir tür değil | Durun, yeniden deneme aynı sonucu okur |
rejected |
Hizmet tarafında başarısız oldu | Bir kez yeniden deneyin |
Yani kodunuz iki kez dallanır: hatalar için HTTP durumuna, sonuçlar için meta.status değerine göre. no_document_found durumunu istisna sayarsanız asla okunmayacak bir fotoğrafı yeniden denersiniz. Başarı sayarsanız alanı olmayan bir belge kaydedersiniz.
Bulanık fotoğrafın bedelini kim öder
Her yanıt meta.billed taşır. Canlı bir anahtarda bu değer, belge türü belirlendiğinde ve veri gerçekten çıkarıldığında true olur: kontrol haneleri tutan bir MRZ, basılı bölgeden en az beş alan ya da doğru çözülmüş bir barkod. Geri kalan her şey (belge bulunamadı, okunamayan görüntü, desteklenmeyen tür, iç hata, zaman aşımı) ücretsizdir. Ücretlendirilen bir belgenin fiyatı, her hacimde sabit $0.01'dir.
İki sandbox anahtarının hiçbirinde hiçbir ücret alınmaz. Herkese açık anahtar sk_sandbox_public ile billed yine de aynı taramanın canlı bir anahtarda ücretlendirilip ücretlendirilmeyeceğini söyler; bu anahtarı bayrağı test etmek için kullanışlı kılan da budur. Bir hesabın kendi sandbox anahtarı görüntünüzü okumaz: her çağrıya yerleşik tek bir örnekle yanıt verir, dolayısıyla orada billed o örneği anlatır.
Bayrak çağrı başınadır, bu yüzden onu sonucun yanında saklayın. Canlı bir anahtar için bir takvim ayında (UTC) kendi saydığınız billed: true satır sayısı, GET /v1/usage uç noktasının o ay için scans.billed olarak bildirdiği sayıyla aynıdır; ayrıca mutabakat adımı gerekmez.
Çift ücrete yol açamayan yeniden denemeler
Tehlikeli yeniden deneme, zaman aşımından sonraki denemedir: ilk isteğin ulaşıp ulaşmadığını bilmezsiniz. API, POST /v1/scans üzerinde bir Idempotency-Key başlığı kabul eder (1 ile 255 karakter arası). Canlı bir anahtarda, aynı anahtar ve aynı gövdeyle yapılan bir yeniden deneme, tanımayı yeniden çalıştırıp tekrar ücret almak yerine saklanan ilk sonucu (görüntü kırpmaları olmadan) döndürür.
Başvuru belgelerinde, istemciyi yazma biçiminizi değiştiren üç ayrıntı var:
- Anahtar, gövdenin tamamının parmak iziyle birlikte eşleştirilir. Aynı anahtar ama farklı görüntü, sessiz bir tekrar değil,
409 idempotency_conflictdöndürür. - Bir anahtar, sonuç saklandığı sürece hatırlanır.
retain_hours: 0(hiçbir şey saklama) gönderirseniz anahtar yine de 24 saat hatırlanır: bu sürede aynı anahtarla yapılan bir yeniden deneme409 idempotency_replay_unavailablealır; tarama iki kez çalıştırılmaz ama geri verilecek bir şey de yoktur. Bu 24 saat geçtikten sonra aynı anahtarla yapılan yeniden deneme yeni bir taramadır. Sıfır saklama ile tekrarlanabilir yeniden denemeler birbirini dışlar; her kullanım senaryosu için birini seçin. - Sandbox anahtarında başlık kabul edilir ama hiçbir etkisi olmaz, çünkü orada hiçbir şey ücretlendirilmez. Kod yolunu yine de yazıp test edebilirsiniz.
İstemcinin tamamı
Bu, bir servise koyacağımız sürüm. Her görüntü için bir anahtar üretir ve her yeniden denemede onu yeniden kullanır, yalnızca belgelerin yeniden denenebilir dediği hata kodlarını yeniden dener, Retry-After değerine bir dakikaya kadar uyar ve daha uzun beklemek yerine vazgeçer, ayrıca her alanı elinizde tutmanız için ham taramayı döndürür.
// 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(); // bu görüntü için tek anahtar, her yeniden denemede yeniden kullanılır
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;
}
// Aradaki bir proxy HTML ile yanıt verebilir; bunu bir ağ hatası gibi ele alın.
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);
// Sandbox'ın saatlik limiti bir saate kadar beklemeyi ister; tek bir çağrının
// içinde bu kadar beklemek kimsenin işine yaramaz, bu yüzden bir dakikayı aşan her şey hatadır.
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;
}
}
node scan.mjs specimen.jpg ile çalıştırın. 24 Eylül 2026'da herkese açık sandbox anahtarıyla, üretilmiş bir test pasaportu için yazdırdığı çıktı aşağıda. Belgeden okunan değerler … ile değiştirildi; geri kalan her şey yazdırıldığı gibidir:
{
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 }
Sandbox anahtarındaki billed: true bir ücret değildir: bu taramanın canlı bir anahtarda bir krediye mal olacağını söyler.
Açıklamaya değer birkaç tercih:
fetch, 4xx ya da 5xx durumunda hata fırlatmaz. Yalnızca ağ hatasında fırlatır; bu yüzden istemciresponse.okdeğerini kontrol eder ve her iki durumda da JSON hata gövdesini okur.- HTTP durumuna göre değil, her zaman
error.codedeğerine göre dallanın.POST /v1/scansüzerinde üç idempotency kodu aynı409durumunu, üç farklı kesinti aynı503durumunu paylaşır ve bunların farklı ele alınması gerekir. Her hata gövdesi aynı şekle sahiptir:code,message,docs_url,request_id,event_id.request_iddeğerini loglayın; destek ekibinin ihtiyaç duyduğu budur. Tablonun tamamı handle errors rehberinde (İngilizce). - Her şey yeniden denenebilir değildir.
validation_failed,payload_too_large,unauthorizedveinsufficient_creditsbir döngü değil, bir düzeltme gerektirir. Sandbox'ta ayrıcaregistration_required(ücretsiz hak bitti; beklemek onu yenilemez) vedocument_repeated(aynı görüntü bir saat içinde çok sık gönderildi) ile karşılaşırsınız. - İstek gövdesini loglamayın. O bir kimlik belgesidir.
referencedeğerimeta.referenceolarak geri yansıtılır (en fazla 128 karakter); bir taramayı kendi siparişinize ya da kullanıcı kaydınıza bağlamanın kolay yolu budur. Bir kimlik kartının iki yüzü iki çağrıdır; ikisine de aynıreferencedeğerini verin.
Daha az veri tutmak
Yüklenen görüntü istek süresince bellekte tutulur ve asla kalıcı depolamaya yazılmaz. Sonuç ise başka bir konudur: GET /v1/scans/{id} ile geri okuyabilmeniz için, sizin seçtiğiniz bir süre boyunca saklanır. Hesap ayarı 24 saat, 7 gün, 30 gün ya da bir yıl sunar ve yeni bir hesabın varsayılanı bir yıldır. İstek başına options.retain_hours 0 ile 8760 arasında değer alır; 0 hiçbir satır yazmaz. JSON'a yalnızca bir kez ihtiyacınız varsa retain_hours: 0 gönderin ve yukarıdaki idempotency ödünleşimini kabul edin. İşleme AB'de gerçekleşir.
Yukarıdaki istemcide kullanılan return_portrait: false, belge sahibinin fotoğrafının kırpması olan images.main_photo alanını dışarıda bırakır; bu da kendi loglarınızda ve depolamanızda dikkatle ele almanız gereken bir şeyin eksilmesi demektir. Sayfanın tamamının kırpması yine de döner.
Canlıya geçiş
sk_sandbox_public yerine DOC_CHEAP_API_KEY üzerinden kendi anahtarınızı koyun, başka hiçbir şey değişmez: aynı uç nokta, aynı şekil. Kayıtlı bir anahtar dakikada 60 isteğe izin verir. Krediler kripto parayla (BTC, ETH, TRX ya da Ethereum veya Tron üzerinde USDT) en az $1 ile satın alınır; bugün kartla ödeme yok, finans ekibine bir demo planlamadan önce bunu bilmekte fayda var. Fiyatlandırma sayfasında tek fiyat ve yalnızca başarıda ücretlendirme kuralı, ücretsiz pasaport OCR API sayfasında ise kayıtsız ilk çağrı tek bir curl olarak yer alıyor.
Denerseniz ve yanıt şeklinde Node'da çalışırken zahmetli gelen bir şey olursa admin@doc.cheap adresine yazın. Aradığımız geri bildirim tam olarak bu.
Yukarıdaki istemci canlı sandbox'a karşı çalıştırıldı ve çıktısı yazdırıldığı gibi aktarıldı; doc.cheap hakkındaki her ifade onun koduyla karşılaştırılarak kontrol edildi.