You have a passport photo coming in through a PHP form, and your code needs the name, the number, the dates and the machine-readable zone (MRZ) as data. This post builds that reader twice: once in plain PHP 8.1 with ext-curl and nothing else, and once as a Laravel service on the Http client. Both versions send one request, branch on the outcome, retry without paying twice and turn every refusal into an error code you can act on.

The API is the Passport and ID OCR API behind doc.cheap, and this is doc.cheap's own blog, so weigh the product choices accordingly. The patterns (one idempotency key per image, branching on a stable error code, storing a per-call billing flag) carry over to any paid API you call from PHP.

The first call, with no account

The docs print a public sandbox key, sk_sandbox_public. It runs real recognition, needs no sign-up and draws no credit. It allows 10 requests per hour per IP address and 10 free recognitions per client in total, after which it answers 403 registration_required.

The request is POST https://api.doc.cheap/v1/scans with a JSON body. One field is required, image: the file's bytes as base64, JPEG or 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;

The call is synchronous: the fields come back in this response, with no job id to poll and no webhook to host. Test with a synthetic specimen, never your own passport.

The body is checked strictly. An unknown key, such as a misspelled option, is refused with 422 validation_failed and a message that names the path, so a typo fails loudly instead of being ignored. A body over the size ceiling is 413 payload_too_large; the passport recognition guide covers sizing the photo and every option.

What comes back

Every answer has the same eight groups, and every key is always present: meta, document, holder, fields, mrz, images, quality and authenticity. After json_decode($body, true) a value that is not known is null, never a missing key. An abridged, masked example:

{
  "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>" }
}

Four parts decide what your code does next:

  • meta.status is one of five strings: recognized, no_document_found, unreadable, unsupported_document, rejected. All five arrive as HTTP 200. Only the first carries data.
  • document and holder are the curated values, with dates as YYYY-MM-DD. Each group is null as a whole when nothing was read for it.
  • mrz.status is passed, failed or absent. passed means the zone was found, every check digit validated and nothing in it contradicts the printed page.
  • meta.billed says whether this call drew a credit.

The point that shapes both clients: a photo that could not be read is a 200, not an error. Branch twice: on the error code for refusals, and on meta.status for outcomes. Retry on no_document_found and you resend a photo that will never read; treat it as success and you store a document with no fields.

Checking the MRZ yourself in PHP

mrz.lines and mrz.text are the zone exactly as read, and they are the only part of the response you can verify independently. On a passport (the TD3 format) the second line carries three check digits that are easy to recompute: the document number, the birth date and the expiry date. Each digit uses weights 7, 3, 1 repeating; digits count as themselves, A to Z as 10 to 35 and the filler < as 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],
    ];
}

Run it on $scan['mrz']['lines'][1] when $scan['document']['kind'] is passport. You do not need it to trust mrz.status; it is useful in a test, or when you keep the zone and want to prove later what was read.

Who pays: the billed flag

Sandbox keys never touch a balance. On a live key a scan is charged only when the document type was determined and at least one of these came out of it: an MRZ whose check digits pass, five or more fields from the printed page, or a decoded barcode. Nothing found, an unreadable photo, an unsupported type or a failure on the service's side costs nothing. On sk_sandbox_public, billed still reports whether the same scan would have been charged, which makes it worth testing against.

Store the flag next to your own order or user row, and your records match the bill without a reconciliation step. An account gets 100 free recognitions every month, drawn before any paid credit.

Retries that cannot charge twice

The risky retry is the one after a timeout: you do not know whether the first request reached the server. Send an Idempotency-Key header, 1 to 255 characters, and reuse it on every attempt for the same image. On a live key the server matches the key together with a fingerprint of the whole body:

  • Same key, same body, result stored: the first result comes back and nothing is charged again.
  • Same key, different body: 409 idempotency_conflict. Build the body once, outside the loop.
  • First request still running: 409 idempotency_in_progress. Wait and retry with the same key.
  • Same key, no stored result (for example after retain_hours: 0): 409 idempotency_replay_unavailable. Send it under a new key.

Every error body has the same shape (code, message, docs_url, request_id, event_id), and code is the value to branch on, because one HTTP status carries codes that need opposite handling. The guide to handling errors sorts all of them into buckets; the ones a scan client meets are these:

Codes What to do
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Wait (honour Retry-After), then retry
idempotency_in_progress Wait, retry with the same key
validation_failed, invalid_request, payload_too_large, unsupported_media_type Fix the request; a retry fails again
unauthorized, registration_required, insufficient_credits Fix the key or the account; waiting changes nothing

Retry-After is whole seconds. The sandbox's hourly limit can ask for most of an hour, so both clients below give up when the wait is over a minute instead of blocking a PHP worker.

The plain PHP client

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

Run it as php scan.php specimen.jpg. On a readable specimen it prints one line of this shape (values masked):

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

A few choices worth explaining:

  • curl does not fail on a 4xx or 5xx. The client reads the JSON body either way, because the error body carries the code.
  • The body and the key are built once, before the loop. That is what makes every attempt the same request in the server's eyes.
  • return_portrait: false leaves the crop of the holder's photo out of the answer, so one fewer face sits in your logs and storage.
  • reference comes back as meta.reference (up to 128 characters): the easy way to join a scan to your own order or user.
  • Never log the request body. It is an identity document. Log code, request_id and docs_url; that is everything support needs.

The Laravel version

Laravel's Http client (Laravel 10 or later) already does most of the loop: retry() takes a closure for the wait and a closure that decides whether an attempt is worth repeating, and post() with an array sends JSON. Put the key in config/services.php:

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

Then a small service:

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

With throw: false, a refusal that is not worth retrying comes back as a response, and the service turns it into an exception that names the code. In your own code, give that exception its own class with the error array on it, so callers can branch on code rather than on the message.

A controller that takes an upload and keeps only what it needs:

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

In a queued job, make the key part of the job. If you move the call into a job, generate the idempotency key when the upload arrives and pass it to the job's constructor. A job that fails and is retried by the queue worker then sends the same key, and a scan that already ran is replayed instead of charged again. A fresh Str::uuid() inside handle() would defeat that.

Test the retry path with Http::fake(). A sequence of a 429 and then a 200 proves that the second attempt happens and carries the same key, without a network call:

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

Keeping less data

Do not store the uploaded image after the call; read what you need from the result and drop the bytes. On the service side, the result is kept so you can read it again with GET /v1/scans/{id}, for a window you choose per request: options.retain_hours takes 0 to 8760, and 0 writes nothing at all. The trade-off is the replay rule above: with 0, a retry under the same key gets idempotency_replay_unavailable. If you want both, choose a short window above zero.

Going live

Set DOC_CHEAP_API_KEY to your own sk_live_ key and nothing else changes: same endpoint, same response, same client. A registered key allows 60 requests a minute, so a batch that honours Retry-After will not need its own rate limiter. If you are still choosing a provider, the passport OCR API comparison lists paid alternatives with their published prices.

A question for you: do you parse the MRZ yourself in PHP today, or do you trust a service for it? Tell us in the comments below.