Anda punya foto paspor dan layanan .NET 8 yang membutuhkan nama, nomor, tanggal, dan zona yang dapat dibaca mesin (MRZ) sebagai data. Tulisan ini membangun kliennya dengan C# 12 biasa: HttpClient, System.Net.Http.Json dan System.Text.Json, tanpa SDK. Isinya mencakup panggilan pertama, record untuk jawabannya, status yang bukan galat, percobaan ulang yang tidak akan pernah menagih dua kali, penanganan galat berdasarkan kode, dan klien bertipe untuk ASP.NET Core.

API yang dipakai adalah API OCR Paspor dan Kartu Identitas di balik doc.cheap. API ini sinkron: satu POST, satu jawaban, tanpa job id yang perlu di-polling.

Panggilan pertama, tanpa akun

Kunci sandbox publiknya adalah sk_sandbox_public. Kunci ini tidak memerlukan pendaftaran dan tidak memakai kredit. Batasnya 10 request per jam per alamat IP dan sejumlah kecil percobaan gratis seumur pakai, setelah itu jawabannya 403 registration_required.

Body request berupa JSON dengan satu field wajib, image: byte file dalam base64. JPEG dan PNG diterima; selain itu ditolak dengan 415 unsupported_media_type, yang diperiksa dari signature file, bukan dari namanya.

using System.Net.Http.Headers;
using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.doc.cheap/v1/") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "sk_sandbox_public");

var image = Convert.ToBase64String(await File.ReadAllBytesAsync("passport.jpg"));
using var response = await http.PostAsJsonAsync("scans", new { image });

Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());

Skema request-nya ketat. Kunci yang tidak dikenal di body ditolak dengan 422 validation_failed, jadi salah ketik pada nama opsi langsung gagal dengan jelas alih-alih diabaikan diam-diam.

Apa yang dikembalikan

Setiap jawaban punya delapan grup yang sama, dan setiap kunci selalu ada: meta, document, holder, fields, mrz, images, quality dan authenticity. Contoh ringkas yang nilainya disamarkan:

{
  "meta": {
    "schema_version": "1.0",
    "id": "<SCAN_ID>",
    "status": "recognized",
    "billed": true,
    "confidence": "high",
    "created_at": "<ISO_TIMESTAMP>",
    "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>" }
}

document dan holder bernilai null jika tidak ada yang terbaca. mrz.status bernilai passed, failed atau absent, dan passed berarti digit pemeriksanya cocok.

Record untuk jawabannya

JSON-nya memakai snake_case. .NET 8 sudah menyertakan JsonNamingPolicy.SnakeCaseLower, jadi record bisa tetap memakai nama C# yang lazim. Properti yang tidak dikenal diabaikan secara bawaan, jadi Anda cukup mendeklarasikan yang Anda pakai.

using System.Text.Json;

public static class Api
{
    public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };
}

public sealed record Scan(Meta Meta, DocumentInfo? Document, Holder? Holder, Mrz Mrz);

public sealed record Meta(
    string Id, string Status, bool Billed, string Confidence,
    string CreatedAt, string? Reference);

public sealed record DocumentInfo(
    string? Kind, string? Country, string? Number,
    string? IssueDate, string? ExpiryDate, bool? IsExpired, int? DaysRemaining);

public sealed record Holder(
    string? GivenNames, string? Surname, string? BirthDate,
    string? Sex, string? Nationality);

public sealed record Mrz(string Status, string? Reason, IReadOnlyList<string>? Lines, string? Text);

public sealed record ApiError(string Code, string Message, string? DocsUrl, string? RequestId);
public sealed record ErrorEnvelope(ApiError Error);

Tanggal sengaja dibiarkan sebagai string. Parse dengan DateOnly.TryParse di tempat Anda membutuhkannya, supaya satu nilai aneh pada sebuah dokumen tidak melempar exception di dalam deserializer.

Foto buram bukanlah exception

200 tidak selalu berarti dokumen berhasil dibaca. Bercabanglah berdasarkan meta.status:

meta.status Arti Apa yang dilakukan
recognized Jenis dokumen berhasil ditentukan dan datanya terbaca Gunakan document, holder, mrz
no_document_found Tidak ada yang berbentuk dokumen di bingkai Minta foto baru
unreadable Ada dokumen, tetapi tidak ada teks yang bisa dipakai Minta foto yang lebih tajam
unsupported_document Dokumen ditemukan, tetapi jenisnya tidak dikenal Beri tahu pengguna
rejected Pengenalan gagal di sisi layanan Catat meta.id di log dan coba lagi nanti

Siapa yang membayar: flag billed

Kunci sandbox tidak pernah memakai kredit. Pada kunci live, pindaian hanya ditagih jika memenuhi syarat: jenis dokumen berhasil ditentukan, dan setidaknya satu dari ini terbaca – MRZ yang digit pemeriksanya lolos, lima field atau lebih dari zona cetak, atau barcode yang terdekode. Jika tidak, kredit yang dicadangkan dilepas kembali. Pada kunci sandbox, meta.billed tetap menunjukkan apakah pindaian memenuhi aturan itu, tetapi tidak ada saldo yang terpakai.

meta.billed memberi tahu hasilnya. Simpan di samping data pesanan atau pengguna Anda sendiri, dan Anda tidak perlu lagi mencocokkan tagihan dengan menebak-nebak. Akun live juga mendapat 100 pengenalan gratis setiap bulan.

Percobaan ulang yang tidak bisa menagih dua kali

Timeout di sisi Anda tidak berarti pindaian tidak berjalan di sisi kami. Kirim header Idempotency-Key (1 sampai 255 karakter) dan pakai ulang untuk setiap percobaan ulang dokumen yang sama. Pada kunci live, server mencocokkan kunci tersebut bersama sidik jari body:

  • Kunci sama, body sama: hasil pertama diputar ulang, dan tidak ada kredit kedua yang terpakai.
  • Kunci sama, body berbeda: 409 idempotency_conflict. Gunakan kunci baru.
  • Panggilan pertama masih berjalan: panggilan kedua menunggu sebentar, lalu mendapat 409 idempotency_in_progress. Pertahankan kuncinya dan coba lagi.
  • Hasil pertama sudah tidak disimpan: 409 idempotency_replay_unavailable. Gunakan kunci baru.

Jika engine gagal setelah kredit dicadangkan, cadangannya dilepas dan kunci itu bebas untuk percobaan ulang yang sah. Detailnya ada di panduan mencoba ulang dengan aman memakai idempotensi.

Klien lengkapnya

Inilah versi yang layak disimpan. Klien ini hanya mencoba ulang kode yang memang layak dicoba ulang, mematuhi Retry-After hingga 60 detik, memakai satu kunci untuk semua percobaan, dan mengubah setiap galat lain menjadi exception yang membawa kodenya.

using System.Net.Http.Json;

public sealed class ScanFailed(int status, ApiError error)
    : Exception($"{status} {error.Code}: {error.Message}")
{
    public int Status { get; } = status;
    public ApiError Error { get; } = error;
}

public sealed class PassportClient(HttpClient http)
{
    static readonly HashSet<string> Retryable =
    [
        "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
        "service_unavailable", "maintenance", "idempotency_in_progress",
    ];

    public async Task<Scan> ReadAsync(
        byte[] imageBytes, string idempotencyKey, string? reference = null,
        CancellationToken ct = default)
    {
        var body = new
        {
            image = Convert.ToBase64String(imageBytes),
            reference,
            options = new { return_portrait = false },
        };

        for (var attempt = 0; ; attempt++)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, "scans")
            {
                Content = JsonContent.Create(body),
            };
            request.Headers.Add("Idempotency-Key", idempotencyKey);

            using var response = await http.SendAsync(request, ct);
            if (response.IsSuccessStatusCode)
                return (await response.Content.ReadFromJsonAsync<Scan>(Api.Json, ct))!;

            var envelope = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Api.Json, ct);
            var error = envelope?.Error
                ?? new ApiError("unknown", response.ReasonPhrase ?? "", null, null);

            if (!Retryable.Contains(error.Code) || attempt >= 3)
                throw new ScanFailed((int)response.StatusCode, error);

            var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1 << attempt);
            await Task.Delay(wait > TimeSpan.FromSeconds(60) ? TimeSpan.FromSeconds(60) : wait, ct);
        }
    }
}

Kuncinya sebaiknya berasal dari sistem Anda sendiri – id unggahan atau id pesanan – bukan dari Guid.NewGuid() di dalam method. Kunci dari data Anda tetap sama setelah proses di-restart; GUID baru tidak.

Setiap galat punya bentuk yang sama: code, message, docs_url dan request_id. Bercabanglah berdasarkan code, jangan pernah berdasarkan message, dan catat request_id saat Anda menghubungi tim dukungan. Daftar lengkapnya ada di panduan menangani galat. Tiga yang akan cepat Anda temui:

  • 402 insufficient_credits – saldo live habis. Tidak bisa dicoba ulang.
  • 413 payload_too_large – body melebihi batas. Perkecil ukuran fotonya.
  • 401 unauthorized – kunci tidak ada atau tidak diterima. Tidak bisa dicoba ulang.

Klien bertipe dengan IHttpClientFactory

Jangan membuat HttpClient baru untuk setiap request. Di ASP.NET Core, daftarkan kliennya sekali dan biarkan factory yang mengelola handler-nya:

builder.Services.AddHttpClient<PassportClient>(http =>
{
    http.BaseAddress = new Uri("https://api.doc.cheap/v1/");
    http.Timeout = TimeSpan.FromSeconds(60);
    http.DefaultRequestHeaders.Authorization = new("Bearer",
        builder.Configuration["DocCheap:ApiKey"]);
});

Simpan kuncinya di konfigurasi atau secret store, jangan pernah di kode sumber. Kunci live diawali sk_live_, kunci sandbox diawali sk_sandbox_. Jika Anda menambahkan resilience handler dari Microsoft.Extensions.Http.Resilience, biarkan hanya satu lapisan yang mencoba ulang; dua lapisan akan melipatgandakan jumlah percobaan.

Menyimpan lebih sedikit data

Jangan simpan gambarnya sendiri setelah panggilan selesai. Ambil yang Anda butuhkan dari hasilnya lalu buang byte-nya. Klien di atas sudah mengirim return_portrait: false, jadi potongan foto wajah tidak ikut dikembalikan dalam jawaban.

Pada kunci live, hasil disimpan selama jangka waktu riwayat akun Anda, satu tahun secara bawaan. Agar tidak ada yang disimpan untuk satu panggilan, kirim retain_hours: 0 di options: tidak ada baris yang ditulis dan tidak ada thumbnail yang dibuat. Konsekuensinya, percobaan ulang panggilan itu tidak bisa diputar ulang dan kembali sebagai idempotency_replay_unavailable. Pilih jangka waktu pendek di atas nol jika Anda menginginkan keduanya. Panduan pengenalan paspor membahas setiap opsi.

Beralih ke live

Ganti sk_sandbox_public dengan kunci sk_live_ Anda, dan tidak ada hal lain yang berubah. Kunci terdaftar dibatasi 60 request per menit. 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 .NET, atau Anda memercayakannya pada sebuah layanan? Ceritakan di kolom komentar di bawah.