Você tem a foto de um passaporte e um serviço .NET 8 que precisa do nome, do número, das datas e da zona de leitura mecânica (MRZ) como dados. Este post monta esse cliente em C# 12 puro: HttpClient, System.Net.Http.Json e System.Text.Json, sem SDK. Ele cobre a primeira chamada, records para a resposta, os status que não são erros, retentativas que nunca cobram duas vezes, tratamento de erros por código e um cliente tipado para ASP.NET Core.

A API é a API de OCR de passaportes e documentos de identidade do doc.cheap. Ela é síncrona: um POST, uma resposta, nenhum id de tarefa para consultar.

A primeira chamada, sem conta

A chave pública de sandbox é sk_sandbox_public. Ela não exige cadastro e não consome créditos. É limitada a 10 requisições por hora por endereço IP e a um pequeno número total de tentativas gratuitas; depois disso, responde 403 registration_required.

O corpo da requisição é JSON com um único campo obrigatório, image: os bytes do arquivo em base64. JPEG e PNG são aceitos; qualquer outro formato é recusado com 415 unsupported_media_type, verificado pela assinatura do arquivo e não pelo 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());

O schema da requisição é estrito. Uma chave desconhecida no corpo é recusada com 422 validation_failed, então um erro de digitação no nome de uma opção falha de forma visível em vez de ser ignorado.

O que volta

Toda resposta tem os mesmos oito grupos, e todas as chaves estão sempre presentes: meta, document, holder, fields, mrz, images, quality e authenticity. Um exemplo resumido e mascarado:

{
  "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 são null quando nada foi lido. mrz.status é passed, failed ou absent, e passed significa que os dígitos verificadores conferem.

Records para a resposta

O JSON está em snake_case. O .NET 8 traz JsonNamingPolicy.SnakeCaseLower, então os records podem manter nomes normais de C#. Propriedades desconhecidas são ignoradas por padrão, então você só declara o que usa.

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

Aqui as datas continuam como strings de propósito. Converta-as com DateOnly.TryParse onde precisar, para que um valor estranho em um documento não lance exceção dentro do desserializador.

Uma foto borrada não é uma exceção

Um 200 nem sempre significa que um documento foi lido. Ramifique por meta.status:

meta.status O que significa O que fazer
recognized O tipo de documento foi determinado e dados foram extraídos Use document, holder, mrz
no_document_found Não havia nada com forma de documento no enquadramento Peça uma nova foto
unreadable Havia um documento, mas nenhum texto utilizável foi lido Peça uma foto mais nítida
unsupported_document Um documento foi encontrado, mas o tipo não é conhecido Avise o usuário
rejected O reconhecimento falhou do lado do serviço Registre meta.id e tente de novo mais tarde

Quem paga: o indicador billed

Chaves de sandbox nunca consomem créditos. Com uma chave live, um scan só é cobrado quando é faturável: o tipo de documento foi determinado e pelo menos um destes itens foi lido – uma MRZ cujos dígitos verificadores conferem, cinco ou mais campos da zona impressa ou um código de barras decodificado. Caso contrário, o crédito reservado é liberado. Com uma chave de sandbox, meta.billed continua indicando se o scan cumpriu essa regra, mas nenhum saldo é tocado.

meta.billed diz qual foi o caso. Guarde-o junto ao seu próprio pedido ou usuário e você nunca precisará conciliar cobranças no chute. Uma conta live também recebe 100 reconhecimentos gratuitos todo mês.

Retentativas que não podem cobrar duas vezes

Um timeout do seu lado não significa que o scan não rodou do nosso. Envie um cabeçalho Idempotency-Key (de 1 a 255 caracteres) e reutilize-o em cada retentativa do mesmo documento. Com uma chave live, o servidor compara a chave junto com uma impressão digital do corpo:

  • Mesma chave, mesmo corpo: o primeiro resultado é reenviado e nenhum segundo crédito é consumido.
  • Mesma chave, corpo diferente: 409 idempotency_conflict. Use uma chave nova.
  • A primeira chamada ainda está em andamento: a segunda espera um pouco e depois recebe 409 idempotency_in_progress. Mantenha a chave e tente de novo.
  • O primeiro resultado não está mais armazenado: 409 idempotency_replay_unavailable. Use uma chave nova.

Se o mecanismo de reconhecimento falhar depois que o crédito foi reservado, a reserva é liberada e a chave fica livre para uma retentativa legítima. Os detalhes estão no guia sobre retentativas seguras com idempotência.

O cliente completo

Esta é a versão para guardar. Ela só repete os códigos que vale a pena repetir, respeita Retry-After até 60 segundos, mantém uma única chave entre as tentativas e transforma qualquer outro erro em uma exceção que carrega o código.

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

A chave deve vir do seu próprio sistema – um id de upload ou de pedido – e não de Guid.NewGuid() dentro do método. Uma chave tirada dos seus dados sobrevive a um reinício do processo; um GUID novo, não.

Todo erro tem o mesmo formato: code, message, docs_url e request_id. Ramifique por code, nunca por message, e registre o request_id ao escrever para o suporte. A lista completa está no guia de tratamento de erros. Três que você vai encontrar cedo:

  • 402 insufficient_credits – o saldo live acabou. Não vale repetir.
  • 413 payload_too_large – o corpo passa do limite. Reduza a foto.
  • 401 unauthorized – a chave está ausente ou não foi aceita. Não vale repetir.

Um cliente tipado com IHttpClientFactory

Não crie um HttpClient novo a cada requisição. No ASP.NET Core, registre o cliente uma vez e deixe a factory gerenciar os handlers:

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

Guarde a chave na configuração ou em um cofre de segredos, nunca no código-fonte. Uma chave live começa com sk_live_, uma de sandbox com sk_sandbox_. Se você adicionar um handler de resiliência do Microsoft.Extensions.Http.Resilience, deixe apenas uma camada fazer retentativas; duas camadas multiplicam as tentativas.

Guardando menos dados

Não guarde a imagem você mesmo depois da chamada. Leia o que precisa do resultado e descarte os bytes. O cliente acima já envia return_portrait: false, então o recorte do retrato não volta na resposta.

Com uma chave live, o resultado fica guardado pelo período de histórico da sua conta, um ano por padrão. Para não guardar nada em uma chamada, envie retain_hours: 0 em options: nenhuma linha é gravada e nenhuma miniatura é gerada. A contrapartida é que uma retentativa dessa chamada não pode ser reenviada e volta como idempotency_replay_unavailable. Escolha um período curto acima de zero se quiser as duas coisas. O guia de reconhecimento de passaportes cobre todas as opções.

Indo para produção

Troque sk_sandbox_public pela sua chave sk_live_ e nada mais muda. Chaves registradas são limitadas a 60 requisições por minuto. Se você ainda está escolhendo um fornecedor, a comparação de APIs de OCR de passaporte lista alternativas pagas com os preços que elas publicam.

Uma pergunta para você: hoje você interpreta a MRZ por conta própria no .NET ou confia em um serviço para isso? Conte para a gente nos comentários abaixo.