Foto paspor masuk lewat formulir PHP, dan kode Anda membutuhkan nama, nomor, tanggal, dan zona yang dapat dibaca mesin (MRZ) sebagai data. Tulisan ini membangun pembaca itu dua kali: sekali dengan PHP 8.1 biasa, hanya dengan ext-curl, dan sekali sebagai service Laravel di atas klien Http. Kedua versi mengirim satu request, bercabang berdasarkan hasilnya, mencoba ulang tanpa membayar dua kali, dan mengubah setiap penolakan menjadi kode galat yang bisa Anda tindak lanjuti.
API yang dipakai adalah API OCR Paspor dan Kartu Identitas di balik doc.cheap, dan ini blog milik doc.cheap sendiri, jadi timbang pilihan produknya dengan mempertimbangkan hal itu. Polanya (satu kunci idempotensi per gambar, bercabang berdasarkan kode galat yang stabil, menyimpan flag penagihan per panggilan) berlaku untuk API berbayar apa pun yang Anda panggil dari PHP.
Panggilan pertama, tanpa akun
Dokumentasinya mencantumkan kunci sandbox publik, sk_sandbox_public. Kunci ini menjalankan pengenalan sungguhan, tidak memerlukan pendaftaran, dan tidak memakai kredit. Batasnya 10 request per jam per alamat IP dan total 10 pengenalan gratis per klien, setelah itu jawabannya 403 registration_required.
Request-nya adalah POST https://api.doc.cheap/v1/scans dengan body JSON. Satu field wajib, image: byte file dalam base64, JPEG atau 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;
Panggilannya sinkron: field-field kembali dalam jawaban ini juga, tanpa job id yang perlu di-polling dan tanpa webhook yang perlu di-host. Uji dengan spesimen sintetis, jangan pernah dengan paspor Anda sendiri.
Body diperiksa secara ketat. Kunci yang tidak dikenal, misalnya opsi yang salah ketik, ditolak dengan 422 validation_failed dan pesan yang menyebut path-nya, jadi salah ketik langsung gagal dengan jelas alih-alih diabaikan. Body yang melebihi batas ukuran mendapat 413 payload_too_large; panduan pengenalan paspor membahas cara menakar ukuran foto dan setiap opsi.
Apa yang dikembalikan
Setiap jawaban punya delapan grup yang sama, dan setiap kunci selalu ada: meta, document, holder, fields, mrz, images, quality dan authenticity. Setelah json_decode($body, true), nilai yang tidak diketahui adalah null, tidak pernah kunci yang hilang. Contoh ringkas yang nilainya disamarkan:
{
"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>" }
}
Empat bagian menentukan apa yang dilakukan kode Anda selanjutnya:
meta.statusadalah salah satu dari lima string:recognized,no_document_found,unreadable,unsupported_document,rejected. Kelimanya datang sebagai HTTP200. Hanya yang pertama membawa data.documentdanholderadalah nilai yang sudah dirapikan, dengan tanggal berformatYYYY-MM-DD. Setiap grup bernilainullsecara utuh jika tidak ada yang terbaca untuknya.mrz.statusbernilaipassed,failedatauabsent.passedberarti zonanya ditemukan, setiap digit pemeriksa valid, dan tidak ada isinya yang bertentangan dengan halaman cetak.meta.billedmenunjukkan apakah panggilan ini memakai kredit.
Hal yang membentuk kedua klien: foto yang tidak bisa dibaca adalah 200, bukan galat. Bercabanglah dua kali: berdasarkan kode galat untuk penolakan, dan berdasarkan meta.status untuk hasilnya. Jika Anda mencoba ulang pada no_document_found, Anda mengirim ulang foto yang tidak akan pernah terbaca; jika Anda menganggapnya sukses, Anda menyimpan dokumen tanpa field.
Memeriksa MRZ sendiri di PHP
mrz.lines dan mrz.text adalah zona persis seperti yang terbaca, dan keduanya satu-satunya bagian jawaban yang bisa Anda verifikasi secara mandiri. Pada paspor (format TD3), baris kedua membawa tiga digit pemeriksa yang mudah dihitung ulang: nomor dokumen, tanggal lahir, dan tanggal kedaluwarsa. Setiap digit memakai bobot 7, 3, 1 yang berulang; angka bernilai dirinya sendiri, A sampai Z bernilai 10 sampai 35, dan karakter pengisi < bernilai 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],
];
}
Jalankan pada $scan['mrz']['lines'][1] ketika $scan['document']['kind'] bernilai passport. Anda tidak membutuhkannya untuk memercayai mrz.status; fungsi ini berguna dalam tes, atau ketika Anda menyimpan zonanya dan ingin membuktikan nanti apa yang terbaca.
Siapa yang membayar: flag billed
Kunci sandbox tidak pernah menyentuh saldo. Pada kunci live, pindaian hanya ditagih jika jenis dokumen berhasil ditentukan dan setidaknya satu dari ini dihasilkan: MRZ yang digit pemeriksanya lolos, lima field atau lebih dari halaman cetak, atau barcode yang terdekode. Tidak ada yang ditemukan, foto yang tidak terbaca, jenis yang tidak didukung, atau kegagalan di sisi layanan tidak dikenai biaya apa pun. Pada sk_sandbox_public, billed tetap menunjukkan apakah pindaian yang sama akan ditagih, sehingga layak dijadikan bahan uji.
Simpan flag ini di samping baris pesanan atau pengguna Anda sendiri, dan catatan Anda cocok dengan tagihan tanpa langkah rekonsiliasi. Setiap akun mendapat 100 pengenalan gratis setiap bulan, yang dipakai lebih dulu sebelum kredit berbayar.
Percobaan ulang yang tidak bisa menagih dua kali
Percobaan ulang yang berisiko adalah yang dilakukan setelah timeout: Anda tidak tahu apakah request pertama sampai ke server. Kirim header Idempotency-Key, 1 sampai 255 karakter, dan pakai ulang di setiap percobaan untuk gambar yang sama. Pada kunci live, server mencocokkan kunci tersebut bersama sidik jari seluruh body:
- Kunci sama, body sama, hasil tersimpan: hasil pertama dikembalikan dan tidak ada yang ditagih lagi.
- Kunci sama, body berbeda:
409 idempotency_conflict. Susun body sekali saja, di luar loop. - Request pertama masih berjalan:
409 idempotency_in_progress. Tunggu lalu coba lagi dengan kunci yang sama. - Kunci sama, tidak ada hasil tersimpan (misalnya setelah
retain_hours: 0):409 idempotency_replay_unavailable. Kirim dengan kunci baru.
Setiap body galat punya bentuk yang sama (code, message, docs_url, request_id, event_id), dan code adalah nilai untuk bercabang, karena satu status HTTP bisa membawa kode yang perlu ditangani secara berlawanan. Panduan menangani galat mengelompokkan semuanya; yang akan ditemui klien pindai adalah ini:
| Kode | Apa yang dilakukan |
|---|---|
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance |
Tunggu (patuhi Retry-After), lalu coba lagi |
idempotency_in_progress |
Tunggu, coba lagi dengan kunci yang sama |
validation_failed, invalid_request, payload_too_large, unsupported_media_type |
Perbaiki request-nya; percobaan ulang akan gagal lagi |
unauthorized, registration_required, insufficient_credits |
Perbaiki kunci atau akunnya; menunggu tidak mengubah apa pun |
Retry-After berupa detik bulat. Batas per jam di sandbox bisa meminta waktu tunggu hampir satu jam, jadi kedua klien di bawah menyerah ketika waktu tunggunya lebih dari satu menit alih-alih memblokir worker PHP.
Klien PHP biasa
<?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);
}
Jalankan dengan php scan.php specimen.jpg. Pada spesimen yang terbaca, skrip ini mencetak satu baris dengan bentuk seperti ini (nilai disamarkan):
{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}
Beberapa pilihan yang perlu dijelaskan:
- curl tidak gagal pada 4xx atau 5xx. Klien tetap membaca body JSON dalam kedua kasus, karena body galat membawa kodenya.
- Body dan kunci disusun sekali, sebelum loop. Itulah yang membuat setiap percobaan menjadi request yang sama di mata server.
return_portrait: falsemembuat potongan foto pemegang tidak ikut dalam jawaban, sehingga ada satu wajah lebih sedikit di log dan penyimpanan Anda.referencekembali sebagaimeta.reference(hingga 128 karakter): cara mudah menghubungkan pindaian dengan pesanan atau pengguna Anda sendiri.- Jangan pernah mencatat body request di log. Isinya dokumen identitas. Catat
code,request_iddandocs_url; hanya itu yang dibutuhkan tim dukungan.
Versi Laravel
Klien Http Laravel (Laravel 10 atau lebih baru) sudah mengerjakan sebagian besar loop-nya: retry() menerima closure untuk waktu tunggu dan closure yang memutuskan apakah sebuah percobaan layak diulang, dan post() dengan array mengirim JSON. Taruh kuncinya di config/services.php:
'doc_cheap' => [
'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],
Lalu sebuah service kecil:
<?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;
}
}
Dengan throw: false, penolakan yang tidak layak dicoba ulang kembali sebagai response, dan service mengubahnya menjadi exception yang menyebut kodenya. Di kode Anda sendiri, beri exception itu class tersendiri yang membawa array error, supaya pemanggil bisa bercabang berdasarkan code, bukan berdasarkan pesannya.
Controller yang menerima unggahan dan hanya menyimpan yang diperlukan:
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');
}
Di job antrean, jadikan kunci bagian dari job. Jika Anda memindahkan panggilan ke sebuah job, buat kunci idempotensi saat unggahan tiba dan teruskan ke constructor job. Job yang gagal lalu dicoba ulang oleh queue worker akan mengirim kunci yang sama, dan pindaian yang sudah berjalan diputar ulang alih-alih ditagih lagi. Str::uuid() baru di dalam handle() akan menggagalkan hal itu.
Uji jalur percobaan ulang dengan Http::fake(). Urutan 429 lalu 200 membuktikan bahwa percobaan kedua terjadi dan membawa kunci yang sama, tanpa panggilan jaringan:
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']);
Menyimpan lebih sedikit data
Jangan simpan gambar yang diunggah setelah panggilan selesai; ambil yang Anda butuhkan dari hasilnya lalu buang byte-nya. Di sisi layanan, hasil disimpan agar Anda bisa membacanya lagi dengan GET /v1/scans/{id}, selama jangka waktu yang Anda pilih per request: options.retain_hours menerima 0 sampai 8760, dan 0 tidak menulis apa pun. Konsekuensinya adalah aturan pemutaran ulang di atas: dengan 0, percobaan ulang dengan kunci yang sama mendapat idempotency_replay_unavailable. Jika Anda menginginkan keduanya, pilih jangka waktu pendek di atas nol.
Beralih ke live
Isi DOC_CHEAP_API_KEY dengan kunci sk_live_ Anda sendiri, dan tidak ada hal lain yang berubah: endpoint yang sama, jawaban yang sama, klien yang sama. Kunci terdaftar mengizinkan 60 request per menit, jadi batch yang mematuhi Retry-After tidak memerlukan rate limiter sendiri. Jika Anda masih memilih penyedia, perbandingan API OCR paspor mencantumkan alternatif berbayar beserta harga yang mereka publikasikan.
Satu pertanyaan untuk Anda: apakah saat ini Anda mem-parse MRZ sendiri di PHP, atau Anda memercayakannya pada sebuah layanan? Ceritakan di kolom komentar di bawah.