Fraud detection
POST /v1/fraud/detect screens the photos of an identity document for
signs that the document, or the photo of it, is not what it claims to be:
two sides from different cards, a portrait that does not match the card's
QR photo or its own second print, a photo of a screen or a screenshot, a
black-and-white copy, and a pasted photo in a JPEG's compression history.
What each document is checked for
lists the checks per document type. It runs OCR first, so the same upload rules as
Document scanning apply, and it takes
the same amount of time plus the forensic checks; allow at least 90
seconds for the call.
Request
multipart/form-data with image (front), back_image (back, required
for CID and SRC), an optional document_type hint (cid, src,
passport, work_permit) and an optional customer_ref.
Send each capture exactly as the camera or phone saved it, HEIC, JPEG or PNG. Decoding and re-encoding it on the client, for example to resize it, strips the container, the EXIF fields and the screen dimensions that the screenshot and metadata checks rely on; an iOS screenshot is caught by its PNG container and its "Screenshot" tag, and a re-saved copy of it is not. The platform resizes internally where it needs to.
The edit check reads JPEG compression history, so on a CID, passport or
work permit a HEIC, PNG or WebP upload goes to review
(edit_localisation_not_jpeg), as does an upload in which no portrait was
found to score (edit_localisation_too_large,
edit_localisation_no_portrait_window). If you control the capture, have
the camera save JPEG and keep the whole document in frame.
The route reads the document itself before the image checks, which is most
of its latency. If you already ran POST /v1/ocr on the same files, send
that result's ocr_id as well: the check reuses the stored document evidence
and skips its own OCR pass. The images must be byte-identical to the ones
you scanned, otherwise the request is refused with validation_error, and
a result stored before the platform kept that evidence falls back to a fresh
OCR pass.
Reading the result
Start with summary. It is derived from the same evidence and precedence
rules as decision, so the two never disagree, and it is the only part of
the response an integration needs to branch on:
"summary": {
"decision": "retake",
"action": "retake",
"reason": "The CID needs both sides; only the front was sent.",
"reason_codes": ["back_image_missing"],
"overall_score": 0.04,
"signals_fired": [],
"review_triggers": [],
"evidence_gaps": ["cid_number", "side_binding"]
}
action | decision | Meaning | What to do |
|---|---|---|---|
accept | clean | Every check that can decide passed or did not apply, and every check the document owes ran. Supporting image checks may still show failed; they decide nothing. | Proceed. |
review | needs_review, or clean with a memory collision | A check needs a human look. Ahead of any image flag: a document check other than side_binding failed or could not be evaluated (a portrait that does not match the card's QR photo or its own second print lands here, with is_substituted set; a passport portrait that does not sit in its printed frame lands here too), a field reason or advisory, or an image check that could not run (any capture under 640 × 400 px). When no image flag fired: a suspected screenshot file, a photo of a screen (screen_recapture), a region whose compression history differs from the rest of the image (edit_localisation), a check the document owes that could not be scored, or overall_score at or above thresholds.overall. A clean result also comes back as review when the same document, portrait or number was seen recently (review_triggers). | Hold for manual review. A verification session holds in review unless its policy says otherwise. |
reject | fraudulent | The two sides are not the same document (side_binding), whatever else fired. Otherwise an image flag (signals_fired; in practice a screenshot software marker, is_copy) when nothing in the first group of review reasons is present; it outranks the second group. | Reject. |
retake | retake | The capture cannot be assessed. reason_codes say what to fix: document_not_recognised (not a CID, SRC, work permit or passport), back_image_missing (a CID or SRC sent without its back), image_monochrome (black and white, like a photocopy or grayscale scan; back_image_monochrome for the back), image_blurry, image_resolution_too_low, image_too_dark, image_too_bright. | Ask the user to capture again — a supported document, both sides, or a better photo, as the code says. |
retry | incomplete | A processing failure on the platform side. | Retry; if it persists, send us the request_id. Not billed. |
reason is one sentence for the first reason code, for logs and reviewer
screens. reason_codes are the codes behind the decision in precedence
order; evidence_gaps lists the active checks that could not be evaluated
on this capture. Switched-off checks are never listed.
The fields behind it
assessedsays whether the forensic checks ran at all, withunassessed_reasonwhen they did not:document_not_recognised,back_image_missing,low_qualityorimage_quality_unavailable. A document the OCR pass cannot classify is neverclean: there is no template, side or field evidence to clear it.overall_scoreis a diagnostic number, not a fraud probability: an uncalibrated fusion of the image heuristics (fake-document 0.40, tampering 0.33, copy or screen 0.27). At or abovethresholds.overall(fraud_overall_threshold, default 0.55, tunable on the dashboard from 0.4 to 0.8) the result goes to review; it never makes a resultfraudulent. Genuine documents commonly score between 0.0 and 0.3; do not threshold it yourself.signals.*.is_fake,is_substituted,is_tampered,is_copyare evidence flags, not the decision;summary.signals_firedlists the true ones.is_substitutedis set when a portrait comparison fails on its own floor (for an older CID's ghost, the tenant variableocr_portrait_ghost_min_cosine; on a 2026 CID each ghost found must match the portrait or the photo in the back QR at a fixed 0.25), not onthresholds.substitution; that comparison is also a failed document check, so the result isneeds_review.is_copycomes from a screenshot software marker (or a screen model in enforce mode), not fromthresholds.copy_or_screen.is_fakeandis_tamperedneed corroborated evidence and rarely fire;thresholds.fakeandthresholds.tamperinggate them. A true flag makes the resultfraudulentunless a document check other thanside_bindingfailed or was not evaluated, a field reason or advisory is present, or an image check could not run. A failed edit or screen check, an unscored owed check or a memory collision does not hold it back. Branch onsummary.action, never on a flag.- Every check carries a
status:passed,failed,not_applicable(the input did not call for it, or the check is switched off;metrics.reasonsays which),not_evaluatedorerror. Anerrorin any active check makes the resultincomplete.not_evaluatedhas three causes: a check the document owes could not run (review;evidence_gapsnames it), the capture is under 640 × 400 px, or 480 × 300 px for a work permit (capture_below_forensic_floor, review), or the check is switched off or recorded only:colour_consistency,backlight_illumination_variation,frequency_aliasing,font_consistencyandcharacter_spacingreadnot_evaluatedon every result, and the document checklayoutreads it when no template layout resolves; they are never inevidence_gapsand decide nothing. Among the image checks onlyedit_localisation,screen_recapture,direct_digital_screenshot,recapture_classifierand the portrait comparisons decide by passing or failing; the others only feedconfidenceandoverall_score, and their ownfaileddecides nothing, even on acleanresult. deterministic_checksis the document-level evidence:side_binding(the only hard check), the portrait comparisons (portrait_qr_agreement,portrait_ghost_agreement,portrait_secondary_agreement; a failure is a review reason),symbol_agreement(a CID or SRC front against its own back's QR or PDF417;details.fieldsnames the fields that differ, and a disagreement, or an unread code on a CID back that is not clearly the older design, is a review reason), field validity and template identification.back_ghost_agreement: on a CID whose sides the back code's number bound, the best match between the front's faces and the back's photo. Below the floor it is a review reason. It isnot_applicablewhen no usable face was found, and it does not affect/v1/ocreligibility.ghost_colour_copyrecords whether a CID's ghost is a colour copy of its portrait (a ghost pasted in rather than printed); it is recorded only and does not affect the decision yet. Region coordinates, here and undersignals, are fractions of the side turned upright bydeterministic_checks.rectification.<side>.details.rotation_to_upright_degrees(0, 90, 180 or 270 clockwise from the image as decoded with EXIF orientation applied); to draw them on the upload as sent, turn them back.signals.photo_substitution.checks.edit_localisationreads the upload's own JPEG stream for a region whose compression history differs from the rest, such as a pasted photo. It fails on per-document thresholds for CID, passport and work permit and is a review reason; itsregionsentry marks the portrait window for the reviewer. On those documents an upload it cannot score leaves itnot_evaluated, a review reason too:edit_localisation_not_jpeg(PNG, HEIC or WebP carry no JPEG history),edit_localisation_too_largeoredit_localisation_no_portrait_window(no portrait found to score), oredit_localisation_timeout(a score that ran out of time under load). On SRC it still runs in shadow and is not in the response.signals.copy_or_screen.checks.screen_recaptureis a trained model's probability that the capture shows a screen (a phone or laptop photographed, or a screenshot) rather than the document itself. At 0.4 or above it isfailedand sends the result to review (review_triggerslistsscreen_recapture); below that it passes. A capture the model could not score isnot_evaluatedand goes to review. It may later become a verdict (is_copy,fraudulent); an integration that branches onsummary.actionneeds no change for that.image_qualityis measured per side;acceptable: falseis what makes aretake.memory.collisionslists earlier checks that shared this document, image or portrait within the window; a collision never changesdecisionbut turns a clean result'sactionintoreview. Memory is off unless it is enabled for your account; while it is off,memory.collisionsis always empty.
Three rules that surprise integrators:
- Resolution gates trust, not just eligibility. A capture between the
OCR floor and 640 by 400 px is still checked, but the image-forensic
results are not trusted at that size, so the best outcome is
needs_review, neverclean. The summary names it:capture_below_forensic_floorcomes before the skipped checks inreason_codes, andreasonasks for a larger photo. - Portrait substitution uses the card's own QR photo. On a 2026 CID or
SRC the back QR carries a photograph; the printed portrait is compared
with it, and a mismatch is a review reason. Template-bound checks for
other documents are reported as
not_applicableuntil they are enabled. - A check the document owes and could not run means review, not a
pass. A CID or SRC whose portrait could be compared neither with the
back QR's photo nor with the card's own ghost portrait (every older SRC,
an older CID whose ghost portrait was not found), an upload the edit check
cannot read, and a capture the
screen model could not score each leave that check
not_evaluated.evidence_gapsnames it,reasonsays what could not run, and the result goes to review. A fraud signal that fired still makes itfraudulent.
Show end users neutral guidance and keep the signals for your own review tooling.
What each document is checked for
| Check | CID | SRC | Passport | Work permit | When it fails |
|---|---|---|---|---|---|
Front and back are the same card (side_binding) | yes | yes | — | — | fraudulent; not measurable: review |
| Portrait against the photo in the back QR | 2026 card | 2026 card | — | — | review |
| Portrait against the document's own second print | front ghost | — | faint second print | — | review |
| Neither portrait comparison could be made | yes | yes | yes | — | review |
Back photo against the front's faces (back_ghost_agreement) | card bound by its back code's number | — | — | — | review |
Front fields against the card's own back QR or PDF417 (symbol_agreement) | yes | yes | — | — | review |
A pasted photo in the JPEG compression history (edit_localisation) | yes | not in the response | yes | yes | review |
| Number format | CID number | — | personal number (Bhutanese passport) | — | review |
| MRZ check digits; visual zone against the MRZ | — | — | yes | — | review |
| Dates (issue before expiry, birth before issue, none in the future) | yes | yes | yes | yes | review |
Expired (recorded in field_validation.checks.not_expired; POST /v1/ocr refuses it as expired_document) | yes | yes | yes | yes | not a fraud reason |
| Screenshot software marker / screenshot-like file | yes | yes | yes | yes | fraudulent / review |
Photo of a screen (screen_recapture) | yes | yes | yes | yes | review |
| Black-and-white capture | yes | yes | yes | yes | retake |
Edited or retyped text is caught only where it contradicts the
machine-readable data the document carries (a passport's MRZ, a CID or
SRC back code) or breaks a format rule (the CID number). Template layout, guilloche, seal and fonts are
recorded and switched off. POST /v1/ocr separately refuses a CID or SRC
whose front disagrees with its own back code (field_mismatch). A result's
checks show which of these ran on it.
Reading a check later
GET /v1/fraud/checks/{fc_id}, with a secret key, returns a stored result while
store_results is on and fraud_result_ttl_days has not passed. Fraud
checks are not yet subscribable as webhook events; poll this endpoint. The
dashboard's Fraud checks page shows the same
record laid out for review.
What end users see
A session token or publishable key gets the end-user view (view: "end_user"): fc_id, decision, is_fraudulent and summary with only
decision, action, reason and reason_codes. Scores, thresholds,
signals, document checks and memory are left out.
- Reason codes are given only for a retake, where they say what to fix (for
example
back_image_missing). Every other action gets a fixed sentence and no codes. - A clean result held for review by fraud memory reads
needs_reviewin this view; the stored check keepsclean. - The
fraud.signal_detectedwebhook carries the full result only for a fraudulent check. Read every other full result (accept, review, retake, retry) on your server withGET /v1/fraud/checks/{fc_id}and a secret key, using thefc_idfrom the end-user view.
Automatic check after OCR
With Run a fraud check after every OCR scan (fraud_after_ocr) turned
on in the dashboard's decision thresholds settings, every eligible
POST /v1/ocr scan also starts a fraud check on the same images, so a
forged card is caught even when your integration only calls OCR. The OCR
response does not wait for it; it returns the check's id as
fraud_check_id, readable at GET /v1/fraud/checks/{fc_id} with a secret
key once the check finishes (usually within a minute; 404 until then, and for good if it
could not run). Each check is billed and stored like a
POST /v1/fraud/detect call and carries the scan's correlation_id and
customer_ref. Scans made
inside a verification session are skipped, and calling
POST /v1/fraud/detect yourself as well produces a second check.
Inside a verification session
A fraud check made with a verification's token records the document
step: clean and needs_review complete it, fraudulent fails it. The
check must send the images that verification's POST /v1/ocr scan used;
otherwise it is refused with 400 validation_error before analysis or
billing. A decided verification returns 409 verification_closed and an
expired one 404 not_found. The decision uses the worst verdict of any scan
in the verification, including a check that finishes after the document was
scanned again. See
Verification sessions.