Bạn có ảnh chụp một cuốn hộ chiếu và một dịch vụ .NET 8 cần họ tên, số hộ chiếu, các ngày tháng và vùng đọc máy (MRZ) dưới dạng dữ liệu. Bài viết này xây dựng client đó bằng C# 12 thuần: HttpClient, System.Net.Http.Json và System.Text.Json, không cần SDK. Bài gồm lệnh gọi đầu tiên, record cho kết quả trả về, các trạng thái không phải lỗi, cơ chế thử lại không bao giờ tính phí hai lần, xử lý lỗi theo mã và một typed client cho ASP.NET Core.
API được dùng là API OCR hộ chiếu và thẻ căn cước của doc.cheap. API này đồng bộ: một POST, một câu trả lời, không có job id nào cần polling.
Lệnh gọi đầu tiên, không cần tài khoản
Khóa sandbox công khai là sk_sandbox_public. Khóa này không cần đăng ký và không trừ credit nào. Nó bị giới hạn 10 request mỗi giờ cho mỗi địa chỉ IP và một số ít lượt dùng thử miễn phí trọn đời, sau đó nó trả về 403 registration_required.
Body của request là JSON với một trường bắt buộc, image: các byte của tệp dưới dạng base64. JPEG và PNG được chấp nhận; mọi định dạng khác bị từ chối với 415 unsupported_media_type, được kiểm tra theo chữ ký của tệp chứ không theo tên tệp.
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());
Schema của request rất chặt. Một khóa lạ trong body sẽ bị từ chối với 422 validation_failed, nên một lỗi gõ nhầm tên tùy chọn sẽ báo lỗi rõ ràng thay vì bị bỏ qua.
Những gì được trả về
Mọi câu trả lời đều có cùng tám nhóm, và mọi khóa luôn có mặt: meta, document, holder, fields, mrz, images, quality và authenticity. Một ví dụ rút gọn, đã che giá trị:
{
"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 và holder là null khi không đọc được gì. mrz.status là passed, failed hoặc absent, và passed nghĩa là các chữ số kiểm tra khớp nhau.
Record cho kết quả trả về
JSON dùng snake_case. .NET 8 có sẵn JsonNamingPolicy.SnakeCaseLower, nên các record vẫn giữ được tên C# thông thường. Các thuộc tính lạ mặc định bị bỏ qua, nên bạn chỉ cần khai báo những gì mình dùng.
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);
Ở đây ngày tháng được cố ý giữ dạng chuỗi. Hãy parse chúng bằng DateOnly.TryParse ở chỗ bạn cần, để một giá trị bất thường trên một giấy tờ không ném exception ngay trong deserializer.
Ảnh mờ không phải là exception
200 không phải lúc nào cũng có nghĩa là giấy tờ đã được đọc. Hãy rẽ nhánh theo meta.status:
meta.status |
Ý nghĩa | Cần làm gì |
|---|---|---|
recognized |
Đã xác định được loại giấy tờ và đọc ra dữ liệu | Dùng document, holder, mrz |
no_document_found |
Không có gì giống giấy tờ trong khung hình | Yêu cầu ảnh mới |
unreadable |
Có giấy tờ, nhưng không đọc được văn bản dùng được | Yêu cầu ảnh nét hơn |
unsupported_document |
Tìm thấy giấy tờ, nhưng loại của nó không nằm trong danh sách đã biết | Báo cho người dùng |
rejected |
Nhận dạng thất bại ở phía dịch vụ | Ghi log meta.id và thử lại sau |
Ai trả tiền: cờ billed
Khóa sandbox không bao giờ trừ credit. Với khóa live, một lần quét chỉ bị tính phí khi nó đủ điều kiện: đã xác định được loại giấy tờ, và đọc được ít nhất một trong các thứ sau – một MRZ có chữ số kiểm tra hợp lệ, từ năm trường trở lên trong vùng in, hoặc một mã vạch được giải mã. Nếu không, credit đã giữ chỗ sẽ được hoàn lại. Với khóa sandbox, meta.billed vẫn cho biết lần quét có đáp ứng quy tắc đó hay không, nhưng không có số dư nào bị trừ.
meta.billed cho bạn biết kết quả là gì. Hãy lưu nó cạnh đơn hàng hoặc người dùng của chính bạn, và bạn sẽ không bao giờ phải đối soát phí bằng cách đoán. Tài khoản live còn được 100 lượt nhận dạng miễn phí mỗi tháng.
Thử lại mà không bị tính phí hai lần
Timeout ở phía bạn không có nghĩa là lần quét không chạy ở phía chúng tôi. Hãy gửi header Idempotency-Key (từ 1 đến 255 ký tự) và dùng lại nó cho mọi lần thử lại của cùng một giấy tờ. Với khóa live, máy chủ đối chiếu khóa cùng với dấu vân tay của body:
- Cùng khóa, cùng body: kết quả đầu tiên được phát lại, và không có credit thứ hai nào bị trừ.
- Cùng khóa, khác body:
409 idempotency_conflict. Hãy dùng khóa mới. - Lệnh gọi đầu tiên vẫn đang chạy: lệnh gọi thứ hai chờ một lát, rồi nhận
409 idempotency_in_progress. Giữ nguyên khóa và thử lại. - Kết quả đầu tiên không còn được lưu:
409 idempotency_replay_unavailable. Hãy dùng khóa mới.
Nếu engine gặp lỗi sau khi credit đã được giữ chỗ, phần giữ chỗ được hoàn lại và khóa được giải phóng cho một lần thử lại chính đáng. Chi tiết có trong hướng dẫn thử lại an toàn với idempotency.
Toàn bộ client
Đây là phiên bản nên giữ lại. Nó chỉ thử lại những mã đáng để thử lại, tôn trọng Retry-After tối đa 60 giây, giữ một khóa duy nhất qua các lần thử và biến mọi lỗi khác thành exception mang theo mã lỗi.
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);
}
}
}
Khóa nên đến từ hệ thống của chính bạn – một upload id hoặc một order id – thay vì Guid.NewGuid() bên trong method. Một khóa lấy từ dữ liệu của bạn vẫn giữ nguyên sau khi tiến trình khởi động lại; một GUID mới tạo thì không.
Mọi lỗi đều có cùng một dạng: code, message, docs_url và request_id. Hãy rẽ nhánh theo code, đừng bao giờ theo message, và ghi log request_id khi bạn liên hệ bộ phận hỗ trợ. Danh sách đầy đủ có trong hướng dẫn xử lý lỗi. Ba lỗi bạn sẽ sớm gặp:
402 insufficient_credits– số dư live đã hết. Không thể thử lại.413 payload_too_large– body vượt quá giới hạn. Hãy giảm kích thước ảnh.401 unauthorized– thiếu khóa hoặc khóa không được chấp nhận. Không thể thử lại.
Typed client với IHttpClientFactory
Đừng tạo HttpClient mới cho mỗi request. Trong ASP.NET Core, hãy đăng ký client một lần và để factory quản lý các 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"]);
});
Hãy giữ khóa trong cấu hình hoặc trong kho bí mật, không bao giờ trong mã nguồn. Khóa live bắt đầu bằng sk_live_, khóa sandbox bắt đầu bằng sk_sandbox_. Nếu bạn thêm một resilience handler từ Microsoft.Extensions.Http.Resilience, hãy để chỉ một tầng thực hiện thử lại; hai tầng sẽ nhân số lần thử lên.
Lưu ít dữ liệu hơn
Đừng tự lưu ảnh sau lệnh gọi. Hãy lấy những gì bạn cần từ kết quả rồi bỏ các byte đi. Client ở trên đã gửi return_portrait: false, nên phần ảnh chân dung cắt ra không được trả về trong câu trả lời.
Với khóa live, kết quả được lưu trong khoảng thời gian lịch sử của tài khoản, mặc định là một năm. Để không lưu gì cho một lệnh gọi, hãy gửi retain_hours: 0 trong options: không có dòng nào được ghi và không có thumbnail nào được tạo. Đổi lại, lần thử lại của lệnh gọi đó không thể được phát lại và sẽ trả về idempotency_replay_unavailable. Hãy chọn một khoảng thời gian ngắn lớn hơn 0 nếu bạn muốn cả hai. Hướng dẫn nhận dạng hộ chiếu trình bày mọi tùy chọn.
Chuyển sang live
Thay sk_sandbox_public bằng khóa sk_live_ của bạn và không có gì khác thay đổi. Khóa đã đăng ký bị giới hạn 60 request mỗi phút. Nếu bạn vẫn đang chọn nhà cung cấp, bảng so sánh API OCR hộ chiếu liệt kê các lựa chọn trả phí cùng giá họ công bố.
Một câu hỏi cho bạn: hiện nay bạn tự parse MRZ trong .NET, hay bạn tin tưởng giao việc đó cho một dịch vụ? Hãy cho chúng tôi biết trong phần bình luận bên dưới.