Skip to main content

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_typeDocumentSides requiredFields returned when eligible
cidCitizenship Identity Cardfront and backfull_name, cid_number, date_of_birth, sex, date_of_issue, date_of_expiry
srcSpecial Resident Cardfront and backfull_name, srp_number, date_of_birth, sex, date_of_issue, date_of_expiry
passportBhutanese passport, data pagefrontfull_name, given_names, surname, document_number, personal_number, date_of_birth, date_of_expiry, nationality, issuing_country, sex
work_permitWork permit, standard typefrontfull_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:

PartRequiredNotes
imageyesFront 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_imagefor cid and srcBack of the same card. Same image formats and limits (not a PDF; with a PDF front, page 2 is the back).
typenoHint: cid, src, passport or work_permit.
customer_refnoYour 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 as side_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 of side_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_eligible is the only field to build on. It is true only when every check passed. When it is false, fields is an empty object and portrait is null, whatever the model read.
  • eligibility_reason names the first failing check and eligibility_message is a sentence you can show the end user unchanged.
  • gate_rejections lists every failing check, not just the first, so a scan that is both expired and unreadable on one line reports both.
  • raw_text is the recognised text per side, for debugging and review. Do not parse it yourself: it is exactly what the eligibility gate already judged insufficient.
  • portrait is 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 (the ocr.completed webhook always carries it); see Capture integrity.

Eligibility reasons​

eligibility_reasonWhat to tell the user
eligibleAll required checks passed.
unsupported_document_typeThe document could not be identified as a supported Bhutanese identity document.
unsupported_document_subtypeThis document type is not accepted for identity verification.
non_bhutanese_passportOnly Bhutanese passports are accepted.
missing_required_sideUpload both the front and the back.
insufficient_image_qualityToo 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_imageThe image is black and white; take a colour photo of the physical document.
orientation_unresolvedPhotograph the card upright and square to the camera.
missing_required_fieldOne or more required fields could not be read.
invalid_field_formatA field was read but its value is not in the expected format.
invalid_date, future_date, invalid_chronologyA 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_documentThe document has expired; a renewed document is required.
invalid_mrzThe passport's machine-readable zone failed its checksums.
extra_face_in_frameAnother face is in the photo; photograph the document on its own.
side_mismatchThe front and back are from different cards.
field_mismatchThe 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_unavailableThe photos on the front and back could not be matched; retake both sides.
field_conflictThe same field was read with conflicting values.
suspected_tamperReserved for an anti-tamper veto; not produced today.
unreadable_documentNo readable text was found.
insufficient_evidenceNot 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.

CodeMeaning
unreadable_documentNo readable OCR text was produced.
unsupported_document_type / unsupported_document_subtypeNot a supported document, or a permit subtype that is not accepted.
missing_required_side:front / :backThe document type requires that side.
orientation_unresolvedThe orientation could not be resolved with confidence.
insufficient_image_quality:front / :backThat 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 / :backThat side has no colour: a grayscale scan or photocopy.
extra_face_in_frameA face other than the document's portrait and its own printed copies is in the photo.
side_binding_unavailableThe front and back could not be positively matched.
side_mismatchThe 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_unresolvedConflicting values for a field.
invalid_date:<date field> / future_date:<date field> / invalid_chronologyDate problems.
expired_documentThe expiry date is before the evaluation date.
missing_document_evidenceThe issuer and document title were not found on the front.
non_bhutanese_passport / invalid_mrz / missing_visual_mrz_agreement / missing_place_of_birth_polygonPassport-specific checks.
suspected_tamperReserved; not produced today.
eligibility_disabledThe 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": []
}
  • status is passed, flagged (a signal fired or a review-trigger check failed) or not_evaluated: a check could not run (named in evidence_gaps) or the analysis did not answer, with reason saying why (busy, timeout, unavailable or error).
  • mode: "shadow" means nothing decides on it yet. ocr_eligible, fields and 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_evaluated with reason: "timeout".
  • signals carries the per-check detail, in the same shape as the fraud check's signals.
  • 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, for result_ttl_days. stored and expires_at in 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, for source_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.