Tienes la foto de un pasaporte y un servicio .NET 8 que necesita el nombre, el número, las fechas y la zona de lectura mecánica (MRZ) como datos. Este artículo construye ese cliente en C# 12 puro: HttpClient, System.Net.Http.Json y System.Text.Json, sin SDK. Cubre la primera llamada, los records para la respuesta, los estados que no son errores, los reintentos que nunca pueden cobrar dos veces, el manejo de errores por código y un cliente tipado para ASP.NET Core.

La API es la API OCR de pasaportes y documentos de identidad de doc.cheap. Es síncrona: un POST, una respuesta, ningún id de tarea que consultar.

La primera llamada, sin cuenta

La clave pública de sandbox es sk_sandbox_public. No requiere registro y no consume créditos. Está limitada a 10 peticiones por hora por dirección IP y a un número reducido de intentos gratuitos en total; agotados estos, responde 403 registration_required.

El cuerpo de la petición es JSON con un único campo obligatorio, image: los bytes del archivo en base64. Se aceptan JPEG y PNG; cualquier otro formato se rechaza con 415 unsupported_media_type, que se comprueba por la firma del archivo y no por su nombre.

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

El esquema de la petición es estricto. Una clave desconocida en el cuerpo se rechaza con 422 validation_failed, así que una errata en el nombre de una opción falla de forma visible en lugar de ignorarse.

Qué recibes

Todas las respuestas tienen los mismos ocho grupos, y todas las claves están siempre presentes: meta, document, holder, fields, mrz, images, quality y authenticity. Un ejemplo abreviado y enmascarado:

{
  "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 y holder son null cuando no se leyó nada. mrz.status es passed, failed o absent, y passed significa que los dígitos de control coinciden.

Records para la respuesta

El JSON usa snake_case. .NET 8 incluye JsonNamingPolicy.SnakeCaseLower, así que los records pueden conservar nombres normales de C#. Las propiedades desconocidas se ignoran por defecto, de modo que solo declaras lo que usas.

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

Aquí las fechas se quedan como cadenas a propósito. Conviértelas con DateOnly.TryParse donde las necesites, para que un valor extraño en un documento no lance una excepción dentro del deserializador.

Una foto borrosa no es una excepción

Un 200 no siempre significa que se leyó un documento. Ramifica según meta.status:

meta.status Qué significa Qué hacer
recognized Se determinó el tipo de documento y se extrajeron datos Usa document, holder, mrz
no_document_found No había nada con forma de documento en el encuadre Pide una foto nueva
unreadable Había un documento, pero no se leyó texto utilizable Pide una foto más nítida
unsupported_document Se encontró un documento, pero su tipo no es uno conocido Avisa al usuario
rejected El reconocimiento falló en el lado del servicio Registra meta.id y vuelve a intentarlo más tarde

Quién paga: el indicador billed

Las claves de sandbox nunca consumen créditos. Con una clave live, un escaneo se cobra solo cuando es facturable: se determinó el tipo de documento y se leyó al menos una de estas cosas – una MRZ cuyos dígitos de control son correctos, cinco o más campos de la zona impresa o un código de barras decodificado. En caso contrario, el crédito reservado se libera. Con una clave de sandbox, meta.billed sigue indicando si el escaneo cumplió esa regla, pero no se toca ningún saldo.

meta.billed te dice qué ocurrió. Guárdalo junto a tu propio pedido o usuario y nunca tendrás que conciliar cargos a ciegas. Una cuenta live recibe además 100 reconocimientos gratis cada mes.

Reintentos que no pueden cobrar dos veces

Que se agote el tiempo de espera en tu lado no significa que el escaneo no se ejecutara en el nuestro. Envía una cabecera Idempotency-Key (de 1 a 255 caracteres) y reutilízala en cada reintento del mismo documento. Con una clave live, el servidor compara la clave junto con una huella del cuerpo:

  • Misma clave, mismo cuerpo: se devuelve el primer resultado y no se consume un segundo crédito.
  • Misma clave, cuerpo distinto: 409 idempotency_conflict. Usa una clave nueva.
  • La primera llamada sigue en curso: la segunda espera un momento y luego recibe 409 idempotency_in_progress. Conserva la clave y reintenta.
  • El primer resultado ya no está guardado: 409 idempotency_replay_unavailable. Usa una clave nueva.

Si el motor falla después de reservar el crédito, la reserva se libera y la clave queda libre para un reintento legítimo. Los detalles están en la guía para reintentar de forma segura con idempotencia.

El cliente completo

Esta es la versión que conviene conservar. Reintenta solo los códigos que vale la pena reintentar, respeta Retry-After hasta 60 segundos, mantiene una sola clave entre intentos y convierte cualquier otro error en una excepción que lleva el 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);
        }
    }
}

La clave debería venir de tu propio sistema – un id de subida o un id de pedido – y no de Guid.NewGuid() dentro del método. Una clave que sale de tus datos sobrevive a un reinicio del proceso; un GUID nuevo no.

Todos los errores tienen la misma forma: code, message, docs_url y request_id. Ramifica según code, nunca según message, y registra request_id cuando escribas al soporte. La lista completa está en la guía para gestionar errores. Tres que verás pronto:

  • 402 insufficient_credits – el saldo live se ha agotado. No es reintentable.
  • 413 payload_too_large – el cuerpo supera el límite. Reduce el tamaño de la foto.
  • 401 unauthorized – falta la clave o no se acepta. No es reintentable.

Un cliente tipado con IHttpClientFactory

No crees un HttpClient nuevo en cada petición. En ASP.NET Core, registra el cliente una vez y deja que la factoría gestione los 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"]);
});

Guarda la clave en la configuración o en un almacén de secretos, nunca en el código fuente. Una clave live empieza por sk_live_, una de sandbox por sk_sandbox_. Si añades un handler de resiliencia de Microsoft.Extensions.Http.Resilience, deja que reintente una sola capa; dos capas multiplican los intentos.

Guardar menos datos

No guardes la imagen tú mismo después de la llamada. Lee del resultado lo que necesites y descarta los bytes. El cliente de arriba ya envía return_portrait: false, así que el recorte del retrato no vuelve en la respuesta.

Con una clave live, el resultado se conserva durante el periodo de historial de tu cuenta, un año por defecto. Para no conservar nada en una llamada concreta, envía retain_hours: 0 en options: no se escribe ninguna fila ni se genera ninguna miniatura. La contrapartida es que un reintento de esa llamada no se puede repetir y vuelve como idempotency_replay_unavailable. Elige un periodo corto mayor que cero si quieres ambas cosas. La guía de reconocimiento de pasaportes cubre todas las opciones.

Pasar a producción

Cambia sk_sandbox_public por tu clave sk_live_ y nada más cambia. Las claves registradas están limitadas a 60 peticiones por minuto. Si todavía estás eligiendo proveedor, la comparativa de API de OCR de pasaportes recoge alternativas de pago con sus precios publicados.

Una pregunta para ti: ¿hoy analizas la MRZ tú mismo en .NET o confías en un servicio para ello? Cuéntanoslo en los comentarios de abajo.