La photo d'un passeport arrive par un formulaire PHP, et votre code a besoin du nom, du numéro, des dates et de la zone de lecture automatique (MRZ) sous forme de données. Cet article construit ce lecteur deux fois : une fois en PHP 8.1 pur, avec ext-curl et rien d'autre, et une fois comme service Laravel sur le client Http. Les deux versions envoient une requête, branchent selon le résultat, relancent sans payer deux fois et transforment chaque refus en un code d'erreur sur lequel vous pouvez agir.

L'API est l'API OCR de passeports et de pièces d'identité de doc.cheap, et ce blog est celui de doc.cheap : pesez les choix de produit en conséquence. Les patterns (une clé d'idempotence par image, un branchement sur un code d'erreur stable, un indicateur de facturation stocké pour chaque appel) valent pour toute API payante que vous appelez depuis PHP.

Le premier appel, sans compte

La documentation affiche une clé sandbox publique, sk_sandbox_public. Elle exécute une vraie reconnaissance, ne demande aucune inscription et ne consomme aucun crédit. Elle autorise 10 requêtes par heure et par adresse IP, et 10 reconnaissances gratuites par client au total ; ensuite, elle répond 403 registration_required.

La requête est POST https://api.doc.cheap/v1/scans avec un corps JSON. Un champ est obligatoire, image : les octets du fichier en base64, JPEG ou 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;

L'appel est synchrone : les champs reviennent dans cette réponse même, sans id de tâche à interroger ni webhook à héberger. Testez avec un spécimen synthétique, jamais avec votre propre passeport.

Le corps est vérifié de façon stricte. Une clé inconnue, comme une option mal orthographiée, est refusée avec 422 validation_failed et un message qui nomme le chemin : une faute de frappe échoue bruyamment au lieu d'être ignorée. Un corps qui dépasse la taille maximale reçoit 413 payload_too_large ; le guide de reconnaissance de passeport explique comment dimensionner la photo et détaille chaque option.

Ce que vous recevez

Chaque réponse contient les mêmes huit groupes, et chaque clé est toujours présente : meta, document, holder, fields, mrz, images, quality et authenticity. Après json_decode($body, true), une valeur inconnue vaut null, jamais une clé absente. Un exemple abrégé et masqué :

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

Quatre parties décident de ce que fait ensuite votre code :

  • meta.status est l'une de cinq chaînes : recognized, no_document_found, unreadable, unsupported_document, rejected. Les cinq arrivent en HTTP 200. Seule la première porte des données.
  • document et holder sont les valeurs consolidées, avec des dates au format YYYY-MM-DD. Chaque groupe vaut null en entier quand rien n'a été lu pour lui.
  • mrz.status vaut passed, failed ou absent. passed signifie que la zone a été trouvée, que chaque chiffre de contrôle a été validé et que rien en elle ne contredit la page imprimée.
  • meta.billed indique si cet appel a consommé un crédit.

Le point qui façonne les deux clients : une photo qui n'a pas pu être lue est un 200, pas une erreur. Branchez deux fois : sur le code d'erreur pour les refus, et sur meta.status pour les résultats. Relancez sur no_document_found et vous renvoyez une photo qui ne sera jamais lue ; traitez-le comme un succès et vous stockez un document sans champs.

Vérifier la MRZ vous-même en PHP

mrz.lines et mrz.text sont la zone exactement telle qu'elle a été lue, et c'est la seule partie de la réponse que vous pouvez vérifier de manière indépendante. Sur un passeport (le format TD3), la deuxième ligne porte trois chiffres de contrôle faciles à recalculer : le numéro du document, la date de naissance et la date d'expiration. Chaque chiffre utilise les poids 7, 3, 1 qui se répètent ; les chiffres comptent pour eux-mêmes, A à Z pour 10 à 35 et le caractère de remplissage < pour 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],
    ];
}

Exécutez-le sur $scan['mrz']['lines'][1] quand $scan['document']['kind'] vaut passport. Vous n'en avez pas besoin pour faire confiance à mrz.status ; c'est utile dans un test, ou quand vous conservez la zone et voulez prouver plus tard ce qui a été lu.

Qui paie : l'indicateur billed

Les clés sandbox ne touchent jamais à un solde. Avec une clé live, un scan n'est facturé que si le type de document a été déterminé et qu'au moins l'un de ces éléments en est sorti : une MRZ dont les chiffres de contrôle sont valides, cinq champs ou plus de la page imprimée, ou un code-barres décodé. Rien de trouvé, une photo illisible, un type non pris en charge ou une panne côté service ne coûtent rien. Avec sk_sandbox_public, billed indique quand même si ce même scan aurait été facturé, ce qui vaut la peine de tester avec.

Stockez l'indicateur à côté de la ligne de votre propre commande ou utilisateur, et vos enregistrements correspondent à la facture sans étape de rapprochement. Un compte reçoit 100 reconnaissances gratuites chaque mois, consommées avant tout crédit payant.

Des relances qui ne peuvent pas facturer deux fois

La relance risquée est celle qui suit un timeout : vous ne savez pas si la première requête a atteint le serveur. Envoyez un en-tête Idempotency-Key, de 1 à 255 caractères, et réutilisez-le à chaque tentative pour la même image. Avec une clé live, le serveur compare la clé avec une empreinte du corps entier :

  • Même clé, même corps, résultat stocké : le premier résultat revient et rien n'est facturé à nouveau.
  • Même clé, corps différent : 409 idempotency_conflict. Construisez le corps une seule fois, hors de la boucle.
  • La première requête est encore en cours : 409 idempotency_in_progress. Attendez et relancez avec la même clé.
  • Même clé, aucun résultat stocké (par exemple après retain_hours: 0) : 409 idempotency_replay_unavailable. Envoyez-la sous une nouvelle clé.

Chaque corps d'erreur a la même forme (code, message, docs_url, request_id, event_id), et code est la valeur sur laquelle brancher, car un même statut HTTP porte des codes qui demandent des traitements opposés. Le guide de gestion des erreurs les range tous par catégorie ; ceux que rencontre un client de scan sont ceux-ci :

Codes Que faire
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Attendre (respecter Retry-After), puis relancer
idempotency_in_progress Attendre, relancer avec la même clé
validation_failed, invalid_request, payload_too_large, unsupported_media_type Corriger la requête ; une relance échoue encore
unauthorized, registration_required, insufficient_credits Corriger la clé ou le compte ; attendre ne change rien

Retry-After est en secondes entières. La limite horaire de la sandbox peut demander presque une heure d'attente, donc les deux clients ci-dessous abandonnent quand l'attente dépasse une minute au lieu de bloquer un worker PHP.

Le client en PHP pur

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

Lancez-le avec php scan.php specimen.jpg. Sur un spécimen lisible, il affiche une ligne de cette forme (valeurs masquées) :

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

Quelques choix qui méritent une explication :

  • curl n'échoue pas sur un 4xx ou un 5xx. Le client lit le corps JSON dans tous les cas, car le corps d'erreur porte le code.
  • Le corps et la clé sont construits une fois, avant la boucle. C'est ce qui fait de chaque tentative la même requête aux yeux du serveur.
  • return_portrait: false laisse le recadrage de la photo du titulaire hors de la réponse : un visage de moins dans vos logs et votre stockage.
  • reference revient sous la forme meta.reference (jusqu'à 128 caractères) : le moyen simple de relier un scan à votre propre commande ou utilisateur.
  • Ne journalisez jamais le corps de la requête. C'est une pièce d'identité. Journalisez code, request_id et docs_url ; c'est tout ce dont le support a besoin.

La version Laravel

Le client Http de Laravel (Laravel 10 ou plus récent) fait déjà l'essentiel de la boucle : retry() accepte une closure pour l'attente et une closure qui décide si une tentative mérite d'être répétée, et post() avec un tableau envoie du JSON. Mettez la clé dans config/services.php :

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

Puis un petit 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;
    }
}

Avec throw: false, un refus qui ne vaut pas une relance revient sous forme de réponse, et le service le transforme en exception qui nomme le code. Dans votre propre code, donnez à cette exception sa propre classe avec le tableau error dessus, pour que les appelants puissent brancher sur code plutôt que sur le message.

Un contrôleur qui reçoit un upload et ne garde que ce dont il a besoin :

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

Dans un job en file d'attente, la clé fait partie du job. Si vous déplacez l'appel dans un job, générez la clé d'idempotence à l'arrivée de l'upload et passez-la au constructeur du job. Un job qui échoue et que le worker de la file relance envoie alors la même clé, et un scan déjà exécuté est rejoué au lieu d'être facturé à nouveau. Un Str::uuid() tout neuf dans handle() réduirait cela à néant.

Testez le chemin de relance avec Http::fake(). Une séquence d'un 429 puis d'un 200 prouve que la deuxième tentative a lieu et porte la même clé, sans appel réseau :

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

Garder moins de données

Ne stockez pas l'image envoyée après l'appel ; lisez dans le résultat ce dont vous avez besoin et jetez les octets. Côté service, le résultat est conservé pour que vous puissiez le relire avec GET /v1/scans/{id}, pendant une durée que vous choisissez à chaque requête : options.retain_hours accepte de 0 à 8760, et 0 n'écrit rien du tout. La contrepartie est la logique de rejeu vue plus haut : avec 0, une relance sous la même clé reçoit idempotency_replay_unavailable. Si vous voulez les deux, choisissez une durée courte supérieure à zéro.

Passer en production

Mettez votre propre clé sk_live_ dans DOC_CHEAP_API_KEY, et rien d'autre ne change : même endpoint, même réponse, même client. Une clé enregistrée autorise 60 requêtes par minute, donc un lot qui respecte Retry-After n'aura pas besoin de son propre limiteur de débit. Si vous choisissez encore un fournisseur, le comparatif des API OCR de passeport liste des alternatives payantes avec leurs prix publiés.

Une question pour vous : aujourd'hui, analysez-vous la MRZ vous-même en PHP, ou faites-vous confiance à un service ? Dites-le-nous dans les commentaires ci-dessous.