Vous avez la photo d'un passeport et un service .NET 8 qui a besoin du nom, du numéro, des dates et de la zone de lecture automatique (MRZ) sous forme de données. Cet article construit ce client en C# 12 pur : HttpClient, System.Net.Http.Json et System.Text.Json, sans SDK. Il couvre le premier appel, des records pour la réponse, les statuts qui ne sont pas des erreurs, des relances qui ne peuvent jamais facturer deux fois, la gestion des erreurs par code et un client typé pour ASP.NET Core.
L'API est l'API OCR de passeports et de pièces d'identité de doc.cheap. Elle est synchrone : un POST, une réponse, aucun id de tâche à interroger.
Le premier appel, sans compte
La clé sandbox publique est sk_sandbox_public. Elle ne demande aucune inscription et ne consomme aucun crédit. Elle est limitée à 10 requêtes par heure et par adresse IP, et à un petit nombre total d'essais gratuits, après quoi elle répond 403 registration_required.
Le corps de la requête est du JSON avec un seul champ obligatoire, image : les octets du fichier en base64. JPEG et PNG sont acceptés ; tout autre format est refusé avec 415 unsupported_media_type, vérifié d'après la signature du fichier et non d'après son nom.
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());
Le schéma de la requête est strict. Une clé inconnue dans le corps est refusée avec 422 validation_failed : une faute de frappe dans le nom d'une option échoue bruyamment au lieu d'être ignorée.
Ce que vous recevez
Chaque réponse contient les mêmes huit groupes, et chaque clé est toujours présente : meta, document, holder, fields, mrz, images, quality et authenticity. Un exemple abrégé et masqué :
{
"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 et holder valent null quand rien n'a été lu. mrz.status vaut passed, failed ou absent, et passed signifie que les chiffres de contrôle concordent.
Des records pour la réponse
Le JSON est en snake_case. .NET 8 fournit JsonNamingPolicy.SnakeCaseLower, les records peuvent donc garder des noms C# normaux. Les propriétés inconnues sont ignorées par défaut : vous ne déclarez que ce que vous utilisez.
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);
Les dates restent ici des chaînes, volontairement. Convertissez-les avec DateOnly.TryParse là où vous en avez besoin, pour qu'une valeur inattendue sur un document ne lève pas d'exception dans le désérialiseur.
Une photo floue n'est pas une exception
Un 200 ne signifie pas toujours qu'un document a été lu. Branchez sur meta.status :
meta.status |
Ce que cela signifie | Que faire |
|---|---|---|
recognized |
Le type de document a été déterminé et des données en ont été extraites | Utilisez document, holder, mrz |
no_document_found |
Rien qui ressemble à un document dans le cadre | Demandez une nouvelle photo |
unreadable |
Un document était là, mais aucun texte exploitable n'a été lu | Demandez une photo plus nette |
unsupported_document |
Un document a été trouvé, mais son type n'est pas connu | Prévenez l'utilisateur |
rejected |
La reconnaissance a échoué côté service | Journalisez meta.id et réessayez plus tard |
Qui paie : l'indicateur billed
Les clés sandbox ne consomment jamais de crédit. Avec une clé live, un scan n'est facturé que s'il est facturable : le type de document a été déterminé et au moins l'un de ces éléments a été lu – une MRZ dont les chiffres de contrôle sont valides, cinq champs ou plus de la zone imprimée, ou un code-barres décodé. Sinon, le crédit réservé est libéré. Avec une clé sandbox, meta.billed indique quand même si le scan remplit cette règle, mais aucun solde n'est touché.
meta.billed vous dit ce qu'il en est. Stockez-le à côté de votre propre commande ou de votre utilisateur, et vous n'aurez jamais à rapprocher les débits au jugé. Un compte live reçoit aussi 100 reconnaissances gratuites chaque mois.
Des relances qui ne peuvent pas facturer deux fois
Un timeout de votre côté ne signifie pas que le scan n'a pas tourné du nôtre. Envoyez un en-tête Idempotency-Key (de 1 à 255 caractères) et réutilisez-le pour chaque relance du même document. Avec une clé live, le serveur compare la clé avec une empreinte du corps :
- Même clé, même corps : le premier résultat est renvoyé, et aucun second crédit n'est consommé.
- Même clé, corps différent :
409 idempotency_conflict. Utilisez une nouvelle clé. - Le premier appel est encore en cours : le second attend brièvement, puis reçoit
409 idempotency_in_progress. Gardez la clé et réessayez. - Le premier résultat n'est plus stocké :
409 idempotency_replay_unavailable. Utilisez une nouvelle clé.
Si le moteur échoue après la réservation du crédit, la réservation est libérée et la clé reste disponible pour une relance légitime. Les détails sont dans le guide pour relancer sans risque grâce à l'idempotence.
Le client complet
Voici la version à garder. Elle ne relance que les codes qui valent la peine de l'être, respecte Retry-After jusqu'à 60 secondes, garde une seule clé d'une tentative à l'autre et transforme toute autre erreur en exception qui porte le 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);
}
}
}
La clé doit venir de votre propre système – un id d'upload ou un id de commande – plutôt que de Guid.NewGuid() à l'intérieur de la méthode. Une clé tirée de vos données survit au redémarrage du processus ; un GUID tout neuf, non.
Toutes les erreurs ont la même forme : code, message, docs_url et request_id. Branchez sur code, jamais sur message, et notez le request_id quand vous écrivez au support. La liste complète est dans le guide de gestion des erreurs. Trois que vous croiserez tôt :
402 insufficient_credits– le solde live est épuisé. Pas de relance.413 payload_too_large– le corps dépasse la limite. Réduisez la photo.401 unauthorized– la clé est absente ou refusée. Pas de relance.
Un client typé avec IHttpClientFactory
Ne créez pas un nouveau HttpClient à chaque requête. Dans ASP.NET Core, enregistrez le client une fois et laissez la factory gérer les 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"]);
});
Gardez la clé dans la configuration ou dans un coffre à secrets, jamais dans le code source. Une clé live commence par sk_live_, une clé sandbox par sk_sandbox_. Si vous ajoutez un handler de résilience de Microsoft.Extensions.Http.Resilience, ne laissez qu'une seule couche relancer ; deux couches multiplient les tentatives.
Garder moins de données
Ne stockez pas l'image vous-même après l'appel. Lisez ce dont vous avez besoin dans le résultat et jetez les octets. Le client ci-dessus envoie déjà return_portrait: false, le recadrage du portrait ne revient donc pas dans la réponse.
Avec une clé live, le résultat est conservé pendant la durée d'historique de votre compte, un an par défaut. Pour ne rien conserver lors d'un appel, envoyez retain_hours: 0 dans options : aucune ligne n'est écrite et aucune miniature n'est créée. La contrepartie : une relance de cet appel ne peut pas être rejouée et revient en idempotency_replay_unavailable. Choisissez une durée courte supérieure à zéro si vous voulez les deux. Le guide de reconnaissance de passeport couvre toutes les options.
Passer en production
Remplacez sk_sandbox_public par votre clé sk_live_, et rien d'autre ne change. Les clés enregistrées sont limitées à 60 requêtes par minute. Si vous choisissez encore un fournisseur, le comparatif des API OCR de passeport liste des alternatives payantes avec leurs prix publiés.
Une question pour vous : aujourd'hui, analysez-vous la MRZ vous-même en .NET, ou faites-vous confiance à un service ? Dites-le-nous dans les commentaires ci-dessous.