You have a photo of a passport and a .NET 8 service that needs the name, the number, the dates and the machine-readable zone (MRZ) as data. This post builds that client in plain C# 12: HttpClient, System.Net.Http.Json and System.Text.Json, no SDK. It covers the first call, records for the answer, the statuses that are not errors, retries that can never charge twice, error handling by code and a typed client for ASP.NET Core.
The API is the Passport and ID OCR API behind doc.cheap. It is synchronous: one POST, one answer, no job id to poll.
The first call, with no account
The public sandbox key is sk_sandbox_public. It needs no sign-up and draws no credit. It is limited to 10 requests per hour per IP address and to a small lifetime number of free tries, after which it answers 403 registration_required.
The request body is JSON with one required field, image: the file's bytes as base64. JPEG and PNG are accepted; anything else is refused with 415 unsupported_media_type, checked by the file's signature rather than its name.
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());
The request schema is strict. An unknown key in the body is refused with 422 validation_failed, so a typo in an option name fails loudly instead of being ignored.
What comes back
Every answer has the same eight groups, and every key is always present: meta, document, holder, fields, mrz, images, quality and authenticity. An abridged, masked example:
{
"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 and holder are null when nothing was read. mrz.status is passed, failed or absent, and passed means the check digits agree.
Records for the answer
The JSON is snake_case. .NET 8 ships JsonNamingPolicy.SnakeCaseLower, so the records can keep normal C# names. Unknown properties are ignored by default, so you only declare what you use.
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);
Dates stay strings here on purpose. Parse them with DateOnly.TryParse where you need them, so one odd value on a document does not throw inside the deserializer.
A blurry photo is not an exception
A 200 does not always mean a document was read. Branch on meta.status:
meta.status |
What it means | What to do |
|---|---|---|
recognized |
The document type was determined and data came out of it | Use document, holder, mrz |
no_document_found |
Nothing document-shaped was in the frame | Ask for a new photo |
unreadable |
A document was there, but no usable text was read | Ask for a sharper photo |
unsupported_document |
A document was found, but its type is not a known one | Tell the user |
rejected |
Recognition failed on the service's side | Log meta.id and try again later |
Who pays: the billed flag
Sandbox keys never draw a credit. On a live key, a scan is charged only when it is billable: the document type was determined, and at least one of these was read – an MRZ whose check digits pass, five or more fields from the printed zone, or a decoded barcode. Otherwise the reserved credit is released. On a sandbox key, meta.billed still reports whether the scan met that rule, but no balance is touched.
meta.billed tells you which way it went. Store it next to your own order or user, and you never have to reconcile charges by guessing. A live account also gets 100 free recognitions every month.
Retries that cannot charge twice
A timeout on your side does not mean the scan did not run on ours. Send an Idempotency-Key header (1 to 255 characters) and reuse it for every retry of the same document. On a live key the server matches the key together with a fingerprint of the body:
- Same key, same body: the first result is replayed, and no second credit is drawn.
- Same key, different body:
409 idempotency_conflict. Use a new key. - First call still running: the second one waits briefly, then gets
409 idempotency_in_progress. Keep the key and retry. - First result no longer stored:
409 idempotency_replay_unavailable. Use a new key.
If the engine fails after the credit was reserved, the reservation is released and the key is free for an honest retry. The details are in the guide to retrying safely with idempotency.
The whole client
This is the version to keep. It retries only codes that are worth retrying, honours Retry-After up to 60 seconds, keeps one key across attempts and turns every other error into an exception that carries the code.
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);
}
}
}
The key should come from your own system – an upload id or an order id – rather than Guid.NewGuid() inside the method. A key from your data survives a process restart; a fresh GUID does not.
Every error has the same shape: code, message, docs_url and request_id. Branch on code, never on message, and log request_id when you write to support. The full list is in the guide to handling errors. Three you will meet early:
402 insufficient_credits– the live balance ran out. Not retryable.413 payload_too_large– the body is over the limit. Resize the photo.401 unauthorized– the key is missing or not accepted. Not retryable.
A typed client with IHttpClientFactory
Do not create a new HttpClient per request. In ASP.NET Core, register the client once and let the factory manage the 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"]);
});
Keep the key in configuration or a secret store, never in source. A live key starts with sk_live_, a sandbox key with sk_sandbox_. If you add a resilience handler from Microsoft.Extensions.Http.Resilience, let only one layer retry; two layers multiply the attempts.
Keeping less data
Do not store the image yourself after the call. Read what you need from the result and drop the bytes. The client above already sends return_portrait: false, so the portrait crop does not come back in the answer.
On a live key, the result is kept for your account's history window, one year by default. To keep nothing for one call, send retain_hours: 0 in options: no row is written and no thumbnail is made. The trade-off is that a retry of that call cannot be replayed and comes back as idempotency_replay_unavailable. Choose a short window above zero if you want both. The passport recognition guide covers every option.
Going live
Swap sk_sandbox_public for your sk_live_ key and nothing else changes. Registered keys are limited to 60 requests per minute. If you are still choosing a provider, the passport OCR API comparison lists paid alternatives with their published prices.
A question for you: do you parse the MRZ yourself in .NET today, or do you trust a service for it? Tell us in the comments below.