Đọc ảnh hộ chiếu thành dữ liệu có cấu trúc là kiểu việc trông như chỉ cần “gọi một API OCR”, nhưng khi chạy trên production thì lập tức sinh ra ba câu hỏi. Ảnh bị mờ thì sao? Request bị timeout và bạn thử lại thì sao: bạn vừa trả tiền hai lần à? Và hệ thống của bạn làm sao biết lần gọi nào tốn tiền?
Bài hướng dẫn này trả lời ba câu hỏi đó bằng Node.js 18+ thuần và fetch có sẵn, không cần SDK. Bài dùng doc.cheap, và đây là blog của chính doc.cheap, nên hãy nhìn các lựa chọn sản phẩm với sự hoài nghi vừa phải. Các pattern (idempotency key, phân nhánh theo mã lỗi ổn định, lưu cờ chi phí cho từng lần gọi) áp dụng được cho bất kỳ API trả phí nào.
Lần gọi đầu tiên, không cần tài khoản
API có một khóa sandbox công khai in sẵn trong tài liệu, sk_sandbox_public. Nó chạy cùng một bộ nhận dạng như khóa trả phí và không cần đăng ký: tổng cộng 10 giấy tờ được nhận dạng miễn phí cho mỗi địa chỉ IP, và tối đa 10 request mỗi giờ, bất kể kết quả trả về. Như vậy là đủ để thử mọi thứ bên dưới. Đăng ký sau đó sẽ được thêm 20 credit miễn phí, không cần thẻ.
import { readFileSync } from "node:fs";
const image = readFileSync("specimen.jpg").toString("base64");
const response = await fetch("https://api.doc.cheap/v1/scans", {
method: "POST",
headers: {
Authorization: "Bearer sk_sandbox_public",
"Content-Type": "application/json",
},
body: JSON.stringify({ image }),
});
const scan = await response.json();
console.log(scan.meta.status, scan.holder?.full_name, scan.mrz.status);
Lưu thành first.mjs rồi chạy node first.mjs. Lần gọi là đồng bộ: không có job id, không polling, không webhook. Việc nhận dạng diễn ra ngay trong request và các trường được trả về trong response.
Hãy dùng mẫu tổng hợp để kiểm thử, đừng dùng hộ chiếu của chính bạn. Nhiều nơi cấp giấy tờ công bố trang mẫu, và các giấy tờ hư cấu “Utopia” của ICAO tồn tại chính cho mục đích này.
Kích thước ảnh quan trọng hơn bạn nghĩ. Ảnh chụp thẳng từ camera điện thoại có thể nặng vài megabyte, và base64 làm nó phình thêm khoảng một phần ba. Tài liệu khuyến nghị khoảng 1600 px ở cạnh dài với chất lượng JPEG 85. Nếu lần gọi đầu tiên có vẻ chậm, hãy xem meta.timing.upload_ms trước khi đổ lỗi cho máy chủ của bất kỳ ai.
Những gì được trả về
Một cấu trúc JSON duy nhất, tám nhóm, và mọi key luôn có mặt. Giá trị không xác định là null, không bao giờ là key bị thiếu. Một response đã nhận dạng, rút gọn, trông như sau:
{
"meta": {
"schema_version": "1.0",
"id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3",
"status": "recognized",
"billed": true,
"confidence": "high",
"timing": { "upload_ms": 214, "processing_ms": 843, "total_ms": 1074 },
"created_at": "2026-09-17T09:41:12Z",
"reference": null
},
"document": {
"kind": "passport", "country": "GRC", "country_name": "Greece",
"number": "AM7304518", "issue_date": "2022-03-10", "expiry_date": "2032-03-10",
"is_expired": false, "days_remaining": 2001
},
"holder": {
"given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA",
"birth_date": "1994-03-08", "sex": "F", "nationality": "GRC"
},
"fields": [],
"mrz": { "status": "passed", "reason": null, "lines": ["P<GRC…", "AM7304518…"], "text": "P<GRC…" },
"images": { "document_crop": "data:image/jpeg;base64,…", "main_photo": "data:image/jpeg;base64,…" },
"quality": { "overall": "pass" },
"authenticity": { "overall": "not_checked", "checks": [] }
}
(Người mang giấy tờ là một mẫu hư cấu lấy từ tài liệu; fields và images đã được cắt bớt.) Những phần đáng biết:
documentvàholderlà các giá trị đã được chọn lọc. Ngày theo ISO 8601, quốc gia theo ISO 3166-1 alpha-3. Mỗi nhóm lànulltoàn bộ khi lượt quét không tạo ra gì cho nhóm đó, đó là lý do đoạn code ở trên dùng?..fields[]là từng kết quả đọc riêng lẻ, mỗi kết quả có mứcconfidenceriêng (high,medium,low, không phải một con số phần trăm chính xác giả tạo). Tên in bằng chữ Hy Lạp được trả về hai lần, một lần bằng chữ Hy Lạp và một lần đã chuyển tự, và kết quả đọc bằng chữ Hy Lạp được gắn nhãn ngôn ngữ.mrz.statuslàpassed,failedhoặcabsent, vàmrz.textlà vùng MRZ thô để bạn tự chạy lại phép kiểm tra chữ số kiểm tra.authenticity.overalllànot_checked. Đây là nhận dạng, không phải phát hiện giả mạo. Đừng giới thiệu nó với bộ phận compliance của bạn như một giải pháp xác minh danh tính.
Ảnh mờ không phải là exception
Điều hữu ích nhất cần ghi nhớ: một bức ảnh không đọc được là 200, không phải lỗi. meta.status là một trong đúng năm chuỗi:
status |
Ý nghĩa | Cần làm gì |
|---|---|---|
recognized |
Đọc thành công | Dùng dữ liệu |
no_document_found |
Không có gì giống giấy tờ trong khung hình | Yêu cầu người dùng chụp lại cho đúng khung |
unreadable |
Có giấy tờ, nhưng không có văn bản dùng được | Ánh sáng tốt hơn, lấy nét, đổi góc chụp |
unsupported_document |
Tìm thấy, nhưng không phải loại API biết | Dừng lại, thử lại cũng đọc ra như cũ |
rejected |
Thất bại ở phía dịch vụ | Thử lại một lần |
Vì vậy code của bạn phân nhánh hai lần: theo HTTP status cho lỗi, và theo meta.status cho kết quả. Coi no_document_found là exception thì bạn sẽ thử lại một bức ảnh không bao giờ đọc được. Coi nó là thành công thì bạn sẽ lưu một giấy tờ không có trường nào.
Ai trả tiền cho bức ảnh mờ
Mọi response đều có meta.billed. Với khóa live, nó là true khi xác định được loại giấy tờ và thực sự trích xuất được dữ liệu: một MRZ có chữ số kiểm tra hợp lệ, ít nhất năm trường của vùng in, hoặc một mã vạch được giải mã đúng. Mọi trường hợp khác (không tìm thấy giấy tờ, ảnh không đọc được, loại không hỗ trợ, lỗi nội bộ, timeout) đều không mất phí. Giá cho một giấy tờ bị tính phí là $0.01, cố định, ở mọi khối lượng.
Với cả hai khóa sandbox, không có gì bị tính phí. Với khóa công khai sk_sandbox_public, billed vẫn cho biết cùng lượt quét đó có bị tính phí trên khóa live hay không, và đó là điều khiến khóa này hữu ích để kiểm thử cờ. Khóa sandbox riêng của một tài khoản không đọc ảnh của bạn: nó trả lời mọi lệnh gọi bằng một mẫu dựng sẵn duy nhất, nên ở đó billed mô tả mẫu ấy.
Cờ này tính theo từng lần gọi, nên hãy lưu nó cạnh kết quả. Với khóa live, số dòng billed: true do bạn tự đếm trong một tháng dương lịch (UTC) khi đó chính là con số GET /v1/usage báo cáo trong scans.billed cho tháng đó, không cần bước đối soát nào.
Thử lại mà không bị tính tiền hai lần
Lần thử lại nguy hiểm là lần sau timeout: bạn không biết request đầu tiên đã đến nơi hay chưa. API chấp nhận header Idempotency-Key trên POST /v1/scans (từ 1 đến 255 ký tự). Với khóa live, một lần thử lại với cùng key và cùng body sẽ trả về kết quả đầu tiên đã lưu (không kèm ảnh cắt) thay vì chạy nhận dạng và tính phí lần nữa.
Ba chi tiết trong tài liệu tham khảo làm thay đổi cách bạn viết client:
- Key được đối chiếu cùng với dấu vân tay (fingerprint) của toàn bộ body. Cùng key mà khác ảnh sẽ nhận
409 idempotency_conflict, không phải phát lại âm thầm. - Key được ghi nhớ chừng nào kết quả còn được lưu. Nếu bạn gửi
retain_hours: 0(không lưu gì), key vẫn được ghi nhớ trong 24 giờ: lần thử lại với key đó trong thời gian này sẽ nhận409 idempotency_replay_unavailable, nên lượt quét không chạy hai lần, nhưng cũng không có gì để trả lại. Sau 24 giờ đó, lần thử lại với cùng key là một lượt quét mới. Không lưu dữ liệu và thử lại có phát lại loại trừ lẫn nhau, nên hãy chọn một trong hai cho từng trường hợp sử dụng. - Với khóa sandbox, header được chấp nhận nhưng không có tác dụng, vì ở đó không có gì bị tính phí. Bạn vẫn có thể viết và kiểm thử nhánh code này.
Client hoàn chỉnh
Đây là phiên bản chúng tôi sẽ đưa vào một service. Nó tạo một key cho mỗi ảnh và dùng lại key đó ở mọi lần thử lại, chỉ thử lại các mã lỗi mà tài liệu nói là thử lại được, tôn trọng Retry-After tối đa một phút và bỏ cuộc thay vì chờ lâu hơn, rồi trả về nguyên kết quả quét để bạn giữ mọi trường.
// scan.mjs
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const API = "https://api.doc.cheap/v1/scans";
const KEY = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public";
const RETRY = new Set([
"rate_limited", "document_repeated", "internal_error", "engine_unavailable",
"service_unavailable", "maintenance", "idempotency_in_progress",
]);
export class ScanError extends Error {
constructor(status, error) {
super(`${error.code} (${status}): ${error.message}`);
this.code = error.code;
this.docsUrl = error.docs_url;
this.requestId = error.request_id;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function scan(path, { reference = null, attempts = 4 } = {}) {
const image = (await readFile(path)).toString("base64");
const body = JSON.stringify({ image, reference, options: { return_portrait: false } });
const idempotencyKey = randomUUID(); // một key cho ảnh này, dùng lại ở mọi lần thử lại
for (let attempt = 1; ; attempt++) {
let response;
try {
response = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
signal: AbortSignal.timeout(60_000),
});
} catch (networkError) {
if (attempt >= attempts) throw networkError;
await sleep(1000 * 2 ** attempt);
continue;
}
// Một proxy ở giữa có thể trả về HTML; xử lý như lỗi mạng.
const payload = await response.json().catch(() => null);
if (payload === null) {
if (attempt >= attempts) throw new Error(`HTTP ${response.status} without a JSON body`);
await sleep(1000 * 2 ** attempt);
continue;
}
if (response.ok) return payload;
const { error } = payload;
if (!RETRY.has(error.code) || attempt >= attempts) throw new ScanError(response.status, error);
// Giới hạn theo giờ của sandbox có thể yêu cầu chờ đến một giờ; chờ lâu như vậy
// trong một lần gọi chẳng giúp được ai, nên quá một phút là lỗi.
const retryAfter = Number(response.headers.get("Retry-After"));
const wait = retryAfter > 0 ? retryAfter : 2 ** attempt;
if (wait > 60) throw new ScanError(response.status, error);
await sleep(1000 * wait);
}
}
export function summarise({ meta, document, holder, mrz }) {
if (meta.status !== "recognized") {
return { ok: false, status: meta.status, billed: meta.billed };
}
return {
ok: true,
billed: meta.billed,
confidence: meta.confidence,
kind: document?.kind ?? null,
country: document?.country ?? null,
number: document?.number ?? null,
expiryDate: document?.expiry_date ?? null,
isExpired: document?.is_expired ?? null,
surname: holder?.surname ?? null,
givenNames: holder?.given_names ?? null,
birthDate: holder?.birth_date ?? null,
mrz: mrz.status,
mrzReason: mrz.reason,
};
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
const result = await scan(process.argv[2], { reference: "demo-1" });
console.log(summarise(result), result.meta.timing);
} catch (err) {
if (err instanceof ScanError) console.error(err.message, err.docsUrl, err.requestId);
else throw err;
process.exitCode = 1;
}
}
Chạy bằng node scan.mjs specimen.jpg. Đây là những gì nó in ra cho một hộ chiếu kiểm thử được tạo tự động, trên khóa sandbox công khai, ngày 24 tháng 9 năm 2026. Các giá trị đọc từ giấy tờ được thay bằng …; mọi thứ khác giữ đúng như khi in ra:
{
ok: true,
billed: true,
confidence: 'medium',
kind: 'passport',
country: '…',
number: '…',
expiryDate: '…',
isExpired: false,
surname: '…',
givenNames: '…',
birthDate: '…',
mrz: 'passed',
mrzReason: null
} { upload_ms: 271, processing_ms: 410, total_ms: 691 }
billed: true trên khóa sandbox không phải là một khoản phí: nó cho biết lượt quét này sẽ tốn một credit trên khóa live.
Vài lựa chọn đáng giải thích:
fetchkhông throw khi gặp 4xx hay 5xx. Nó chỉ throw khi lỗi mạng, nên client kiểm traresponse.okvà đọc body lỗi dạng JSON trong mọi trường hợp.- Phân nhánh theo
error.code, không bao giờ theo HTTP status. TrênPOST /v1/scans, ba mã idempotency dùng chung409và ba kiểu sự cố khác nhau dùng chung503, và chúng cần cách xử lý khác nhau. Mọi body lỗi đều có cùng cấu trúc:code,message,docs_url,request_id,event_id. Hãy ghi logrequest_id; bộ phận hỗ trợ cần đúng thứ đó. Bảng đầy đủ nằm trong xử lý lỗi (tài liệu bằng tiếng Anh). - Không phải lỗi nào cũng thử lại được.
validation_failed,payload_too_large,unauthorizedvàinsufficient_creditscần được sửa, không phải lặp lại. Trên sandbox bạn cũng sẽ gặpregistration_required(hạn mức miễn phí đã dùng hết; chờ đợi không làm nó đầy lại) vàdocument_repeated(cùng một ảnh được gửi quá nhiều lần trong một giờ). - Đừng ghi log body của request. Đó là một giấy tờ tùy thân.
referenceđược trả lại dưới dạngmeta.reference(tối đa 128 ký tự), là cách đơn giản để gắn một lượt quét với đơn hàng hoặc bản ghi người dùng của chính bạn. Hai mặt của một thẻ căn cước là hai lần gọi; hãy dùng cùng mộtreferencecho cả hai.
Giữ ít dữ liệu hơn
Ảnh tải lên được giữ trong bộ nhớ trong suốt request và không bao giờ được ghi vào bộ lưu trữ lâu dài. Kết quả thì khác: nó được giữ lại để bạn đọc lại bằng GET /v1/scans/{id}, trong một khoảng thời gian bạn chọn. Cài đặt tài khoản cho phép chọn 24 giờ, 7 ngày, 30 ngày hoặc một năm, và mặc định cho tài khoản mới là một năm. Theo từng request, options.retain_hours nhận giá trị từ 0 đến 8760; 0 không ghi dòng nào cả. Nếu bạn chỉ cần JSON một lần, hãy gửi retain_hours: 0 và chấp nhận sự đánh đổi về idempotency ở trên. Việc xử lý diễn ra tại EU.
return_portrait: false, được dùng trong client ở trên, bỏ images.main_photo, tức ảnh cắt chân dung của người mang giấy tờ, bớt đi một thứ cần xử lý cẩn thận trong log và bộ lưu trữ của bạn. Ảnh cắt của toàn bộ trang vẫn được trả về.
Chuyển sang live
Thay sk_sandbox_public bằng khóa của riêng bạn qua DOC_CHEAP_API_KEY và không có gì khác thay đổi: cùng endpoint, cùng cấu trúc. Một khóa đã đăng ký cho phép 60 request mỗi phút. Credit được mua bằng tiền mã hóa (BTC, ETH, TRX, hoặc USDT trên Ethereum hay Tron) với mức tối thiểu $1; hiện chưa có thanh toán bằng thẻ, điều nên biết trước khi bạn lên kế hoạch demo cho bộ phận tài chính. Trang Bảng giá có mức giá duy nhất và quy tắc chỉ tính phí khi thành công, còn trang API OCR hộ chiếu miễn phí có lần gọi đầu tiên không cần đăng ký dưới dạng một lệnh curl duy nhất.
Nếu bạn thử và thấy điều gì trong cấu trúc response khó dùng với Node, hãy viết cho admin@doc.cheap. Đó chính là phản hồi chúng tôi đang tìm.
Client ở trên đã được chạy với sandbox thật và kết quả được dán đúng như khi in ra; mọi nhận định về doc.cheap đều đã được đối chiếu với code của nó.