Te llega la foto de un pasaporte a través de un formulario PHP, y tu código necesita el nombre, el número, las fechas y la zona de lectura mecánica (MRZ) como datos. Este artículo construye ese lector dos veces: una en PHP 8.1 puro, con ext-curl y nada más, y otra como servicio de Laravel sobre el cliente Http. Ambas versiones envían una petición, ramifican según el resultado, reintentan sin pagar dos veces y convierten cada rechazo en un código de error con el que puedes actuar.
La API es la API OCR de pasaportes y documentos de identidad de doc.cheap, y este es el blog del propio doc.cheap, así que valora las decisiones de producto con eso en mente. Los patrones (una clave de idempotencia por imagen, ramificar según un código de error estable, guardar un indicador de facturación por llamada) sirven para cualquier API de pago que llames desde PHP.
La primera llamada, sin cuenta
La documentación muestra una clave pública de sandbox, sk_sandbox_public. Ejecuta un reconocimiento real, no requiere registro y no consume créditos. Permite 10 peticiones por hora por dirección IP y 10 reconocimientos gratuitos por cliente en total; después responde 403 registration_required.
La petición es POST https://api.doc.cheap/v1/scans con un cuerpo JSON. Hay un campo obligatorio, image: los bytes del archivo en base64, JPEG o 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;
La llamada es síncrona: los campos vuelven en esta misma respuesta, sin id de tarea que consultar ni webhook que alojar. Prueba con un espécimen sintético, nunca con tu propio pasaporte.
El cuerpo se valida de forma estricta. Una clave desconocida, como una opción mal escrita, se rechaza con 422 validation_failed y un mensaje que nombra la ruta, así que una errata falla de forma visible en lugar de ignorarse. Un cuerpo que supera el límite de tamaño recibe 413 payload_too_large; la guía de reconocimiento de pasaportes explica cómo dimensionar la foto y todas las opciones.
Qué recibes
Todas las respuestas tienen los mismos ocho grupos, y todas las claves están siempre presentes: meta, document, holder, fields, mrz, images, quality y authenticity. Después de json_decode($body, true), un valor desconocido es null, nunca una clave ausente. Un ejemplo abreviado y enmascarado:
{
"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>" }
}
Cuatro partes deciden qué hace tu código a continuación:
meta.statuses una de cinco cadenas:recognized,no_document_found,unreadable,unsupported_document,rejected. Las cinco llegan como HTTP200. Solo la primera trae datos.documentyholderson los valores depurados, con fechas en formatoYYYY-MM-DD. Cada grupo esnullpor completo cuando no se leyó nada para él.mrz.statusespassed,failedoabsent.passedsignifica que se encontró la zona, todos los dígitos de control se validaron y nada en ella contradice la página impresa.meta.billedindica si esta llamada consumió un crédito.
Lo que da forma a ambos clientes: una foto que no se pudo leer es un 200, no un error. Ramifica dos veces: según el código de error para los rechazos y según meta.status para los resultados. Si reintentas ante no_document_found, reenvías una foto que nunca se leerá; si lo tratas como éxito, guardas un documento sin campos.
Comprobar la MRZ tú mismo en PHP
mrz.lines y mrz.text son la zona exactamente como se leyó, y son la única parte de la respuesta que puedes verificar de forma independiente. En un pasaporte (el formato TD3), la segunda línea lleva tres dígitos de control fáciles de recalcular: el número de documento, la fecha de nacimiento y la fecha de caducidad. Cada dígito usa los pesos 7, 3, 1 en ciclo; los dígitos valen lo que son, de A a Z valen de 10 a 35 y el carácter de relleno < 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],
];
}
Ejecútalo sobre $scan['mrz']['lines'][1] cuando $scan['document']['kind'] sea passport. No lo necesitas para fiarte de mrz.status; resulta útil en un test, o cuando guardas la zona y quieres demostrar más tarde qué se leyó.
Quién paga: el indicador billed
Las claves de sandbox nunca tocan un saldo. Con una clave live, un escaneo se cobra solo cuando se determinó el tipo de documento y se obtuvo al menos una de estas cosas: una MRZ cuyos dígitos de control son correctos, cinco o más campos de la página impresa o un código de barras decodificado. Si no se encuentra nada, si la foto es ilegible, si el tipo no está soportado o si el fallo es del lado del servicio, no cuesta nada. Con sk_sandbox_public, billed sigue indicando si ese mismo escaneo se habría cobrado, lo que hace que valga la pena probarlo.
Guarda el indicador junto a la fila de tu propio pedido o usuario, y tus registros coincidirán con la factura sin un paso de conciliación. Una cuenta recibe 100 reconocimientos gratis cada mes, que se consumen antes que cualquier crédito de pago.
Reintentos que no pueden cobrar dos veces
El reintento arriesgado es el que sigue a un timeout: no sabes si la primera petición llegó al servidor. Envía una cabecera Idempotency-Key, de 1 a 255 caracteres, y reutilízala en cada intento para la misma imagen. Con una clave live, el servidor compara la clave junto con una huella de todo el cuerpo:
- Misma clave, mismo cuerpo, resultado guardado: vuelve el primer resultado y no se cobra nada de nuevo.
- Misma clave, cuerpo distinto:
409 idempotency_conflict. Construye el cuerpo una sola vez, fuera del bucle. - La primera petición sigue en curso:
409 idempotency_in_progress. Espera y reintenta con la misma clave. - Misma clave, sin resultado guardado (por ejemplo, tras
retain_hours: 0):409 idempotency_replay_unavailable. Envíala con una clave nueva.
Todos los cuerpos de error tienen la misma forma (code, message, docs_url, request_id, event_id), y code es el valor según el que ramificar, porque un mismo estado HTTP lleva códigos que necesitan un tratamiento opuesto. La guía para gestionar errores los agrupa todos; los que se encuentra un cliente de escaneo son estos:
| Códigos | Qué hacer |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Espera (respeta Retry-After) y reintenta |
idempotency_in_progress |
Espera y reintenta con la misma clave |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Corrige la petición; un reintento vuelve a fallar |
unauthorized, registration_required, insufficient_credits |
Corrige la clave o la cuenta; esperar no cambia nada |
Retry-After viene en segundos enteros. El límite por hora del sandbox puede pedir casi una hora de espera, así que los dos clientes de abajo se rinden cuando la espera supera un minuto en lugar de bloquear un worker de PHP.
El cliente en 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);
}
Ejecútalo con php scan.php specimen.jpg. Con un espécimen legible imprime una línea con esta forma (valores enmascarados):
{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}
Algunas decisiones que conviene explicar:
- curl no falla ante un 4xx o un 5xx. El cliente lee el cuerpo JSON en ambos casos, porque el cuerpo del error lleva el código.
- El cuerpo y la clave se construyen una vez, antes del bucle. Eso es lo que hace que cada intento sea la misma petición a ojos del servidor.
return_portrait: falsedeja fuera de la respuesta el recorte de la foto del titular, así que hay una cara menos en tus logs y en tu almacenamiento.referencevuelve comometa.reference(hasta 128 caracteres): la forma sencilla de unir un escaneo con tu propio pedido o usuario.- Nunca registres el cuerpo de la petición. Es un documento de identidad. Registra
code,request_idydocs_url; es todo lo que necesita el soporte.
La versión para Laravel
El cliente Http de Laravel (Laravel 10 o posterior) ya hace la mayor parte del bucle: retry() acepta un closure para la espera y otro que decide si vale la pena repetir un intento, y post() con un array envía JSON. Pon la clave en config/services.php:
'doc_cheap' => [
'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],
Y después, un servicio pequeño:
<?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;
}
}
Con throw: false, un rechazo que no vale la pena reintentar vuelve como respuesta, y el servicio lo convierte en una excepción que nombra el código. En tu propio código, dale a esa excepción su propia clase con el array error dentro, para que quien la llame pueda ramificar según code y no según el mensaje.
Un controlador que recibe una subida y guarda solo lo que necesita:
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');
}
En un job en cola, haz que la clave forme parte del job. Si mueves la llamada a un job, genera la clave de idempotencia cuando llega la subida y pásala al constructor del job. Así, un job que falla y que el worker de la cola reintenta envía la misma clave, y un escaneo que ya se ejecutó se repite en lugar de cobrarse otra vez. Un Str::uuid() nuevo dentro de handle() lo echaría a perder.
Prueba el camino de reintento con Http::fake(). Una secuencia de un 429 seguido de un 200 demuestra que el segundo intento ocurre y lleva la misma clave, sin ninguna llamada de red:
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']);
Guardar menos datos
No guardes la imagen subida después de la llamada; lee del resultado lo que necesites y descarta los bytes. En el lado del servicio, el resultado se conserva para que puedas volver a leerlo con GET /v1/scans/{id}, durante un periodo que eliges en cada petición: options.retain_hours acepta de 0 a 8760, y 0 no escribe nada. La contrapartida es la norma de repetición de arriba: con 0, un reintento con la misma clave recibe idempotency_replay_unavailable. Si quieres ambas cosas, elige un periodo corto mayor que cero.
Pasar a producción
Pon en DOC_CHEAP_API_KEY tu propia clave sk_live_ y nada más cambia: el mismo endpoint, la misma respuesta, el mismo cliente. Una clave registrada permite 60 peticiones por minuto, así que un lote que respete Retry-After no necesitará su propio limitador de ritmo. Si todavía estás eligiendo proveedor, la comparativa de API de OCR de pasaportes recoge alternativas de pago con sus precios publicados.
Una pregunta para ti: ¿hoy analizas la MRZ tú mismo en PHP o confías en un servicio para ello? Cuéntanoslo en los comentarios de abajo.