Document scanning (OCR)
POST /v1/ocr reads a Bhutanese identity document and returns the
extracted fields together with a verdict on whether the scan can be relied
on. The verdict is the part integrators most often get wrong: a scan that
reads every field can still be ineligible, and an ineligible result
carries no fields at all.
Supported documents
document_type | Document | Sides required | Fields returned when eligible |
|---|---|---|---|
cid | Citizenship Identity Card | front and back | full_name, cid_number, date_of_birth, sex, date_of_issue, date_of_expiry |
src | Special Resident Card | front and back | full_name, srp_number, date_of_birth, sex, date_of_issue, date_of_expiry |
passport | Bhutanese passport, data page | front | full_name, given_names, surname, document_number, personal_number, date_of_birth, date_of_expiry, nationality, issuing_country, sex |
work_permit | Work permit, standard type | front | full_name, work_permit_number, date_of_birth, nationality, job_category, employer, location, date_of_issue, date_of_expiry |
The document type is detected from the image. The optional type field is
a hint and must be one of the four values above; anything else is a
validation_error.
Request
multipart/form-data with these parts:
| Part | Required | Notes |
|---|---|---|
image | yes | Front of the document. JPEG, PNG, HEIC, WebP, TIFF or BMP, or a PDF, up to 10 MB and 40 megapixels; PDF pages are read at 200 DPI, at most 4000 px on the long side. Phone photos can be sent as they come off the camera; HEIC needs no conversion for OCR; a fraud check on a non-JPEG CID, passport or work permit goes to review (see Fraud detection). |
back_image | for cid and src | Back of the same card. Same image formats and limits (not a PDF; with a PDF front, page 2 is the back). |
type | no | Hint: cid, src, passport or work_permit. |
customer_ref | no | Your own identifier for the end user; echoed in the result and webhook. |
An upload over the size limit is rejected with a 400 validation_error
whose message starts with upload rejected.
Image requirements
The platform refuses captures it cannot trust rather than guessing. Each rule below maps to a rejection code you will see in the result.
- Colour photo of the physical document. A grayscale scan or a
photocopy saved as an image is refused (
monochrome_image). The check measures colour saturation, so it does not depend on the file format. - At least 320 px on the long side and 230 px on the short side
(
insufficient_image_quality). A work permit that fills the frame reads at that size, but a CID needs closer to 850 px on the long side before every field reads, and a two-sided card must still match its sides, which small captures often cannot. Send phone photos at full resolution rather than thumbnails. The floor is a tenant setting under Settings → Decision thresholds, down to 300 × 190 px. - Sharp, well lit, upright, card filling most of the frame. Blur is
forgiven when every required field still reads; a card photographed
sideways is not (
orientation_unresolved). - Both sides of a two-sided card must be the same card. The small
ghost portrait printed on the front and back of a CID or SRC is matched
server side. Sides whose photographs clearly belong to different people
are rejected (
side_mismatch); a pair whose ghosts cannot be found, compared, or matched with confidence is rejected asside_binding_unavailable, and the fix is to retake both sides with the small portrait clearly visible. - The front must match the QR code on the back. A 2026-format CID or
SRC carries the card's own record of the name, date of birth, sex and
card number in that QR. When it reads, each of those fields on the front
must match it, or the scan is refused as
field_mismatch(reported instead ofside_binding_unavailable): the front was altered, belongs to another card, or was misread. An older CID carries a PDF417 barcode instead; when it reads, the front must match it the same way. Neither code ever fills in or corrects a field; the fields returned are always the ones read from the front. - Only the document's own faces may be in the photo. The portrait is
the face in the document's printed portrait frame; the CID's ghost
portrait and the passport's faint second print are recognised by their
position and size relative to it. Any other person's face in the photo,
or a portrait frame with no face in it, is refused as
extra_face_in_frame: photograph the document on its own. - The card does not have to fill the frame. For a CID the card is located inside the photo and the matching runs on the card itself, so a phone photo of a card on a desk works as long as the resolution rule above holds for the whole image.
Reading the result
{
"ocr_id": "ocr_01a0799179d576da…",
"object": "ocr.result",
"document_type": "cid",
"document_type_confidence": 0.98,
"ocr_eligible": false,
"decision_version": "ocr-eligibility-v1",
"eligibility_reason": "expired_document",
"eligibility_message": "The document has expired. A renewed document is required.",
"gate_rejections": [
{
"code": "expired_document",
"label": "Expired document",
"message": "The expiry date is before the server evaluation date."
}
],
"fields": {},
"raw_text": { "front": "KINGDOM OF BHUTAN\n…", "back": "Date of Issue: 04/03/2016\n…" },
"portrait": null,
"customer_ref": "user-8812",
"stored": true,
"expires_at": "2026-12-06T01:52:52.181Z",
"request_id": "01a07991-79d5-76da-a6bc-1020aa49ba28",
"created_at": "2026-09-07T01:52:52.181Z",
"processing_time_ms": 3295
}
ocr_eligibleis the only field to build on. It istrueonly when every check passed. When it isfalse,fieldsis an empty object andportraitisnull, whatever the model read.eligibility_reasonnames the first failing check andeligibility_messageis a sentence you can show the end user unchanged.gate_rejectionslists every failing check, not just the first, so a scan that is both expired and unreadable on one line reports both.raw_textis the recognised text per side, for debugging and review. Do not parse it yourself: it is exactly what the eligibility gate already judged insufficient.portraitis the cropped photo from the document, as a signed URL when storage is on and inline base64 otherwise.capture_integrity, when present, is the fraud checks that need nothing from the read, run beside it. It changes nothing about the result and is returned only to a secret key (theocr.completedwebhook always carries it); see Capture integrity.
Eligibility reasons
eligibility_reason | What to tell the user |
|---|---|
eligible | All required checks passed. |
unsupported_document_type | The document could not be identified as a supported Bhutanese identity document. |
unsupported_document_subtype | This document type is not accepted for identity verification. |
non_bhutanese_passport | Only Bhutanese passports are accepted. |
missing_required_side | Upload both the front and the back. |
insufficient_image_quality | Too blurry or too small, or the code on the back of a CID could not be read and the back is not clearly an older card (older cards carry a barcode that often does not read and are not asked for it); retake in good light, filling the frame with the card and keeping the QR sharp. |
monochrome_image | The image is black and white; take a colour photo of the physical document. |
orientation_unresolved | Photograph the card upright and square to the camera. |
missing_required_field | One or more required fields could not be read. |
invalid_field_format | A field was read but its value is not in the expected format. |
invalid_date, future_date, invalid_chronology | A date could not be read, is in the future, or the birth, issue and expiry dates are inconsistent. A work permit is also inconsistent when it is valid for more than five years, or when its holder was under 15 on the issue date. |
expired_document | The document has expired; a renewed document is required. |
invalid_mrz | The passport's machine-readable zone failed its checksums. |
extra_face_in_frame | Another face is in the photo; photograph the document on its own. |
side_mismatch | The front and back are from different cards. |
field_mismatch | The details read from the front do not match the code on the back; retake sharp, well-lit photos of both sides of the same card. |
side_binding_unavailable | The photos on the front and back could not be matched; retake both sides. |
field_conflict | The same field was read with conflicting values. |
suspected_tamper | Reserved for an anti-tamper veto; not produced today. |
unreadable_document | No readable text was found. |
insufficient_evidence | Not enough confidence to accept; retake showing the whole card. |
Gate rejection codes
Codes are stable strings. Field-scoped codes carry the field name after a
colon, for example missing_required_field:cid_number.
| Code | Meaning |
|---|---|
unreadable_document | No readable OCR text was produced. |
unsupported_document_type / unsupported_document_subtype | Not a supported document, or a permit subtype that is not accepted. |
missing_required_side:front / :back | The document type requires that side. |
orientation_unresolved | The orientation could not be resolved with confidence. |
insufficient_image_quality:front / :back | That side failed the resolution or blur gate; :back on a CID also means its back code did not decode on a back that is not clearly the older design. |
monochrome_image:front / :back | That side has no colour: a grayscale scan or photocopy. |
extra_face_in_frame | A face other than the document's portrait and its own printed copies is in the photo. |
side_binding_unavailable | The front and back could not be positively matched. |
side_mismatch | The front and back are different cards. |
field_mismatch:<field> | The value read from the front does not match the code on the card's back. |
missing_required_field:<field> | No value was read for a required field. |
invalid_field_format:<field> | The value is not in the field's required format. |
missing_field_evidence:<field> | The value was not backed by OCR geometry on the image. |
field_conflict:<field> / field_conflicts_unresolved | Conflicting values for a field. |
invalid_date:<date field> / future_date:<date field> / invalid_chronology | Date problems. |
expired_document | The expiry date is before the evaluation date. |
missing_document_evidence | The issuer and document title were not found on the front. |
non_bhutanese_passport / invalid_mrz / missing_visual_mrz_agreement / missing_place_of_birth_polygon | Passport-specific checks. |
suspected_tamper | Reserved; not produced today. |
eligibility_disabled | The service is running in fail-closed mode; every scan is ineligible. |
Capture integrity
While OCR reads the document, the platform can run the fraud checks that
need nothing from the read: the file's container and metadata (a screenshot,
a re-saved export), screen and photocopy artifacts, and a copy-move check
that rarely fires.
They run on every side sent and come back on POST /v1/ocr:
"capture_integrity": {
"mode": "shadow",
"status": "flagged",
"reason": null,
"signals_fired": ["copy_or_screen"],
"review_triggers": [],
"evidence_gaps": []
}
statusispassed,flagged(a signal fired or a review-trigger check failed) ornot_evaluated: a check could not run (named inevidence_gaps) or the analysis did not answer, withreasonsaying why (busy,timeout,unavailableorerror).mode: "shadow"means nothing decides on it yet.ocr_eligible,fieldsand everything else are the same with or without it, so do not branch on it. It is not enabled everywhere yet; when it is off, the field is absent.- It never holds the result up for long: the response waits for it at most
two seconds after the read, and a slower analysis is reported as
not_evaluatedwithreason: "timeout". signalscarries the per-check detail, in the same shape as the fraud check'ssignals.- It does not replace Fraud detection,
which also checks the portrait, the two sides against each other and the
fields OCR read.
GET /v1/ocr/{ocr_id}(secret key only, like the list and delete calls) does not re-process, so it never has this block.
Storage and retention
What is kept follows your tenant's storage policy (Dashboard, Settings, Storage policy):
- Results (
store_results): the fields and diagnostics, forresult_ttl_days.storedandexpires_atin the response say whether and until when. - Source images (
store_source_images): the uploaded photos and, for CIDs, the automatic card crops used for side matching, forsource_image_ttl_days. These appear on the scan's page in the dashboard for review. - The portrait is kept with the result and served through short-lived signed URLs.
Test-mode results follow the same policy; for what does differ see Test mode vs live mode.
Webhook
A completed scan publishes ocr.completed with the same body as the
response. See Webhooks.