Skip to main content

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"]
}
actiondecisionMeaningWhat to do
acceptcleanEvery 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.
reviewneeds_review, or clean with a memory collisionA 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.
rejectfraudulentThe 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.
retakeretakeThe 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.
retryincompleteA 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​

  • assessed says whether the forensic checks ran at all, with unassessed_reason when they did not: document_not_recognised, back_image_missing, low_quality or image_quality_unavailable. A document the OCR pass cannot classify is never clean: there is no template, side or field evidence to clear it.
  • overall_score is 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 above thresholds.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 result fraudulent. Genuine documents commonly score between 0.0 and 0.3; do not threshold it yourself.
  • signals.*.is_fake, is_substituted, is_tampered, is_copy are evidence flags, not the decision; summary.signals_fired lists the true ones. is_substituted is set when a portrait comparison fails on its own floor (for an older CID's ghost, the tenant variable ocr_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 on thresholds.substitution; that comparison is also a failed document check, so the result is needs_review. is_copy comes from a screenshot software marker (or a screen model in enforce mode), not from thresholds.copy_or_screen. is_fake and is_tampered need corroborated evidence and rarely fire; thresholds.fake and thresholds.tampering gate them. A true flag makes the result fraudulent unless a document check other than side_binding failed 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 on summary.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.reason says which), not_evaluated or error. An error in any active check makes the result incomplete. not_evaluated has three causes: a check the document owes could not run (review; evidence_gaps names 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_consistency and character_spacing read not_evaluated on every result, and the document check layout reads it when no template layout resolves; they are never in evidence_gaps and decide nothing. Among the image checks only edit_localisation, screen_recapture, direct_digital_screenshot, recapture_classifier and the portrait comparisons decide by passing or failing; the others only feed confidence and overall_score, and their own failed decides nothing, even on a clean result.
  • deterministic_checks is 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.fields names 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 is not_applicable when no usable face was found, and it does not affect /v1/ocr eligibility. ghost_colour_copy records 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 under signals, are fractions of the side turned upright by deterministic_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_localisation reads 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; its regions entry marks the portrait window for the reviewer. On those documents an upload it cannot score leaves it not_evaluated, a review reason too: edit_localisation_not_jpeg (PNG, HEIC or WebP carry no JPEG history), edit_localisation_too_large or edit_localisation_no_portrait_window (no portrait found to score), or edit_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_recapture is 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 is failed and sends the result to review (review_triggers lists screen_recapture); below that it passes. A capture the model could not score is not_evaluated and goes to review. It may later become a verdict (is_copy, fraudulent); an integration that branches on summary.action needs no change for that.
  • image_quality is measured per side; acceptable: false is what makes a retake. memory.collisions lists earlier checks that shared this document, image or portrait within the window; a collision never changes decision but turns a clean result's action into review. Memory is off unless it is enabled for your account; while it is off, memory.collisions is 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, never clean. The summary names it: capture_below_forensic_floor comes before the skipped checks in reason_codes, and reason asks 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_applicable until 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_gaps names it, reason says what could not run, and the result goes to review. A fraud signal that fired still makes it fraudulent.

Show end users neutral guidance and keep the signals for your own review tooling.

What each document is checked for​

CheckCIDSRCPassportWork permitWhen it fails
Front and back are the same card (side_binding)yesyes——fraudulent; not measurable: review
Portrait against the photo in the back QR2026 card2026 card——review
Portrait against the document's own second printfront ghost—faint second print—review
Neither portrait comparison could be madeyesyesyes—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)yesyes——review
A pasted photo in the JPEG compression history (edit_localisation)yesnot in the responseyesyesreview
Number formatCID 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)yesyesyesyesreview
Expired (recorded in field_validation.checks.not_expired; POST /v1/ocr refuses it as expired_document)yesyesyesyesnot a fraud reason
Screenshot software marker / screenshot-like fileyesyesyesyesfraudulent / review
Photo of a screen (screen_recapture)yesyesyesyesreview
Black-and-white captureyesyesyesyesretake

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_review in this view; the stored check keeps clean.
  • The fraud.signal_detected webhook carries the full result only for a fraudulent check. Read every other full result (accept, review, retake, retry) on your server with GET /v1/fraud/checks/{fc_id} and a secret key, using the fc_id from 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.