आपके PHP फ़ॉर्म से पासपोर्ट की एक फ़ोटो आती है, और आपके कोड को नाम, नंबर, तारीखें और मशीन-रीडेबल ज़ोन (MRZ) डेटा के रूप में चाहिए। यह पोस्ट वह रीडर दो बार बनाती है: एक बार सादे PHP 8.1 में, सिर्फ़ ext-curl के साथ, और एक बार Http क्लाइंट पर बनी Laravel सर्विस के रूप में। दोनों वर्ज़न एक रिक्वेस्ट भेजते हैं, नतीजे के हिसाब से ब्रांच करते हैं, दो बार भुगतान किए बिना रीट्राई करते हैं और हर इनकार को ऐसे एरर कोड में बदलते हैं जिस पर आप कार्रवाई कर सकें।

API है doc.cheap के पीछे का पासपोर्ट और ID कार्ड OCR API, और यह doc.cheap का अपना ब्लॉग है, इसलिए प्रोडक्ट से जुड़े सुझावों को इसी नज़र से तौलें। ये पैटर्न (हर इमेज के लिए एक idempotency key, एक स्थिर एरर कोड पर ब्रांचिंग, हर कॉल का बिलिंग फ़्लैग सेव करना) किसी भी पेड API पर लागू होते हैं जिसे आप PHP से कॉल करते हैं।

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

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

रिक्वेस्ट है POST https://api.doc.cheap/v1/scans, JSON बॉडी के साथ। एक ही फ़ील्ड ज़रूरी है, image: फ़ाइल के बाइट्स base64 में, JPEG या PNG।

<?php
$ch = curl_init('https://api.doc.cheap/v1/scans');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode(['image' => base64_encode(file_get_contents('specimen.jpg'))]),
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer sk_sandbox_public',
        'Content-Type: application/json',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $body, PHP_EOL;

कॉल सिंक्रोनस है: फ़ील्ड इसी जवाब में लौटते हैं, न पोल करने के लिए कोई job id, न होस्ट करने के लिए कोई webhook। टेस्ट किसी सिंथेटिक नमूने से करें, अपने पासपोर्ट से कभी नहीं।

बॉडी की जाँच सख़्ती से होती है। कोई अनजान key, जैसे ग़लत लिखा गया ऑप्शन, 422 validation_failed के साथ लौटा दी जाती है, और मैसेज में उसका पाथ होता है, इसलिए टाइपो नज़रअंदाज़ होने के बजाय साफ़ एरर देता है। साइज़ की सीमा से बड़ी बॉडी पर 413 payload_too_large मिलता है; फ़ोटो का साइज़ तय करना और हर ऑप्शन पासपोर्ट रिकग्निशन गाइड में है।

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

हर जवाब में वही आठ ग्रुप होते हैं, और हर key हमेशा मौजूद रहती है: meta, document, holder, fields, mrz, images, quality और authenticity। json_decode($body, true) के बाद जो वैल्यू पता नहीं है वह null होती है, key कभी ग़ायब नहीं होती। एक छोटा किया गया, मास्क किया हुआ उदाहरण:

{
  "meta": { "id": "<SCAN_ID>", "status": "recognized", "billed": true, "confidence": "high", "reference": null },
  "document": { "kind": "passport", "country": "<ISO3>", "number": "<DOCUMENT_NUMBER>", "expiry_date": "<YYYY-MM-DD>", "is_expired": false },
  "holder": { "given_names": "<GIVEN_NAMES>", "surname": "<SURNAME>", "birth_date": "<YYYY-MM-DD>", "sex": "<M|F|X>", "nationality": "<ISO3>" },
  "mrz": { "status": "passed", "reason": null, "lines": ["<MRZ_LINE_1>", "<MRZ_LINE_2>"], "text": "<MRZ_TEXT>" }
}

चार हिस्से तय करते हैं कि आपका कोड आगे क्या करेगा:

  • meta.status पाँच स्ट्रिंग में से एक है: recognized, no_document_found, unreadable, unsupported_document, rejected। पाँचों HTTP 200 के रूप में आती हैं। डेटा सिर्फ़ पहली में होता है।
  • document और holder साफ़ की गई वैल्यू हैं, तारीखें YYYY-MM-DD में। जब किसी ग्रुप के लिए कुछ नहीं पढ़ा गया, तो वह पूरा ग्रुप null होता है।
  • mrz.status की वैल्यू passed, failed या absent होती है। passed का मतलब है कि ज़ोन मिला, हर चेक डिजिट सही निकला और उसमें कुछ भी प्रिंटेड पेज से मेल न खाने वाला नहीं है।
  • meta.billed बताता है कि इस कॉल ने क्रेडिट काटा या नहीं।

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

PHP में MRZ की जाँच ख़ुद करना

mrz.lines और mrz.text ज़ोन को ठीक वैसा ही देते हैं जैसा पढ़ा गया, और जवाब का यही एक हिस्सा है जिसे आप स्वतंत्र रूप से जाँच सकते हैं। पासपोर्ट (TD3 फ़ॉर्मेट) की दूसरी लाइन में तीन चेक डिजिट होते हैं जिन्हें दोबारा गिनना आसान है: दस्तावेज़ नंबर, जन्म तिथि और समाप्ति तिथि। हर डिजिट में वज़न 7, 3, 1 दोहराए जाते हैं; अंक अपनी ही वैल्यू गिने जाते हैं, A से Z तक 10 से 35, और फ़िलर < को 0।

<?php
function mrz_check_digit(string $field): int
{
    $weights = [7, 3, 1];
    $sum = 0;
    foreach (str_split($field) as $i => $char) {
        $value = match (true) {
            ctype_digit($char) => (int) $char,
            ctype_upper($char) => ord($char) - 55, // A = 10 ... Z = 35
            default => 0,                          // the filler <
        };
        $sum += $value * $weights[$i % 3];
    }
    return $sum % 10;
}

function td3_line2_checks(string $line): array
{
    return [
        'number' => mrz_check_digit(substr($line, 0, 9)) === (int) $line[9],
        'birth_date' => mrz_check_digit(substr($line, 13, 6)) === (int) $line[19],
        'expiry_date' => mrz_check_digit(substr($line, 21, 6)) === (int) $line[27],
    ];
}

जब $scan['document']['kind'] की वैल्यू passport हो, तो इसे $scan['mrz']['lines'][1] पर चलाएँ। mrz.status पर भरोसा करने के लिए आपको इसकी ज़रूरत नहीं; यह किसी टेस्ट में काम आता है, या तब जब आप ज़ोन सेव रखते हैं और बाद में साबित करना चाहते हैं कि क्या पढ़ा गया था।

पैसा कौन देता है: billed फ़्लैग

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

फ़्लैग को अपने ऑर्डर या यूज़र की पंक्ति के साथ सेव करें, फिर आपके रिकॉर्ड बिना किसी मिलान वाले क़दम के बिल से मेल खाएँगे। अकाउंट को हर महीने 100 मुफ़्त रिकग्निशन मिलते हैं, जो किसी भी पेड क्रेडिट से पहले इस्तेमाल होते हैं।

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

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

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

हर एरर बॉडी का आकार एक जैसा है (code, message, docs_url, request_id, event_id), और ब्रांचिंग code पर करनी है, क्योंकि एक ही HTTP स्टेटस ऐसे कोड ला सकता है जिन्हें बिल्कुल उलटे तरीक़े से संभालना होता है। एरर संभालने की गाइड इन सबको समूहों में बाँटती है; स्कैन क्लाइंट का सामना इनसे होता है:

कोड क्या करें
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance इंतज़ार करें (Retry-After का पालन करें), फिर रीट्राई करें
idempotency_in_progress इंतज़ार करें, उसी key से रीट्राई करें
validation_failed, invalid_request, payload_too_large, unsupported_media_type रिक्वेस्ट ठीक करें; रीट्राई फिर फ़ेल होगा
unauthorized, registration_required, insufficient_credits key या अकाउंट ठीक करें; इंतज़ार से कुछ नहीं बदलता

Retry-After पूरे सेकंड में होता है। sandbox की घंटे वाली सीमा लगभग पूरे घंटे का इंतज़ार माँग सकती है, इसलिए नीचे के दोनों क्लाइंट PHP वर्कर को रोके रखने के बजाय तब हार मान लेते हैं जब इंतज़ार एक मिनट से ज़्यादा हो।

सादा PHP क्लाइंट

<?php
// scan.php – PHP 8.1+, ext-curl and ext-json only

const API = 'https://api.doc.cheap/v1/scans';
const RETRYABLE = [
    'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
    'service_unavailable', 'maintenance', 'idempotency_in_progress',
];
const MAX_WAIT = 60; // seconds; a longer Retry-After is reported, not slept through

final class ScanError extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $error)
    {
        parent::__construct(sprintf('%s (%d): %s', $error['code'], $status, $error['message']));
    }
}

/** One HTTP attempt: [status, decoded body, Retry-After seconds]. */
function post_scan(string $apiKey, string $idempotencyKey, string $json): array
{
    $retryAfter = 0;
    $ch = curl_init(API);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idempotencyKey,
        ],
        CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$retryAfter): int {
            if (stripos($header, 'Retry-After:') === 0) {
                $retryAfter = (int) trim(substr($header, 12));
            }
            return strlen($header);
        },
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('transport: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return [$status, json_decode($body, true, flags: JSON_THROW_ON_ERROR), $retryAfter];
}

function scan(string $path, ?string $reference = null, int $attempts = 4): array
{
    $bytes = file_get_contents($path);
    if ($bytes === false) {
        throw new RuntimeException("cannot read $path");
    }
    $apiKey = getenv('DOC_CHEAP_API_KEY') ?: 'sk_sandbox_public';
    $json = json_encode([
        'image' => base64_encode($bytes),
        'reference' => $reference,
        'options' => ['return_portrait' => false],
    ], JSON_THROW_ON_ERROR);
    $idempotencyKey = bin2hex(random_bytes(16)); // one key per image, reused on every attempt

    for ($attempt = 1; ; $attempt++) {
        $last = $attempt >= $attempts;
        try {
            [$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
        } catch (RuntimeException | JsonException $e) {
            // A timeout, a dropped connection, or a proxy answering with HTML.
            if ($last) {
                throw $e;
            }
            sleep(2 ** $attempt);
            continue;
        }
        if ($status === 200) {
            return $payload;
        }
        $error = $payload['error'];
        $wait = $retryAfter > 0 ? $retryAfter : 2 ** $attempt;
        if ($last || !in_array($error['code'], RETRYABLE, true) || $wait > MAX_WAIT) {
            throw new ScanError($status, $error);
        }
        sleep($wait);
    }
}

function summarise(array $scan): array
{
    $meta = $scan['meta'];
    if ($meta['status'] !== 'recognized') {
        return ['ok' => false, 'status' => $meta['status'], 'billed' => $meta['billed']];
    }
    $document = $scan['document'] ?? [];
    $holder = $scan['holder'] ?? [];
    return [
        'ok' => true,
        'billed' => $meta['billed'],
        'kind' => $document['kind'] ?? null,
        'country' => $document['country'] ?? null,
        'expiry_date' => $document['expiry_date'] ?? null,
        'surname' => $holder['surname'] ?? null,
        'birth_date' => $holder['birth_date'] ?? null,
        'mrz' => $scan['mrz']['status'],
    ];
}

try {
    echo json_encode(summarise(scan($argv[1], reference: 'demo-1'))), PHP_EOL;
} catch (ScanError $e) {
    fwrite(STDERR, $e->getMessage() . ' ' . $e->error['docs_url'] . ' ' . $e->error['request_id'] . PHP_EOL);
    exit(1);
}

इसे php scan.php specimen.jpg से चलाएँ। पढ़े जा सकने वाले नमूने पर यह इस आकार की एक लाइन छापता है (वैल्यू मास्क की हुई):

{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}

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

  • curl किसी 4xx या 5xx पर फ़ेल नहीं होता। क्लाइंट दोनों हालात में JSON बॉडी पढ़ता है, क्योंकि एरर बॉडी में कोड होता है।
  • बॉडी और key एक ही बार, लूप से पहले बनती हैं। यही बात सर्वर की नज़र में हर कोशिश को एक ही रिक्वेस्ट बनाती है।
  • return_portrait: false धारक की फ़ोटो का क्रॉप जवाब से बाहर रखता है, इसलिए आपके लॉग और स्टोरेज में एक चेहरा कम रहता है।
  • reference meta.reference के रूप में लौटता है (128 कैरेक्टर तक): किसी स्कैन को अपने ऑर्डर या यूज़र से जोड़ने का आसान तरीक़ा।
  • रिक्वेस्ट बॉडी को कभी लॉग न करें। वह एक पहचान दस्तावेज़ है। code, request_id और docs_url लॉग करें; सपोर्ट को बस इतना ही चाहिए।

Laravel वर्ज़न

Laravel का Http क्लाइंट (Laravel 10 या उसके बाद) लूप का ज़्यादातर काम पहले से करता है: retry() इंतज़ार के लिए एक closure लेता है और एक closure जो तय करता है कि कोशिश दोहराने लायक है या नहीं, और array के साथ post() JSON भेजता है। key को config/services.php में रखें:

'doc_cheap' => [
    'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],

फिर एक छोटी सर्विस:

<?php

namespace App\Services;

use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class PassportReader
{
    private const RETRYABLE = [
        'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
        'service_unavailable', 'maintenance', 'idempotency_in_progress',
    ];

    public function read(string $bytes, string $idempotencyKey, ?string $reference = null): array
    {
        $response = Http::baseUrl('https://api.doc.cheap/v1')
            ->withToken(config('services.doc_cheap.key'))
            ->withHeaders(['Idempotency-Key' => $idempotencyKey])
            ->acceptJson()
            ->timeout(60)
            ->retry(
                4,
                fn (int $attempt, Exception $e) => $this->waitSeconds($attempt, $e) * 1000,
                fn (Exception $e) => $this->worthRetrying($e),
                throw: false,
            )
            ->post('scans', [
                'image' => base64_encode($bytes),
                'reference' => $reference,
                'options' => ['return_portrait' => false],
            ]);

        if ($response->successful()) {
            return $response->json();
        }

        $error = $response->json('error') ?? ['code' => 'unknown', 'message' => $response->reason()];
        throw new RuntimeException(sprintf('%s (%d): %s', $error['code'], $response->status(), $error['message']));
    }

    private function waitSeconds(int $attempt, Exception $e): int
    {
        $after = $e instanceof RequestException ? (int) $e->response->header('Retry-After') : 0;

        return $after > 0 ? $after : 2 ** $attempt;
    }

    private function worthRetrying(Exception $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true; // a timeout or a dropped connection: the key makes the retry safe
        }

        return $e instanceof RequestException
            && in_array($e->response->json('error.code'), self::RETRYABLE, true)
            && (int) $e->response->header('Retry-After') <= 60;
    }
}

throw: false के साथ, जो इनकार रीट्राई लायक नहीं है वह जवाब के रूप में लौटता है, और सर्विस उसे ऐसे एक्सेप्शन में बदल देती है जिसमें कोड का नाम हो। अपने कोड में उस एक्सेप्शन को उसकी अपनी क्लास दें, जिस पर error array हो, ताकि कॉल करने वाले मैसेज के बजाय code पर ब्रांच कर सकें।

एक कंट्रोलर जो अपलोड लेता है और सिर्फ़ उतना रखता है जितना उसे चाहिए:

public function store(Request $request, PassportReader $reader)
{
    $request->validate(['passport' => ['required', 'file', 'mimes:jpg,png', 'max:10240']]);

    $scan = $reader->read(
        $request->file('passport')->get(),
        (string) Str::uuid(),
        reference: (string) $request->user()->id,
    );

    if ($scan['meta']['status'] !== 'recognized') {
        return back()->withErrors(['passport' => 'We could not read that photo. Please try a sharper one.']);
    }

    $request->user()->passportChecks()->create([
        'scan_id' => $scan['meta']['id'],
        'billed' => $scan['meta']['billed'],
        'expiry_date' => $scan['document']['expiry_date'] ?? null,
        'mrz_status' => $scan['mrz']['status'],
    ]);

    return redirect()->route('profile');
}

क्यू वाले job में key को job का हिस्सा बनाएँ। अगर आप कॉल को किसी job में ले जाते हैं, तो अपलोड आते ही idempotency key बनाएँ और उसे job के कंस्ट्रक्टर में पास करें। तब जो job फ़ेल होकर क्यू वर्कर से दोबारा चलता है, वह वही key भेजता है, और पहले से चल चुका स्कैन दोबारा चार्ज होने के बजाय फिर से लौटा दिया जाता है। handle() के अंदर नया Str::uuid() इस पूरे इंतज़ाम को बेकार कर देगा।

रीट्राई के रास्ते को Http::fake() से टेस्ट करें। पहले 429 और फिर 200 का क्रम यह साबित करता है कि दूसरी कोशिश होती है और उसमें वही key जाती है, बिना किसी नेटवर्क कॉल के:

Http::fake([
    'api.doc.cheap/*' => Http::sequence()
        ->push(['error' => ['code' => 'rate_limited', 'message' => 'Slow down', 'docs_url' => null, 'request_id' => 'req_test', 'event_id' => null]], 429, ['Retry-After' => '1'])
        ->push(['meta' => ['id' => 'scan_test', 'status' => 'no_document_found', 'billed' => false]], 200),
]);

$scan = app(PassportReader::class)->read('fake-bytes', 'key-1');

Http::assertSentCount(2);
Http::assertNotSent(fn ($request) => $request->header('Idempotency-Key') !== ['key-1']);

कम डेटा रखना

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

लाइव पर जाना

DOC_CHEAP_API_KEY में अपनी sk_live_ key डालें, और कुछ और नहीं बदलता: वही endpoint, वही जवाब, वही क्लाइंट। रजिस्टर्ड key एक मिनट में 60 रिक्वेस्ट की अनुमति देती है, इसलिए Retry-After का पालन करने वाले बैच को अपने अलग रेट लिमिटर की ज़रूरत नहीं होगी। अगर आप अभी प्रोवाइडर चुन रहे हैं, तो पासपोर्ट OCR API की तुलना में पेड विकल्प उनकी प्रकाशित क़ीमतों के साथ दिए गए हैं।

आपसे एक सवाल: क्या आज आप PHP में MRZ ख़ुद पार्स करते हैं, या इसके लिए किसी सर्विस पर भरोसा करते हैं? नीचे टिप्पणियों में हमें बताएँ।