A foto de um passaporte chega por um formulário PHP, e o seu código precisa do nome, do número, das datas e da zona de leitura mecânica (MRZ) como dados. Este post monta esse leitor duas vezes: uma em PHP 8.1 puro, com ext-curl e nada mais, e outra como um serviço do Laravel sobre o cliente Http. As duas versões enviam uma requisição, ramificam conforme o resultado, repetem sem pagar duas vezes e transformam cada recusa em um código de erro com o qual você pode agir.
A API é a API de OCR de passaportes e documentos de identidade do doc.cheap, e este é o blog do próprio doc.cheap, então pese as escolhas de produto levando isso em conta. Os padrões (uma chave de idempotência por imagem, ramificar por um código de erro estável, guardar um indicador de cobrança por chamada) valem para qualquer API paga que você chame a partir do PHP.
A primeira chamada, sem conta
A documentação traz uma chave pública de sandbox, sk_sandbox_public. Ela executa um reconhecimento real, não exige cadastro e não consome créditos. Permite 10 requisições por hora por endereço IP e 10 reconhecimentos gratuitos por cliente no total; depois disso, responde 403 registration_required.
A requisição é POST https://api.doc.cheap/v1/scans com um corpo JSON. Um campo é obrigatório, image: os bytes do arquivo em 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;
A chamada é síncrona: os campos voltam nesta mesma resposta, sem id de tarefa para consultar e sem webhook para hospedar. Teste com um espécime sintético, nunca com o seu próprio passaporte.
O corpo é validado de forma estrita. Uma chave desconhecida, como uma opção digitada errado, é recusada com 422 validation_failed e uma mensagem que aponta o caminho, então um erro de digitação falha de forma visível em vez de ser ignorado. Um corpo acima do limite de tamanho recebe 413 payload_too_large; o guia de reconhecimento de passaportes explica como dimensionar a foto e todas as opções.
O que volta
Toda resposta tem os mesmos oito grupos, e todas as chaves estão sempre presentes: meta, document, holder, fields, mrz, images, quality e authenticity. Depois de json_decode($body, true), um valor desconhecido é null, nunca uma chave ausente. Um exemplo resumido e mascarado:
{
"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>" }
}
Quatro partes decidem o que o seu código faz em seguida:
meta.statusé uma de cinco strings:recognized,no_document_found,unreadable,unsupported_document,rejected. As cinco chegam como HTTP200. Só a primeira traz dados.documenteholdersão os valores tratados, com datas no formatoYYYY-MM-DD. Cada grupo énullpor inteiro quando nada foi lido para ele.mrz.statusépassed,failedouabsent.passedsignifica que a zona foi encontrada, todos os dígitos verificadores foram validados e nada nela contradiz a página impressa.meta.billedinforma se esta chamada consumiu um crédito.
O ponto que molda os dois clientes: uma foto que não pôde ser lida é um 200, não um erro. Ramifique duas vezes: pelo código de erro nas recusas e por meta.status nos resultados. Se repetir diante de no_document_found, você reenvia uma foto que nunca será lida; se tratar como sucesso, você guarda um documento sem campos.
Conferindo a MRZ você mesmo em PHP
mrz.lines e mrz.text são a zona exatamente como foi lida, e são a única parte da resposta que você pode verificar de forma independente. Em um passaporte (o formato TD3), a segunda linha traz três dígitos verificadores fáceis de recalcular: o número do documento, a data de nascimento e a data de validade. Cada dígito usa os pesos 7, 3, 1 em sequência repetida; dígitos valem eles mesmos, de A a Z valem de 10 a 35 e o caractere de preenchimento < vale 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],
];
}
Rode sobre $scan['mrz']['lines'][1] quando $scan['document']['kind'] for passport. Você não precisa disso para confiar em mrz.status; é útil em um teste, ou quando você guarda a zona e quer provar depois o que foi lido.
Quem paga: o indicador billed
Chaves de sandbox nunca tocam em saldo. Com uma chave live, um scan só é cobrado quando o tipo de documento foi determinado e pelo menos um destes itens saiu dele: uma MRZ cujos dígitos verificadores conferem, cinco ou mais campos da página impressa ou um código de barras decodificado. Nada encontrado, uma foto ilegível, um tipo não suportado ou uma falha do lado do serviço não custam nada. Com sk_sandbox_public, billed continua informando se o mesmo scan teria sido cobrado, o que faz valer a pena testar com ele.
Guarde o indicador ao lado da linha do seu próprio pedido ou usuário, e os seus registros batem com a fatura sem uma etapa de conciliação. Uma conta recebe 100 reconhecimentos gratuitos todo mês, consumidos antes de qualquer crédito pago.
Retentativas que não podem cobrar duas vezes
A retentativa arriscada é a que vem depois de um timeout: você não sabe se a primeira requisição chegou ao servidor. Envie um cabeçalho Idempotency-Key, de 1 a 255 caracteres, e reutilize-o em cada tentativa para a mesma imagem. Com uma chave live, o servidor compara a chave junto com uma impressão digital do corpo inteiro:
- Mesma chave, mesmo corpo, resultado guardado: o primeiro resultado volta e nada é cobrado de novo.
- Mesma chave, corpo diferente:
409 idempotency_conflict. Monte o corpo uma vez, fora do loop. - A primeira requisição ainda está em andamento:
409 idempotency_in_progress. Espere e repita com a mesma chave. - Mesma chave, nenhum resultado guardado (por exemplo, depois de
retain_hours: 0):409 idempotency_replay_unavailable. Envie com uma chave nova.
Todo corpo de erro tem o mesmo formato (code, message, docs_url, request_id, event_id), e code é o valor pelo qual ramificar, porque um mesmo status HTTP carrega códigos que exigem tratamentos opostos. O guia de tratamento de erros separa todos eles em grupos; os que um cliente de scan encontra são estes:
| Códigos | O que fazer |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Espere (respeite Retry-After) e repita |
idempotency_in_progress |
Espere e repita com a mesma chave |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Corrija a requisição; uma retentativa falha de novo |
unauthorized, registration_required, insufficient_credits |
Corrija a chave ou a conta; esperar não muda nada |
Retry-After vem em segundos inteiros. O limite por hora do sandbox pode pedir quase uma hora, então os dois clientes abaixo desistem quando a espera passa de um minuto em vez de travar um worker PHP.
O cliente em PHP puro
<?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);
}
Rode com php scan.php specimen.jpg. Com um espécime legível, ele imprime uma linha neste formato (valores mascarados):
{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}
Algumas escolhas que valem explicação:
- O curl não falha diante de um 4xx ou 5xx. O cliente lê o corpo JSON nos dois casos, porque o corpo do erro traz o código.
- O corpo e a chave são montados uma vez, antes do loop. É isso que faz cada tentativa ser a mesma requisição aos olhos do servidor.
return_portrait: falsedeixa o recorte da foto do titular fora da resposta, então há um rosto a menos nos seus logs e no seu armazenamento.referencevolta comometa.reference(até 128 caracteres): o jeito fácil de ligar um scan ao seu próprio pedido ou usuário.- Nunca registre o corpo da requisição em log. Ele é um documento de identidade. Registre
code,request_idedocs_url; é tudo de que o suporte precisa.
A versão em Laravel
O cliente Http do Laravel (Laravel 10 ou posterior) já faz a maior parte do loop: retry() recebe uma closure para a espera e outra que decide se vale a pena repetir uma tentativa, e post() com um array envia JSON. Coloque a chave em config/services.php:
'doc_cheap' => [
'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],
Depois, um serviço pequeno:
<?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;
}
}
Com throw: false, uma recusa que não vale a pena repetir volta como resposta, e o serviço a transforma em uma exceção que cita o código. No seu próprio código, dê a essa exceção uma classe própria com o array error nela, para que quem chama possa ramificar por code em vez de pela mensagem.
Um controller que recebe um upload e guarda só o que precisa:
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');
}
Em um job na fila, faça a chave fazer parte do job. Se você mover a chamada para um job, gere a chave de idempotência quando o upload chegar e passe-a para o construtor do job. Assim, um job que falha e é repetido pelo worker da fila envia a mesma chave, e um scan que já rodou é reenviado em vez de cobrado de novo. Um Str::uuid() novo dentro de handle() anularia isso.
Teste o caminho de retentativa com Http::fake(). Uma sequência com um 429 e depois um 200 prova que a segunda tentativa acontece e leva a mesma chave, sem chamada de rede:
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']);
Guardando menos dados
Não guarde a imagem enviada depois da chamada; leia o que precisa do resultado e descarte os bytes. Do lado do serviço, o resultado fica guardado para que você possa lê-lo de novo com GET /v1/scans/{id}, por um período que você escolhe a cada requisição: options.retain_hours aceita de 0 a 8760, e 0 não grava nada. A contrapartida é a lógica de reenvio acima: com 0, uma retentativa com a mesma chave recebe idempotency_replay_unavailable. Se quiser as duas coisas, escolha um período curto acima de zero.
Indo para produção
Defina DOC_CHEAP_API_KEY com a sua própria chave sk_live_ e nada mais muda: o mesmo endpoint, a mesma resposta, o mesmo cliente. Uma chave registrada permite 60 requisições por minuto, então um lote que respeita Retry-After não vai precisar de um limitador de taxa próprio. Se você ainda está escolhendo um fornecedor, a comparação de APIs de OCR de passaporte lista alternativas pagas com os preços que elas publicam.
Uma pergunta para você: hoje você interpreta a MRZ por conta própria em PHP ou confia em um serviço para isso? Conte para a gente nos comentários abaixo.