手元にパスポートの写真があり、.NET 8のサービスで氏名、旅券番号、日付、そして機械読み取り領域(MRZ)をデータとして扱いたい。この記事では、そのためのクライアントを素のC# 12で作ります。使うのは HttpClient、System.Net.Http.Json、System.Text.Json だけで、SDKは使いません。最初の呼び出し、レスポンスを受けるレコード型、エラーではないステータス、二重課金にならないリトライ、コードによるエラー処理、そしてASP.NET Core向けの型付きクライアントまでを扱います。

使うAPIは、doc.cheapのパスポート・身分証OCR APIです。同期型なので、POST を1回送れば答えが1回返ってきます。ポーリングするジョブIDはありません。

アカウントなしで最初の呼び出し

公開sandboxキーは sk_sandbox_public です。登録は不要で、クレジットも消費しません。上限はIPアドレスごとに1時間10リクエスト、さらに通算で少数の無料トライまでで、それを超えると 403 registration_required が返ります。

リクエストボディはJSONで、必須フィールドは image の1つだけです。ファイルのバイト列をbase64にしたものを入れます。受け付けるのはJPEGとPNGで、それ以外は 415 unsupported_media_type で拒否されます。判定はファイル名ではなく、ファイルのシグネチャで行われます。

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());

リクエストのスキーマは厳格です。ボディに未知のキーがあると 422 validation_failed で拒否されるので、オプション名のタイプミスは黙って無視されず、はっきり失敗します。

返ってくるもの

どのレスポンスも同じ8つのグループを持ち、キーは常にすべて存在します。meta、document、holder、fields、mrz、images、quality、authenticity です。一部を省略し、値を伏せた例を示します。

{
  "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 と holder は null になります。mrz.status は passed、failed、absent のいずれかで、passed はチェックディジットが一致したことを意味します。

レスポンスを受けるレコード型

JSONはsnake_caseです。.NET 8には JsonNamingPolicy.SnakeCaseLower が含まれているので、レコード型は普通のC#の名前のままで構いません。未知のプロパティは既定で無視されるため、使うものだけを宣言すれば十分です。

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

日付はあえて文字列のままにしています。必要な箇所で DateOnly.TryParse を使ってパースすれば、書類に変わった値が1つあってもデシリアライザーの中で例外になりません。

ぼやけた写真は例外ではない

200 が返っても、書類が読み取れたとは限りません。meta.status で分岐します。

meta.status 意味 対応
recognized 書類の種類が特定され、データが取り出せた document、holder、mrz を使う
no_document_found 画像内に書類らしきものがなかった 新しい写真を依頼する
unreadable 書類はあったが、使える文字が読み取れなかった もっと鮮明な写真を依頼する
unsupported_document 書類は見つかったが、既知の種類ではない ユーザーに伝える
rejected サービス側で認識に失敗した meta.id をログに残し、後でリトライする

誰が払うのか:billed フラグ

sandboxキーでは、クレジットは一切消費されません。本番キーでは、課金対象のスキャンだけが課金されます。条件は、書類の種類が特定され、さらに次のいずれかが読み取れたことです。チェックディジットが一致するMRZ、印字領域の5つ以上のフィールド、またはデコードできたバーコード。それ以外の場合、予約されたクレジットは解放されます。 sandboxキーでも、meta.billed はスキャンがこの条件を満たしたかどうかを示しますが、残高には一切触れません。

どちらになったかは meta.billed でわかります。自社の注文やユーザーの記録と一緒に保存しておけば、課金を推測で突き合わせる必要はありません。本番アカウントには、毎月100件の無料認識も付いています。

二重課金にならないリトライ

こちら側でタイムアウトしても、サーバー側でスキャンが実行されなかったとは限りません。Idempotency-Key ヘッダー(1~255文字)を送り、同じ書類のリトライではすべて同じ値を使い回してください。本番キーでは、サーバーはキーとボディのフィンガープリントを組み合わせて照合します。

  • 同じキー、同じボディ:最初の結果がそのまま返され、2回目のクレジットは消費されません。
  • 同じキー、違うボディ:409 idempotency_conflict。新しいキーを使ってください。
  • 最初の呼び出しがまだ処理中:2回目は少し待ってから 409 idempotency_in_progress を受け取ります。キーはそのままでリトライしてください。
  • 最初の結果がもう保存されていない:409 idempotency_replay_unavailable。新しいキーを使ってください。

クレジットの予約後に認識エンジンが失敗した場合は、予約が解放され、キーも正当なリトライのために空きます。詳しくは冪等性を使った安全なリトライのガイドを参照してください。

クライアントの全体

これが手元に残しておくべきバージョンです。リトライする価値のあるコードだけをリトライし、Retry-After には60秒まで従い、すべての試行で同じキーを使い、それ以外のエラーはすべてコードを持つ例外に変換します。

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

キーはメソッド内の Guid.NewGuid() ではなく、アップロードIDや注文IDなど自社システムの値から作ってください。自社データから作ったキーはプロセスが再起動しても残りますが、その場で作ったGUIDは残りません。

エラーはすべて同じ形で、code、message、docs_url、request_id を持ちます。分岐は message ではなく必ず code で行い、サポートに問い合わせるときは request_id をログに残しておいてください。一覧はエラー処理のガイドにあります。早い段階で出会うのは次の3つです。

  • 402 insufficient_credits – 本番の残高が尽きた。リトライ不可。
  • 413 payload_too_large – ボディが上限を超えている。写真を縮小する。
  • 401 unauthorized – キーがない、または受け付けられない。リトライ不可。

IHttpClientFactoryで型付きクライアントにする

リクエストごとに新しい HttpClient を作らないでください。ASP.NET Coreでは、クライアントを一度だけ登録し、ハンドラーの管理はファクトリに任せます。

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"]);
});

キーは構成やシークレットストアに置き、ソースコードには書かないでください。本番キーは sk_live_、sandboxキーは sk_sandbox_ で始まります。Microsoft.Extensions.Http.Resilience のレジリエンスハンドラーを追加する場合、リトライするのは1つの層だけにしてください。2つの層でリトライすると、試行回数が掛け算で増えます。

保存するデータを減らす

呼び出しのあと、画像を自分で保存しないでください。結果から必要なものだけを読み取り、バイト列は捨てます。上のクライアントはすでに return_portrait: false を送っているので、顔写真の切り抜きはレスポンスに含まれません。

本番キーでは、結果はアカウントの履歴保存期間(既定は1年)の間保存されます。ある呼び出しについて何も残したくない場合は、options に retain_hours: 0 を入れて送ります。行は書き込まれず、サムネイルも作られません。その代わり、その呼び出しのリトライは結果を再送できず、idempotency_replay_unavailable が返ります。両方を取りたいなら、0より大きい短い期間を選んでください。すべてのオプションはパスポート認識のガイドにまとまっています。

本番への移行

sk_sandbox_public を自分の sk_live_ キーに置き換えるだけで、ほかは何も変わりません。登録済みのキーは1分あたり60リクエストまでです。まだサービスを選んでいる段階なら、パスポートOCR APIの比較で、有料の選択肢とそれぞれが公開している料金を確認できます。

**最後に質問です。**いま.NETでは、MRZを自分でパースしていますか。それともサービスに任せていますか。下のコメント欄で教えてください。