Sie haben das Foto eines Reisepasses und einen .NET-8-Dienst, der Name, Nummer, Daten und die maschinenlesbare Zone (MRZ) als Daten braucht. Dieser Beitrag baut den Client in reinem C# 12: HttpClient, System.Net.Http.Json und System.Text.Json, ohne SDK. Er behandelt den ersten Aufruf, Records für die Antwort, die Status, die keine Fehler sind, Wiederholungen, die niemals doppelt abrechnen, Fehlerbehandlung per Code und einen typisierten Client für ASP.NET Core.

Die API ist die OCR-API für Reisepässe und Ausweise hinter doc.cheap. Sie ist synchron: ein POST, eine Antwort, keine Job-ID zum Abfragen.

Der erste Aufruf, ohne Konto

Der öffentliche Sandbox-Schlüssel ist sk_sandbox_public. Er braucht keine Registrierung und verbraucht kein Guthaben. Er ist auf 10 Anfragen pro Stunde und IP-Adresse sowie auf eine kleine Gesamtzahl kostenloser Versuche begrenzt; danach antwortet er mit 403 registration_required.

Der Request-Body ist JSON mit einem einzigen Pflichtfeld, image: die Bytes der Datei als Base64. JPEG und PNG werden akzeptiert; alles andere wird mit 415 unsupported_media_type abgelehnt, geprüft anhand der Dateisignatur und nicht des Namens.

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

Das Request-Schema ist streng. Ein unbekannter Schlüssel im Body wird mit 422 validation_failed abgelehnt, sodass ein Tippfehler im Namen einer Option laut scheitert, statt stillschweigend ignoriert zu werden.

Was zurückkommt

Jede Antwort hat dieselben acht Gruppen, und jeder Schlüssel ist immer vorhanden: meta, document, holder, fields, mrz, images, quality und authenticity. Ein gekürztes, maskiertes Beispiel:

{
  "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 und holder sind null, wenn nichts gelesen wurde. mrz.status ist passed, failed oder absent, und passed bedeutet, dass die Prüfziffern stimmen.

Records für die Antwort

Das JSON ist in snake_case. .NET 8 bringt JsonNamingPolicy.SnakeCaseLower mit, sodass die Records normale C#-Namen behalten können. Unbekannte Eigenschaften werden standardmäßig ignoriert, Sie deklarieren also nur, was Sie verwenden.

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

Die Datumswerte bleiben hier absichtlich Strings. Parsen Sie sie mit DateOnly.TryParse dort, wo Sie sie brauchen, damit ein ungewöhnlicher Wert auf einem Dokument keine Exception im Deserializer auslöst.

Ein unscharfes Foto ist keine Exception

Ein 200 bedeutet nicht immer, dass ein Dokument gelesen wurde. Verzweigen Sie nach meta.status:

meta.status Was es bedeutet Was zu tun ist
recognized Der Dokumenttyp wurde bestimmt und Daten wurden ausgelesen document, holder, mrz verwenden
no_document_found Nichts Dokumentähnliches im Bild Um ein neues Foto bitten
unreadable Ein Dokument war da, aber es wurde kein brauchbarer Text gelesen Um ein schärferes Foto bitten
unsupported_document Ein Dokument wurde gefunden, aber sein Typ ist nicht bekannt Den Nutzer informieren
rejected Die Erkennung ist auf Seiten des Dienstes fehlgeschlagen meta.id loggen und später erneut versuchen

Wer zahlt: das billed-Flag

Sandbox-Schlüssel verbrauchen nie Guthaben. Mit einem Live-Schlüssel wird ein Scan nur berechnet, wenn er abrechenbar ist: Der Dokumenttyp wurde bestimmt, und mindestens eines davon wurde gelesen – eine MRZ mit gültigen Prüfziffern, fünf oder mehr Felder aus der Druckzone oder ein dekodierter Barcode. Andernfalls wird das reservierte Guthaben wieder freigegeben. Mit einem Sandbox-Schlüssel zeigt meta.billed trotzdem an, ob der Scan diese Regel erfüllt hat, aber kein Guthaben wird angetastet.

meta.billed sagt Ihnen, welcher Fall eingetreten ist. Speichern Sie es neben Ihrer eigenen Bestellung oder Ihrem Nutzer, dann müssen Sie Abbuchungen nie per Raten abgleichen. Ein Live-Konto erhält außerdem 100 kostenlose Erkennungen pro Monat.

Wiederholungen, die nicht doppelt abrechnen können

Ein Timeout auf Ihrer Seite heißt nicht, dass der Scan auf unserer Seite nicht gelaufen ist. Senden Sie einen Idempotency-Key-Header (1 bis 255 Zeichen) und verwenden Sie ihn für jede Wiederholung desselben Dokuments erneut. Mit einem Live-Schlüssel vergleicht der Server den Schlüssel zusammen mit einem Fingerabdruck des Bodys:

  • Gleicher Schlüssel, gleicher Body: Das erste Ergebnis wird erneut ausgeliefert, und es wird kein zweites Guthaben verbraucht.
  • Gleicher Schlüssel, anderer Body: 409 idempotency_conflict. Verwenden Sie einen neuen Schlüssel.
  • Der erste Aufruf läuft noch: Der zweite wartet kurz und erhält dann 409 idempotency_in_progress. Behalten Sie den Schlüssel und versuchen Sie es erneut.
  • Das erste Ergebnis ist nicht mehr gespeichert: 409 idempotency_replay_unavailable. Verwenden Sie einen neuen Schlüssel.

Schlägt die Engine fehl, nachdem das Guthaben reserviert wurde, wird die Reservierung freigegeben, und der Schlüssel ist frei für eine ehrliche Wiederholung. Die Details stehen im Leitfaden zu sicheren Wiederholungen mit Idempotenz.

Der vollständige Client

Das ist die Version zum Behalten. Sie wiederholt nur Codes, bei denen sich eine Wiederholung lohnt, beachtet Retry-After bis zu 60 Sekunden, behält einen Schlüssel über alle Versuche hinweg und macht aus jedem anderen Fehler eine Exception, die den Code trägt.

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

Der Schlüssel sollte aus Ihrem eigenen System stammen – eine Upload-ID oder eine Bestell-ID – und nicht aus Guid.NewGuid() innerhalb der Methode. Ein Schlüssel aus Ihren Daten überlebt einen Neustart des Prozesses; eine frische GUID nicht.

Jeder Fehler hat dieselbe Form: code, message, docs_url und request_id. Verzweigen Sie nach code, nie nach message, und notieren Sie die request_id, wenn Sie den Support anschreiben. Die vollständige Liste steht im Leitfaden zur Fehlerbehandlung. Drei, die Ihnen früh begegnen:

  • 402 insufficient_credits – das Live-Guthaben ist aufgebraucht. Nicht wiederholbar.
  • 413 payload_too_large – der Body überschreitet das Limit. Verkleinern Sie das Foto.
  • 401 unauthorized – der Schlüssel fehlt oder wird nicht akzeptiert. Nicht wiederholbar.

Ein typisierter Client mit IHttpClientFactory

Erzeugen Sie nicht für jede Anfrage einen neuen HttpClient. Registrieren Sie den Client in ASP.NET Core einmal und lassen Sie die Factory die Handler verwalten:

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

Bewahren Sie den Schlüssel in der Konfiguration oder einem Secret Store auf, nie im Quellcode. Ein Live-Schlüssel beginnt mit sk_live_, ein Sandbox-Schlüssel mit sk_sandbox_. Wenn Sie einen Resilience-Handler aus Microsoft.Extensions.Http.Resilience hinzufügen, lassen Sie nur eine Schicht wiederholen; zwei Schichten multiplizieren die Versuche.

Weniger Daten aufbewahren

Speichern Sie das Bild nach dem Aufruf nicht selbst. Lesen Sie aus dem Ergebnis, was Sie brauchen, und verwerfen Sie die Bytes. Der Client oben sendet bereits return_portrait: false, sodass der Porträtausschnitt nicht in der Antwort zurückkommt.

Mit einem Live-Schlüssel wird das Ergebnis für das Verlaufsfenster Ihres Kontos aufbewahrt, standardmäßig ein Jahr. Um für einen Aufruf nichts aufzubewahren, senden Sie retain_hours: 0 in options: Es wird keine Zeile geschrieben und kein Vorschaubild erzeugt. Der Preis dafür: Eine Wiederholung dieses Aufrufs kann nicht erneut ausgeliefert werden und kommt als idempotency_replay_unavailable zurück. Wählen Sie ein kurzes Fenster über null, wenn Sie beides wollen. Der Leitfaden zur Reisepass-Erkennung behandelt jede Option.

Live gehen

Tauschen Sie sk_sandbox_public gegen Ihren sk_live_-Schlüssel, und sonst ändert sich nichts. Registrierte Schlüssel sind auf 60 Anfragen pro Minute begrenzt. Wenn Sie noch einen Anbieter suchen, listet der Vergleich von Reisepass-OCR-APIs kostenpflichtige Alternativen mit ihren veröffentlichten Preisen.

Eine Frage an Sie: Parsen Sie die MRZ heute selbst in .NET, oder vertrauen Sie dafür einem Dienst? Erzählen Sie es uns unten in den Kommentaren.