पासपोर्ट की फ़ोटो को स्ट्रक्चर्ड डेटा में बदलना उन कामों में से है जो देखने में बस "एक OCR API कॉल करो" लगते हैं, पर प्रोडक्शन में चलते ही तीन नए सवाल खड़े कर देते हैं। फ़ोटो धुंधली हो तो क्या होगा? रिक्वेस्ट टाइमआउट हो जाए और आप रीट्राई करें, तो क्या आपने अभी-अभी दो बार पैसे दे दिए? और आपके अपने रिकॉर्ड कैसे जानेंगे कि किन कॉल पर पैसा लगा?

यह ट्यूटोरियल इन तीनों का जवाब सादे Node.js 18+ और बिल्ट-इन fetch से देता है, बिना किसी SDK के। इसमें doc.cheap इस्तेमाल होता है, और यह doc.cheap का अपना ब्लॉग है, इसलिए प्रोडक्ट से जुड़ी पसंदों को उचित संदेह के साथ पढ़ें। पैटर्न (idempotency key, स्थिर एरर कोड पर ब्रांचिंग, हर कॉल के ख़र्च का फ़्लैग रखना) किसी भी पेड API पर लागू होते हैं।

पहली कॉल, बिना अकाउंट के

API की डॉक्स में एक पब्लिक sandbox key छपी है, sk_sandbox_public। यह पेड key जैसी ही रिकग्निशन चलाती है और इसके लिए साइनअप की ज़रूरत नहीं: हर IP एड्रेस पर कुल 10 पहचाने गए दस्तावेज़ मुफ़्त, और एक घंटे में ज़्यादा से ज़्यादा 10 रिक्वेस्ट, जवाब चाहे जो भी हो। नीचे की हर चीज़ आज़माने के लिए इतना काफ़ी है। बाद में रजिस्टर करने पर 20 मुफ़्त क्रेडिट और मिलते हैं, बिना कार्ड के।

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

इसे first.mjs नाम से सेव करें और node first.mjs चलाएँ। कॉल सिंक्रोनस है: न कोई job id, न पोलिंग, न वेबहुक। रिकग्निशन रिक्वेस्ट के अंदर ही होती है और फ़ील्ड उसी के रिस्पॉन्स में लौट आते हैं।

टेस्टिंग के लिए सिंथेटिक नमूना (specimen) इस्तेमाल करें, अपना पासपोर्ट नहीं। कई जारीकर्ता नमूना पेज प्रकाशित करते हैं, और ICAO के काल्पनिक "Utopia" दस्तावेज़ ठीक इसी काम के लिए हैं।

इमेज का साइज़ आपकी सोच से ज़्यादा मायने रखता है। कैमरे से सीधे ली गई फ़ोन की फ़ोटो कई मेगाबाइट की हो सकती है, और base64 उसे लगभग एक-तिहाई और बड़ा कर देता है। डॉक्स लंबी साइड पर लगभग 1600 px और JPEG क्वालिटी 85 की सलाह देती हैं। अगर पहली कॉल धीमी लगे, तो किसी के सर्वर को दोष देने से पहले meta.timing.upload_ms देखें।

जवाब में क्या आता है

एक ही JSON आकार, आठ ग्रुप, और हर key हमेशा मौजूद रहती है। जो मान पता नहीं है वह null होता है, key कभी ग़ायब नहीं होती। एक पहचाने गए दस्तावेज़ का छोटा किया गया रिस्पॉन्स ऐसा दिखता है:

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

(धारक डॉक्स का एक काल्पनिक नमूना है; fields और images छोटे किए गए हैं।) जानने लायक बातें:

  • document और holder छाँटे और साफ़ किए गए मान हैं। तारीखें ISO 8601 में हैं, देश ISO 3166-1 alpha-3 में। जब स्कैन से किसी ग्रुप के लिए कुछ नहीं मिलता, तो पूरा ग्रुप null होता है, इसीलिए ऊपर के स्निपेट में ?. इस्तेमाल हुआ है।
  • fields[] में हर अलग-अलग रीडिंग है, अपने confidence बैंड (high, medium, low) के साथ, कोई नकली-सटीक प्रतिशत नहीं। ग्रीक में छपा नाम दो बार लौटता है, एक बार ग्रीक में और एक बार लिप्यंतरित रूप में, और ग्रीक रीडिंग पर उसकी भाषा का टैग लगा होता है।
  • mrz.status की वैल्यू passed, failed या absent होती है, और mrz.text कच्चा ज़ोन है, ताकि आप चेक डिजिट ख़ुद दोबारा जाँच सकें।
  • authenticity.overall की वैल्यू not_checked है। यह रिकग्निशन है, जालसाज़ी की पहचान नहीं। इसे अपनी कंप्लायंस टीम को ID वेरिफ़िकेशन बताकर न बेचें।

धुंधली फ़ोटो कोई एक्सेप्शन नहीं है

सबसे काम की बात जो याद रखनी है: जो फ़ोटो पढ़ी नहीं जा सकी, वह 200 है, एरर नहीं। meta.status ठीक इन पाँच स्ट्रिंग में से एक होता है:

status मतलब क्या करें
recognized सफलतापूर्वक पढ़ा गया डेटा इस्तेमाल करें
no_document_found फ़्रेम में दस्तावेज़ जैसा कुछ नहीं यूज़र से दोबारा फ़्रेम करने को कहें
unreadable दस्तावेज़ है, पर काम का टेक्स्ट नहीं बेहतर रोशनी, फ़ोकस, एंगल
unsupported_document मिला, पर ऐसा प्रकार जिसे API नहीं जानता रुक जाएँ, रीट्राई भी वही पढ़ेगा
rejected सर्विस की तरफ़ से फ़ेल हुआ एक बार रीट्राई करें

यानी आपका कोड दो जगह ब्रांच करता है: एरर के लिए HTTP स्टेटस पर, और नतीजों के लिए meta.status पर। no_document_found को एक्सेप्शन मानेंगे, तो आप ऐसी फ़ोटो पर रीट्राई करते रहेंगे जो कभी पढ़ी नहीं जाएगी। इसे सफलता मानेंगे, तो आप बिना किसी फ़ील्ड वाला दस्तावेज़ सेव कर लेंगे।

धुंधली फ़ोटो का पैसा कौन देता है

हर रिस्पॉन्स में meta.billed होता है। लाइव key पर यह true तब होता है जब दस्तावेज़ का प्रकार तय हो गया और सच में डेटा निकाला गया: ऐसा MRZ जिसके चेक डिजिट पास हों, प्रिंटेड ज़ोन के कम से कम पाँच फ़ील्ड, या सही तरह डिकोड हुआ बारकोड। बाकी सब (कोई दस्तावेज़ नहीं मिला, इमेज पढ़ी नहीं जा सकी, असमर्थित प्रकार, इंटरनल एरर, टाइमआउट) मुफ़्त है। बिल होने वाले दस्तावेज़ की कीमत $0.01 है, फ़्लैट, किसी भी वॉल्यूम पर।

दोनों में से किसी भी sandbox key पर कुछ भी चार्ज नहीं होता। सार्वजनिक key sk_sandbox_public पर billed फिर भी बताता है कि यही स्कैन लाइव key पर बिल होता या नहीं, और इसी से वह इस फ़्लैग को टेस्ट करने के लिए काम की बनती है। किसी अकाउंट की अपनी sandbox key आपकी इमेज नहीं पढ़ती: वह हर कॉल का जवाब एक ही बिल्ट-इन नमूने से देती है, इसलिए वहाँ billed उसी नमूने के बारे में बताता है।

फ़्लैग हर कॉल का अलग होता है, इसलिए इसे नतीजे के साथ ही सेव करें। तब लाइव key के लिए किसी कैलेंडर महीने (UTC) में billed: true वाली पंक्तियों की आपकी अपनी गिनती वही संख्या होगी जो GET /v1/usage उस महीने के लिए scans.billed के रूप में बताता है, बिना किसी मिलान के।

ऐसे रीट्राई जो दो बार चार्ज नहीं कर सकते

ख़तरनाक रीट्राई वह है जो टाइमआउट के बाद होता है: आपको पता नहीं कि पहली रिक्वेस्ट पहुँची या नहीं। API POST /v1/scans पर Idempotency-Key हेडर (1 से 255 कैरेक्टर) स्वीकार करता है। लाइव key पर, उसी key और उसी बॉडी के साथ रीट्राई करने पर दोबारा रिकग्निशन चलाने और फिर से चार्ज करने के बजाय पहला सेव किया गया नतीजा (इमेज क्रॉप के बिना) लौटता है।

रेफ़रेंस की तीन बातें जो क्लाइंट लिखने का तरीका बदल देती हैं:

  • key का मिलान पूरी बॉडी के फ़िंगरप्रिंट के साथ होता है। वही key और अलग इमेज 409 idempotency_conflict देती है, चुपचाप पुराना नतीजा नहीं।
  • key तब तक याद रखी जाती है जब तक नतीजा सेव रहता है। अगर आप retain_hours: 0 (कुछ भी न रखें) भेजते हैं, तब भी key 24 घंटे याद रखी जाती है: उस दौरान उसी key से रीट्राई 409 idempotency_replay_unavailable पाता है, यानी स्कैन दो बार नहीं चलता, पर लौटाने को कुछ नहीं होता। उन 24 घंटों के बाद उसी key से रीट्राई एक नया स्कैन है। ज़ीरो रिटेंशन और दोहराए जा सकने वाले रीट्राई एक साथ नहीं मिलते, इसलिए हर यूज़ केस के लिए एक चुनें।
  • sandbox key पर हेडर स्वीकार तो होता है, पर उसका कोई असर नहीं होता, क्योंकि वहाँ कुछ बिल ही नहीं होता। फिर भी आप यह कोड पाथ लिख और टेस्ट कर सकते हैं।

पूरा क्लाइंट

यह वह वर्ज़न है जिसे हम किसी सर्विस में लगाएँगे। यह हर इमेज के लिए एक key बनाता है और हर रीट्राई पर उसी को दोबारा इस्तेमाल करता है, सिर्फ़ उन्हीं एरर कोड पर रीट्राई करता है जिन्हें डॉक्स रीट्राई करने लायक बताती हैं, एक मिनट तक Retry-After का पालन करता है और उससे ज़्यादा इंतज़ार करने के बजाय हार मान लेता है, और कच्चा स्कैन लौटाता है ताकि आपके पास हर फ़ील्ड रहे।

// 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(); // इस इमेज के लिए एक key, हर रीट्राई पर वही दोबारा

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

    // बीच में कोई प्रॉक्सी HTML लौटा सकता है; उसे नेटवर्क एरर की तरह मानें।
    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 की घंटे वाली सीमा एक घंटे तक इंतज़ार माँग सकती है; एक कॉल के अंदर
    // इतना इंतज़ार किसी काम का नहीं, इसलिए एक मिनट से ज़्यादा को एरर माना जाता है।
    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 से चलाएँ। 24 सितंबर 2026 को पब्लिक sandbox key पर एक जनरेट किए गए टेस्ट पासपोर्ट के लिए इसने यह छापा। दस्तावेज़ से पढ़े गए मानों को से बदल दिया गया है; बाकी सब ठीक वैसा ही है जैसा छपा था:

{
  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 key पर billed: true कोई चार्ज नहीं है: यह बताता है कि लाइव key पर इस स्कैन का एक क्रेडिट लगता।

कुछ फ़ैसले जिन्हें समझाना ज़रूरी है:

  • fetch किसी 4xx या 5xx पर throw नहीं करता। यह सिर्फ़ नेटवर्क फ़ेल होने पर throw करता है, इसलिए क्लाइंट response.ok जाँचता है और दोनों ही हालात में JSON एरर बॉडी पढ़ता है।
  • ब्रांचिंग error.code पर करें, HTTP स्टेटस पर कभी नहीं। POST /v1/scans पर तीन idempotency कोड एक ही 409 साझा करते हैं और तीन अलग-अलग आउटेज एक ही 503, और इन्हें अलग-अलग तरीके से संभालना होता है। हर एरर बॉडी का आकार एक जैसा है: code, message, docs_url, request_id, event_idrequest_id लॉग करें; सपोर्ट को यही चाहिए। पूरी टेबल एरर संभालने की गाइड में है।
  • हर चीज़ रीट्राई करने लायक नहीं है। validation_failed, payload_too_large, unauthorized और insufficient_credits को सुधार चाहिए, लूप नहीं। sandbox पर आपको registration_required (मुफ़्त सीमा ख़त्म हो गई; इंतज़ार करने से वह वापस नहीं भरती) और document_repeated (एक ही इमेज एक घंटे में बहुत बार भेजी गई) भी मिलेंगे।
  • रिक्वेस्ट बॉडी लॉग न करें। वह एक पहचान दस्तावेज़ है।
  • reference meta.reference के रूप में वापस लौटता है (128 कैरेक्टर तक), और किसी स्कैन को अपने ऑर्डर या यूज़र रिकॉर्ड से जोड़ने का यही आसान तरीका है। ID कार्ड की दो साइड दो कॉल हैं; दोनों को एक ही reference दें।

कम डेटा रखना

अपलोड की गई इमेज रिक्वेस्ट के दौरान मेमोरी में रहती है और कभी स्थायी स्टोरेज में नहीं लिखी जाती। नतीजे की बात अलग है: उसे रखा जाता है ताकि आप उसे GET /v1/scans/{id} से दोबारा पढ़ सकें, आपकी चुनी हुई अवधि तक। अकाउंट सेटिंग में 24 घंटे, 7 दिन, 30 दिन या एक साल के विकल्प हैं, और नए अकाउंट के लिए डिफ़ॉल्ट एक साल है। हर रिक्वेस्ट पर options.retain_hours 0 से 8760 तक लेता है; 0 पर कोई पंक्ति लिखी ही नहीं जाती। अगर आपको JSON सिर्फ़ एक बार चाहिए, तो retain_hours: 0 भेजें, और ऊपर बताया गया idempotency वाला समझौता स्वीकार करें। प्रोसेसिंग EU में होती है।

ऊपर के क्लाइंट में इस्तेमाल हुआ return_portrait: false images.main_photo को छोड़ देता है, यानी धारक की फ़ोटो का क्रॉप, और इस तरह आपके अपने लॉग और स्टोरेज में सावधानी से संभालने लायक एक चीज़ कम हो जाती है। पूरे पेज का क्रॉप फिर भी लौटता है।

लाइव पर जाना

DOC_CHEAP_API_KEY के ज़रिए sk_sandbox_public की जगह अपनी key लगाएँ, और कुछ और नहीं बदलता: वही एंडपॉइंट, वही आकार। रजिस्टर्ड key एक मिनट में 60 रिक्वेस्ट की अनुमति देती है। क्रेडिट क्रिप्टोकरेंसी (BTC, ETH, TRX, या Ethereum या Tron पर USDT) से ख़रीदे जाते हैं, न्यूनतम $1; आज कार्ड से भुगतान की सुविधा नहीं है, और फ़ाइनेंस टीम के लिए डेमो की योजना बनाने से पहले यह जानना काम का है। Pricing पेज पर एकमात्र कीमत और सिर्फ़ सफलता पर बिलिंग का नियम है, और free passport OCR API पेज पर बिना साइनअप वाली पहली कॉल एक curl के रूप में दी गई है।

अगर आप इसे आज़माएँ और रिस्पॉन्स के आकार में कुछ ऐसा हो जो Node में इस्तेमाल करने में असुविधाजनक लगे, तो admin@doc.cheap पर लिखें। हमें ठीक इसी तरह का फ़ीडबैक चाहिए।

ऊपर का क्लाइंट असली sandbox पर चलाया गया और उसका आउटपुट ठीक वैसा ही दिया गया है जैसा छपा था; doc.cheap के बारे में हर बात उसके कोड से जाँची गई।