Hai la foto di un passaporto e un servizio .NET 8 che ha bisogno di nome, numero, date e zona a lettura ottica (MRZ) come dati. Questo articolo costruisce il client in C# 12 puro: HttpClient, System.Net.Http.Json e System.Text.Json, senza SDK. Copre la prima chiamata, i record per la risposta, gli stati che non sono errori, i retry che non possono mai addebitare due volte, la gestione degli errori per codice e un client tipizzato per ASP.NET Core.

L'API è l'API OCR per passaporti e documenti d'identità di doc.cheap. È sincrona: un POST, una risposta, nessun id di job da interrogare.

La prima chiamata, senza account

La chiave sandbox pubblica è sk_sandbox_public. Non richiede registrazione e non consuma crediti. È limitata a 10 richieste all'ora per indirizzo IP e a un piccolo numero complessivo di tentativi gratuiti, esauriti i quali risponde 403 registration_required.

Il corpo della richiesta è JSON con un solo campo obbligatorio, image: i byte del file in base64. Sono accettati JPEG e PNG; qualsiasi altro formato viene rifiutato con 415 unsupported_media_type, verificato in base alla firma del file e non al nome.

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

Lo schema della richiesta è rigido. Una chiave sconosciuta nel corpo viene rifiutata con 422 validation_failed, quindi un refuso nel nome di un'opzione fallisce in modo evidente invece di essere ignorato.

Che cosa torna indietro

Ogni risposta ha gli stessi otto gruppi, e ogni chiave è sempre presente: meta, document, holder, fields, mrz, images, quality e authenticity. Un esempio ridotto e mascherato:

{
  "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 e holder sono null quando non è stato letto nulla. mrz.status vale passed, failed o absent, e passed significa che le cifre di controllo corrispondono.

Record per la risposta

Il JSON è in snake_case. .NET 8 include JsonNamingPolicy.SnakeCaseLower, quindi i record possono mantenere nomi C# normali. Le proprietà sconosciute vengono ignorate per impostazione predefinita, così dichiari solo ciò che usi.

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

Qui le date restano stringhe di proposito. Convertile con DateOnly.TryParse dove ti servono, così un valore anomalo su un documento non solleva un'eccezione dentro il deserializzatore.

Una foto sfocata non è un'eccezione

Un 200 non significa sempre che un documento è stato letto. Dirama in base a meta.status:

meta.status Che cosa significa Che cosa fare
recognized Il tipo di documento è stato determinato e ne sono stati estratti dati Usa document, holder, mrz
no_document_found Nell'inquadratura non c'era nulla che somigliasse a un documento Chiedi una nuova foto
unreadable C'era un documento, ma non è stato letto testo utilizzabile Chiedi una foto più nitida
unsupported_document È stato trovato un documento, ma il suo tipo non è tra quelli noti Avvisa l'utente
rejected Il riconoscimento è fallito lato servizio Registra meta.id e riprova più tardi

Chi paga: il flag billed

Le chiavi sandbox non consumano mai crediti. Con una chiave live, una scansione viene addebitata solo quando è fatturabile: il tipo di documento è stato determinato e almeno uno di questi elementi è stato letto – una MRZ con cifre di controllo valide, cinque o più campi della zona stampata, oppure un codice a barre decodificato. Altrimenti il credito riservato viene rilasciato. Con una chiave sandbox, meta.billed indica comunque se la scansione ha soddisfatto questa regola, ma nessun saldo viene toccato.

meta.billed ti dice com'è andata. Salvalo accanto al tuo ordine o al tuo utente e non dovrai mai riconciliare gli addebiti andando a intuito. Un account live riceve inoltre 100 riconoscimenti gratuiti ogni mese.

Retry che non possono addebitare due volte

Un timeout dalla tua parte non significa che la scansione non sia stata eseguita dalla nostra. Invia un header Idempotency-Key (da 1 a 255 caratteri) e riusalo per ogni retry dello stesso documento. Con una chiave live, il server confronta la chiave insieme a un'impronta del corpo:

  • Stessa chiave, stesso corpo: viene restituito il primo risultato e non si consuma un secondo credito.
  • Stessa chiave, corpo diverso: 409 idempotency_conflict. Usa una chiave nuova.
  • La prima chiamata è ancora in corso: la seconda attende un attimo, poi riceve 409 idempotency_in_progress. Tieni la chiave e riprova.
  • Il primo risultato non è più conservato: 409 idempotency_replay_unavailable. Usa una chiave nuova.

Se il motore fallisce dopo che il credito è stato riservato, la prenotazione viene rilasciata e la chiave resta libera per un retry legittimo. I dettagli sono nella guida per ripetere le chiamate in sicurezza con l'idempotenza.

Il client completo

Questa è la versione da tenere. Ripete solo i codici per cui vale la pena farlo, rispetta Retry-After fino a 60 secondi, mantiene una sola chiave tra i tentativi e trasforma ogni altro errore in un'eccezione che porta con sé il codice.

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

La chiave dovrebbe arrivare dal tuo sistema – un id di upload o un id d'ordine – e non da Guid.NewGuid() dentro il metodo. Una chiave presa dai tuoi dati sopravvive al riavvio del processo; un GUID nuovo no.

Ogni errore ha la stessa forma: code, message, docs_url e request_id. Dirama in base a code, mai a message, e annota il request_id quando scrivi al supporto. L'elenco completo è nella guida alla gestione degli errori. Tre che incontrerai presto:

  • 402 insufficient_credits – il saldo live è esaurito. Non va ripetuto.
  • 413 payload_too_large – il corpo supera il limite. Riduci la foto.
  • 401 unauthorized – la chiave manca o non è accettata. Non va ripetuto.

Un client tipizzato con IHttpClientFactory

Non creare un nuovo HttpClient per ogni richiesta. In ASP.NET Core registra il client una volta e lascia che la factory gestisca gli handler:

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

Tieni la chiave nella configurazione o in un secret store, mai nel codice sorgente. Una chiave live inizia con sk_live_, una sandbox con sk_sandbox_. Se aggiungi un handler di resilienza da Microsoft.Extensions.Http.Resilience, lascia che a ripetere sia un solo livello; due livelli moltiplicano i tentativi.

Conservare meno dati

Non salvare tu l'immagine dopo la chiamata. Leggi dal risultato ciò che ti serve e scarta i byte. Il client qui sopra invia già return_portrait: false, quindi il ritaglio del ritratto non torna nella risposta.

Con una chiave live, il risultato viene conservato per la finestra di cronologia del tuo account, un anno per impostazione predefinita. Per non conservare nulla in una singola chiamata, invia retain_hours: 0 in options: non viene scritta alcuna riga né creata alcuna miniatura. Il compromesso è che un retry di quella chiamata non può essere riproposto e torna come idempotency_replay_unavailable. Scegli una finestra breve superiore a zero se vuoi entrambe le cose. La guida al riconoscimento del passaporto copre ogni opzione.

Andare in produzione

Sostituisci sk_sandbox_public con la tua chiave sk_live_ e non cambia nient'altro. Le chiavi registrate sono limitate a 60 richieste al minuto. Se stai ancora scegliendo un fornitore, il confronto tra API OCR per passaporti elenca le alternative a pagamento con i prezzi che pubblicano.

Una domanda per te: oggi analizzi la MRZ da solo in .NET o ti affidi a un servizio? Raccontacelo nei commenti qui sotto.