Overview & lifecycle
Base URL: https://api.scam.ai/v1 (production, sk_live_ keys); sandbox is https://api-sandbox.scam.ai/v1 with an sk_test_ key — same API, unmetered, running the next release before production does (§2026-10-26). The OpenAPI spec for this API is https://docs.scam.ai/openapi.json; /openapi.json on the API host describes the Scam AI platform API (/v1/detections, account endpoints), not this one. All requests and responses are JSON except file upload (multipart) and the audit-report download (a PDF file). Analysis is asynchronous — a single-page PDF typically completes in seconds, but you should integrate against the async lifecycle, not a fixed latency.
Absent fields are omitted, never null. A field that does not apply to a response is left out of the JSON entirely, recursively. Test for the key rather than branching on null, and read a missing key as "not applicable". The one place a null carries meaning is a request: on POST /v1/loans/{external_ref}/review, "assignee": null unassigns the loan and "note": null clears the note, where omitting either key leaves the current value alone.
Two integration shapes work well inside a LOS pipeline:
- Webhook-driven (recommended): submit on document sync, update your
Fraud Reviewstate whendocument.completedarrives. - Long-poll:
POST /documents?wait=trueblocks up to 30 s and usually returns the finished verdict inline — simplest for low volume.
Changes on 2026-10-26 — the sandbox runs the next release first
The two estates no longer run the same build. Production (https://api.scam.ai/v1, sk_live_ keys) answers model_version 3.0: the docforensics engine it cut over with at 1.3, scoring every file, PNG and JPEG included. The sandbox (https://api-sandbox.scam.ai/v1, sk_test_ keys) runs the next release first: model_version 3.1, the docforensics engine 0.17 with its V10 structural model (the learned_model_score check), everything in the 2026-10-15 note below, and PNG and JPEG answered by a second engine, image-forensics. Production gets these on release. Rows marked correction fix what this page said about both estates.
| Area | What changes | You need to act if… |
|---|---|---|
| Sandbox host | The sandbox is https://api-sandbox.scam.ai/v1. api-dev.scam.ai, which this page used to name, is an internal estate, not a customer sandbox: sandbox keys are issued for api-sandbox.scam.ai. | Your sandbox base URL is api-dev.scam.ai. |
model_version | One badge per estate, not per document: 3.0 on production, 3.1 on the sandbox. Every document an estate returns reads the badge of the engine it runs now, whenever the document was scored; reprocessing does not change it. | You compare model_version to one value on both estates. |
model: image-forensics (sandbox; production on release) | PNG and JPEG uploads are answered by image-forensics: an image model, with the docforensics checks still run on the same file. Such an answer omits risk_score on CLEAN and on a SUSPICIOUS the image model raised alone, carries confidence only on FORGED, and has no next_step on CLEAN — §Image answers. PDFs stay with docforensics. | You require risk_score or confidence on every scored document, or read a missing risk_score as 0. |
math_checks (correction) | On a docforensics document it lists every arithmetic relation the engine ran, on any verdict: ok: true for one that held, ok: false for one that failed or could not be resolved. It never held only failing checks on flagged documents. | You treat every row as a contradiction: filter on ok === false. |
metadata (correction) | A docforensics document carries up to four rows — Capture, Created with, Software kind, Software note — not the ten-row panel of the earlier engine. | You read Producer, Format or another panel row by key. |
findings (correction) | A CLEAN document can carry findings, with no lead_finding and no indicator for them: they did not decide the verdict. Only findings[lead_finding] did. | You render every finding as an edit. |
415 / 422 at submit (correction) | Content that matches no known format is 422 unreadable_document only when the file name ends .pdf, .png, .jpg or .jpeg; otherwise (a .txt file, no extension) it is 415 unsupported_type. | You handle only one of the two. |
models option (correction) | Never chose the engine: the file's content does. It is validated and is part of the byte-identical resubmission key, nothing more. | You send models: ["docforge"] expecting photos to be scored differently. |
| Code lists | Every reason code and indicator code is now listed on this page (§Code reference); the engine endpoints the reference used to point to are not served by the API. | Nothing. |
| OpenAPI | This API's spec is https://docs.scam.ai/openapi.json (YAML: https://docs.scam.ai/checkreality-openapi.yaml). /openapi.json on the API host is the Scam AI platform API and does not describe /v1/documents. | You generate a client from the API host's /openapi.json. |
| Request ids | Every response carries X-Request-Id and X-Correlation-Id, and usually X-Trace-Id. Quote X-Request-Id (and the doc_ id) when you contact us about a request. | Nothing; log them. |
Changes on 2026-10-15 — docforensics engine 0.16
The docforensics engine moves from 0.14 to 0.16 on each estate as it is deployed there: the sandbox now (it runs 0.17), production on release. One field is added, regions[].role (the regions row); no field or endpoint is removed and no existing field changes shape. One limit is new: an image over 89,478,485 pixels is refused, where it used to be scored (the image size row). Photos and scans take longer to score (the timing row). Every other row below is a value a 1.3 client already accepts, or a verdict the engine now reaches more carefully.
| Area | What changes | You need to act if… |
|---|---|---|
model_version | Reads 3.1 (was 3.0). The badge belongs to the estate, not to the document: once an estate runs the new engine, every document it returns reads 3.1, including documents scored before the upgrade. | You compare model_version to 3.0. |
reason_codes | Four new codes from the issuer-registry check, which now reads a dated, pinned snapshot of the FDIC, NCUA and OCC registries (with their name history) instead of a live lookup: REFDATA_UNAVAILABLE (the snapshot could not be read, so the issuer was not checked), REFDATA_STALE (the snapshot is older than the document, so a recent change may be missing), ISSUER_NAME_AMBIGUOUS (more than one institution holds the printed name), ISSUER_NAME_NOT_IN_FORCE_ON_DATE (the institution did not carry that name on the document's date: renamed, merged or closed; a note, never a flag). | You validate reason_codes against a closed list. |
| Indicator codes | 121 checks, up from 96: 25 added, none renamed or removed. Three titles read differently: learned_model_score "Structural model score", sample_or_template_markers "Template or specimen cues", amount_split_from_currency_sign "Amount cut from its dollar sign, or digits spaced unevenly". | You key on title rather than code. |
| Template and specimen cues | Information, no longer a route to review. A document can be CLEAN carrying TEMPLATE_OR_SPECIMEN or NOT_AN_ISSUED_DOCUMENT in reason_codes, with a low-severity indicator saying what it reads as: fiction_reserved_phone_number (a phone number from the block reserved for fiction), sample_or_template_markers (sample or test wording, placeholder values, almost nothing on the page) or producer_names_the_issuer (the software that made the file carries the issuer's name). On a CLEAN document the next_step reads "This looks like a template or sample rather than an issued document; ask for the copy the issuer sent". The same cues used to make the document SUSPICIOUS (review, or shadow for producer_names_the_issuer), and the last two indicators were medium, so expect fewer SUSPICIOUS verdicts. | You relied on a template cue arriving as SUSPICIOUS, or on its indicator's severity: route on the reason code instead. |
| Scanned pages left unread | Image pages past the reader's page cap or time budget are reported: PAGE_CAP in reason_codes beside NO_TEXT_LAYER, and when nothing was found the verdict is INSUFFICIENT_EVIDENCE, never CLEAN. | Nothing: INSUFFICIENT_EVIDENCE routing is unchanged. |
| Arithmetic read from a scan or photo | When the arithmetic the engine reads off the image fails only on running-balance rows, where one misread row breaks every row after it, the document is SUSPICIOUS at most, no longer FORGED. A failure on a statement's summary or on a pay stub's or W-2's labelled boxes can still make it FORGED. | Nothing; a verdict can only move down. |
| Image size | A PNG or JPEG over 89,478,485 pixels (width × height, about 89.5 megapixels) is refused: 413 file_too_large at submit, where the size is read from the image's header, or, when the header cannot be read there, a failed document with file_too_large. Until now such an image was scored up to about 179 megapixels, and a larger one ended failed with unreadable_document. A 48 or 50 megapixel photo fits; the 108 and 200 megapixel camera modes do not. A file the engine refuses as over one of its other limits (a page over 200 × 200 inches, more than 50,000 PDF objects) now ends failed with file_too_large too, not unreadable_document. | You submit full-resolution photos from a 108 or 200 megapixel camera: send them at a normal resolution (300 dpi is enough), or the issuer's PDF. |
| Time to score a photo or scan | Longer. The engine reads each photographed or scanned page more thoroughly than before, for example checking which way up the page is before reading its figures. On the sandbox a photo now takes about 8 to 14 s of server time, where it took about 1 s. Native PDFs take as long as before. | Your client gives up on a photo or scan within about 15 s. ?wait=true still waits up to 30 s before it returns the processing document. |
findings | Can group differently. Arithmetic checks that read the same cell now share one finding instead of one each: on a pay stub whose net-pay and year-to-date checks both read its gross pay, findings holds 1 entry where it held 2, with both checks in its codes. The verdict and the lead finding are unchanged. | You count findings, or expect one finding per check: read each finding's codes. |
findings[].old_text → new_text | Unchanged. The engine reordered its arithmetic evidence internally; the API keeps publishing each check's pair in the order it always has. | Nothing. |
regions | An arithmetic contradiction now boxes every cell the calculation reads, not only the figure under test, so a flagged document can carry more regions than before (for example 4 instead of 2 on a statement whose summary does not add up). Each region still names its indicator_code, and each region now also says whether it is the result or an operand: role is result on the figure the calculation is checked against and operand on a printed figure it reads, and is omitted on every other region. Neither role says which cell was edited. | You count regions, or treat every region as an edited spot: the spot that decided the verdict is findings[lead_finding]; role is additive. |
Changes in 1.3 (2026-09-30) — the docforensics engine
A new scoring engine, docforensics, is rolled out estate by estate (sandbox first). Everything below is additive; 1.2 clients keep working unmodified.
| Area | What changes | You need to act if… |
|---|---|---|
| Fourth verdict | INSUFFICIENT_EVIDENCE: the document could not be examined (scan, photo, invisible OCR layer) or could not be examined in full (every text page is examined; scanned or image-only pages up to 20). No risk_score; a next_step. Neither flagged nor clean — §Document. | Your code assumes three verdict values, or treats a missing risk_score as 0. Route it by next_step, never auto-pass. |
| Indicator codes | Documents scored by docforensics carry its own snake_case codes with an identifier beside each; the earlier engine's codes are not renamed and stay on the documents it scored. Branch on verdict, render codes from title / severity. | You branch on a closed list of codes. |
| New fields | confidence, review_tier, next_step, reason_codes, findings, lead_finding on the Document; verdict_counts.insufficient_evidence on the loan summary; severity: low. headline is now emitted; checks_passed on every completed document. Each field is defined in checkreality-openapi.yaml and reference.md; this page does not list them field by field yet. | Nothing — every one is additive and omitted where it does not apply. |
model | Reads docforensics on a cut-over estate. | You compare model to eva-doc. |
Changes in 1.2 (2026-09-19)
POST /v1/documents/{id}/reprocess requires the original document as the multipart file part, checked against the document's recorded sha256: no file answers 400 source_file_required, a mismatch 400 invalid_request. New error code source_file_required.
New in spec 1.1
All additions below are additive — no field removed, no enum member dropped, no existing response shape changed. A client built against 1.0 keeps working unmodified.
| Capability | Surface |
|---|---|
| Cross-loan triage queue | GET /v1/loans · POST /v1/loans/{external_ref}/review · in_review disposition — §Queue |
| Coverage attestation | PUT /v1/loans/{external_ref}/expected-documents · LoanSummary.coverage — §Coverage |
| Flagged-amount attribution | SubmitOptions.attribution · LoanSummary.flagged_amount — §Attribution |
| Verified borrower re-capture | /v1/verifit/* with C2PA provenance and a server-side audit-safe validator — §Verifit |
| Recomputable audit evidence | format: json · findings[] · manifest[] · manifest_digest — §Audit |
Changes on 2026-09-28
Two corrections to previously-wrong behaviour, and one additive field. No field or endpoint is removed.
| Area | Before | Now | You need to act if… |
|---|---|---|---|
POST /v1/documents resubmission of a document whose analysis ended failed | Answered 200 with the cached failure — the contract's promised cache-hit behaviour for a completed document, wrongly applied to a failed one too | Not a cache hit: re-analyses the same bytes again under the same id, with the original submission's options (202; billed if the analysis completes — a repeat failure is free) | You relied on resubmitting a failed document to learn its outcome without triggering new work — it now does new work, exactly as "resubmit" always meant |
LoanSummary.risk_score | A SUSPICIOUS or FORGED document with no score contributed 0 to the loan's risk, sinking it to the bottom of -risk_score triage | Contributes its verdict's band floor instead — SUSPICIOUS 40, FORGED 70; INSUFFICIENT_EVIDENCE (no score by design, no flag) counts as 0 | A loan holding an unscored flag can read a higher risk_score (unchanged when a higher-scored flag is already present); top_reason, the order of flagged[], and the audit-report findings order can change too |
LoanSummary.documents_unscored | Did not exist | New integer field: how many of the loan's completed documents carry no risk score (including every INSUFFICIENT_EVIDENCE one) | Nothing; additive |
Renamed: Verifi is now Verifit (2026-09-09)
The borrower re-capture product changed its name. Nothing about how it works changed. What changes for you, and when the old names stop working:
| Was | Is | Old name works until |
|---|---|---|
/v1/verifi/* | /v1/verifit/* — the old prefix is an authenticated alias answering with Deprecation: true and Link: </v1/verifit>; rel="successor-version" | 2026-12-31 |
Document.verifi_eligible, Document.verifi_capture_id | verifit_eligible, verifit_capture_id — both names are emitted with the same value | 2026-12-31 |
object: "verifi_link" / "verifi_capture" | "verifit_link" / "verifit_capture" — on every path, including the alias | Changed now |
https://verifi.scam.ai/claim/… | https://verifit.scam.ai/claim/… — the old hostname is retired; a link minted before the rename must be re-issued | Retired 2026-09-09 |
Partner webhooks: Verifi-Signature, Verifi-Event-Id, Verifi-Event-Type | Verifit-Signature, Verifit-Event-Id, Verifit-Event-Type — every delivery carries both sets with identical values | 2026-12-31 |
C2PA assertion org.verifi.inspection | org.verifit.inspection on images signed from now on; images signed earlier keep their label and stay valid | Never removed from signed images |
Changes on 2026-09-09 (post-rename fixes)
Found by the production e2e run after the rename. Two are corrections to behaviour you may have coded around; the rest tighten guarantees.
| Area | Before | Now | You need to act if… |
|---|---|---|---|
POST /v1/documents with file_url | The URL was never fetched; every URL submission ended 422 unreadable_document | Fetched once at submission (60 s, no cookies, at most three redirects, every hop re-checked). Failures are 422 url_fetch_failed with the reason; a body over 200 MiB is 413 file_too_large | You fell back to multipart because URL submission "did not work" — it does now |
POST /v1/documents/{id}/reprocess | created_at moved to the reprocess time | created_at never changes; completed_at moves | You sorted or de-duplicated on created_at after a reprocess |
GET /v1/audit-reports/{id}/download | Any or no sig served the file; a format: json report still served a PDF | sig required (400) and checked (404); json reports have no file (404) | You rebuilt the download URL yourself instead of using download_url |
| Audit PDF bytes | Each download embedded its own creation time — two different files | Byte-identical for the same completed report; safe to hash and store | Nothing; you may now content-address it |
Document.model, AuditReport.models[].name | Internal engine id pdf-native-forensics on some documents | Always the published name eva-doc, matching the PDF | You matched on the internal id |
GET /v1/verifit/links/{id}/qr.png | Served for revoked and expired links | 404 once the link is not pending | You print QR codes after revoking |
message.template_id | Any string accepted and echoed | Must be a preset from GET /v1/verifit/templates; otherwise 404 | You sent your own ids in template_id — move them to your own records |
borrower in production | Undocumented: phone or email required for every delivery | Documented (unchanged behaviour); the sandbox still accepts a mint without one | You validated in the sandbox only |
doc_url, /openapi.json | 404 | /errors/<code> is a page per code; /openapi.json serves the spec as JSON | Nothing |
Changes on 2026-09-08
Behaviour changes shipping with the next production release of api.scam.ai. No field or endpoint is removed; one endpoint is added; two responses change for requests that were previously accepted, or answered incorrectly.
| Area | Before | Now | You need to act if… |
|---|---|---|---|
Idempotency-Key (POST /v1/documents, /v1/verifit/links, /v1/audit-reports) | A reused key replayed the stored response whatever the payload | The key is bound to the payload it first carried; a different payload is 422 idempotency_key_reuse | You derive keys from something coarser than the request — a retry loop that mutates the body, or a per-loan key |
expires_in on POST /v1/verifit/links | P0D / PT0M minted a link that had already expired | Zero durations are 400 invalid_request | You ever send a zero duration (nothing else changes) |
Verifit link status | Could still read pending after expires_at had passed | expired once expires_at passes; the one-live-link-per-document rule uses the same view | You branch on status — it now agrees with expires_at |
POST /v1/loans/{external_ref}/review | note: null was ignored | note: null clears the note, as assignee: null unassigns | You send note: null meaning "no change" — omit the key instead |
DELETE /v1/loans/{external_ref}/expected-documents | No way to withdraw a declaration | Withdraws it; coverage.expected_total, unscreened and complete become absent again | Optional — use it to retract a declaration made in error |
| Malformed JSON body | A non-standard 400 shape, answered even without a key | 400 malformed_json in the standard envelope; 401 invalid_api_key without a key | Your client parses the old shape |
AuditReport.pages | Estimated | Measured from the rendered PDF (values may be lower) | Anything paginates on it |
GET /v1/verifit/links/{id}/qr.png | No cache policy on the response | Cache-Control: private, no-store | You cache the PNG in a shared cache or CDN — honour the header |
Authentication
API keys are accepted in either header — Authorization: Bearer sk_live_… or X-API-Key: sk_live_…. The two are equivalent and both work on every endpoint; use whichever suits your client. sk_test_… for sandbox, sk_live_… for production.
Headers only: the key is not a body field and not a query parameter, since GET endpoints have no body and a key in the query string ends up in access logs, browser history and referrer headers. Keys are server-side only — a request carrying a browser Origin header is rejected 401 (browser_origin_forbidden). Rotate keys in the dashboard; two keys can be live simultaneously for zero-downtime rotation.
When both headers are present, X-API-Key takes precedence. Keys must be shaped sk_live_… / sk_test_… — a malformed key, an unknown key, or a live key presented to the sandbox host all return 401 invalid_api_key with a message naming the specific problem. Rate limits apply per key.
Submit a document
Upload the file directly, or pass a pre-signed URL so bytes move straight from your document store. Attach external_ref (your loan id) to every submission — it powers listing and audit reports.
curl https://api-sandbox.scam.ai/v1/documents?wait=true \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: req-84620" \
-F "file=@25 (4).pdf" \
-F 'options={
"external_ref": "loan_84620",
"category": "bank_statement",
"models": ["eva-doc"]
}'
| Option | Type | Notes |
|---|---|---|
external_ref | string | Your loan/file id. Groups documents; echoed on webhooks. |
category | enum | bank_statement · paystub · tax_form · id_document · utility_bill · other. Optional hint — improves issuer-pipeline and cross-total checks. |
models | array | eva-doc (default) · docforge. Validated, and part of the byte-identical resubmission key, but it does not choose the engine: the file's content does (production scores every file with docforensics; the sandbox sends PNG and JPEG to image-forensics). Leave it at its default. |
webhook_url | uri | Per-request override of the account webhook. Must be an absolute, public http(s) URL — a malformed URL or one pointed at a private/internal host (localhost, an RFC1918 address, the cloud metadata address, .internal) is rejected 400 invalid_option. An empty string means "use the account default", exactly as omitting the field does. |
attribution | object | 1.1 What figure this document substantiates — {section, field, currency, amount_minor}. section is required; currency defaults to USD and amount_minor to 0. Echoed on the Document and summed into LoanSummary.flagged_amount. See §Attribution. |
expected_key | string | 1.1 Links the submission to a declared expected-document item so coverage.missing resolves. See §Coverage. |
Limits: PDF/PNG/JPG · 200 MiB (209,715,200 bytes) · 200 pages · on the sandbox (production on release) images up to 89,478,485 pixels (width × height, about 89.5 megapixels). file.pages is the page count of the file. File type is detected from the file's content (PDF, PNG or JPEG signatures), not its name; content in another format (GIF, WEBP, TIFF, HEIC, BMP) → 415 unsupported_type, content matching no known format → 422 unreadable_document when the file name ends .pdf, .png, .jpg or .jpeg and 415 unsupported_type otherwise (a .txt file, no extension), over size, page or pixel limit → 413 file_too_large, empty upload → 400 missing_file. A PDF's page count is read at submit whenever the file can be parsed, so an over-limit PDF gets this 413 synchronously, on the submitting request, before any analysis runs; when the count can't be read there (encrypted, malformed), the same limit is still enforced, just after scoring — the document is accepted, then ends failed with file_too_large instead. Where the pixel limit applies, an image's width and height are read from its header at submit the same way; when its header cannot be read there, or the engine finds a file over one of its own limits (a page over 200 × 200 inches, more than 50,000 PDF objects), the document ends failed with file_too_large. A file that passes the content check but cannot be opened by the engine ends as failed with unreadable_document — or, when what stopped the engine was encryption, with file_encrypted; neither is a rejection at submit, because opening the file is the only way to find out. A PDF carrying only an owner password ("printing restricted") opens without one and is analysed normally. Byte-identical resubmissions (same SHA-256, same model set, same filename, same external_ref) return 200 with the existing Document — the cached result, or the one still processing — at no charge. A document whose analysis ended failed is not a cache hit: resubmitting it analyses the same bytes again under the same id, with the original submission's options (202; billed if the analysis completes — a repeat failure is free). To re-analyse a failed document, use a new Idempotency-Key (or none): a key replays its first response for 24 h. A JSON submission's file_url must be an absolute http(s) URL on a public host, and so must anything it redirects to — anything else is 422 url_fetch_failed. A file_url file is named from Content-Disposition, else the last URL path segment, else document.pdf — the name is not consulted for typing either; a PDF named .png is still detected and scored as a PDF. A JSON request body over 1 MB is 413 request_too_large. ?wait must be true or false; any other value is 400 invalid_request. Idempotency-Key is 64 characters at most (longer is 400 invalid_request); replays are keyed on the header value alone (24 h) and marked with an Idempotency-Replayed: true response header — never reuse a key with a different payload. The same header works on POST /v1/verifit/links and POST /v1/audit-reports.
The Document object
Risk signal → routing
Branch on verdict; use risk_score for ordering and thresholds within a band. Score bands below are observed behavior, not contractual.
| Verdict | Meaning | Typical score | Recommended routing |
|---|---|---|---|
CLEAN | No tampering evidence found. | 0–39 | Auto-pass; retain verdict in the loan file. |
SUSPICIOUS | Evidence of alteration, but not conclusive (e.g. re-saved through a consumer PDF editor). | 40–69 | Human review — queue in Fraud Review; request re-capture or source document. |
FORGED | Conclusive tampering evidence (recoverable edit trail, arithmetic contradiction). | 70–100 | Escalate; do not rely on the document. Not by itself grounds for adverse action. |
INSUFFICIENT_EVIDENCE (1.3) | Nothing in the document could be examined (a scan, a photo, an invisible OCR layer), or nothing fired on a document that could not be examined in full (every text page is examined; scanned or image-only pages up to 20). Neither flagged nor clean. | none — risk_score is omitted | Follow next_step (ask for the issuer's original file); for a photo or scan, offer a Verifit re-capture. Never auto-pass. |
An image-forensics answer (sandbox; production on release) also omits risk_score when it is CLEAN, or SUSPICIOUS on the image model's evidence alone — §Image answers. Read a missing score as "not scored", never as 0.
Example — flagged document
The single object your integration renders from. Example — a flagged bank statement (a scored document may omit several display fields; see the notes after the example):
{
"id": "doc_9f2c1a",
"object": "document",
"status": "completed",
"external_ref": "loan_84620",
"category": "bank_statement",
"file": { "name": "25 (4).pdf", "type": "pdf", "pages": 1,
"size_bytes": 183296,
"sha256": "e10c2473631cd9a7cc6ad2b268298b949b66d006d8d9c…" },
"issuer": "Commerce Bank", // detected source — list secondary line
"issue_date": "2025-11-17", // date printed on the document, OCR'd
"capture": "native_pdf", // native_pdf | photo | scan — measured; OMITTED if undetermined
"model": "eva-doc",
"model_version": "3.1", // the estate's badge: 3.0 on production, 3.1 on the sandbox; reprocess does not change it
"verdict": "FORGED",
"risk_score": 87,
"headline": "ending balance digitally altered after initial save",
"indicators": [
{ "code": "TOUCHUP_TEXTEDIT", "severity": "high",
"title": "Acrobat TouchUp text edit",
"what_it_is": "A marker Adobe Acrobat leaves behind when someone edits text…",
"what_it_means": "Bank-generated statements are never edited in Acrobat after export…",
"how_detected": "The piece-info dictionary contains a TouchUp_TextEdit entry…" },
{ "code": "REVISION_TEXT_DIFF", "severity": "high", "title": "Text changed between saved revisions", "…": "…" },
{ "code": "CROSS_TOTAL_MISMATCH","severity": "high", "title": "Summary arithmetic does not add up", "…": "…" }
// Illustrative codes shown. A scored document carries the engine's
// snake_case reason codes — touch_up_text_edit, has_piece_info,
// font_character_remapping, create_date_mismatch, … (~90 codes),
// severity high | medium — open enum either way, see §Versioning
],
"regions": [
{ "page": 1, "x": 78.2, "y": 33.1, "w": 12.4, "h": 2.1,
"confidence": 0.94, "severity": "high",
"label": "$50,521.19", "indicator_code": "REVISION_TEXT_DIFF" }
],
"revisions": { "count": 2, "diffs": [
{ "from_revision": 0, "to_revision": 1,
"before": "Ending Balance on June 5 … $10,521.19",
"after": "$50,521.19" } ] },
"math_checks": [
{ "formula": "beginning + deposits − withdrawals − checks = ending",
"computed": "$10,521.19", "stated": "$50,521.19", "ok": false,
"note": "Δ $40,000.00 appears nowhere in the transaction detail" },
{ "formula": "itemized deposits = deposits total",
"computed": "$3,615.08", "stated": "$3,615.08", "ok": true }
],
"metadata": [
{ "key": "Producer", "value": "Adobe Acrobat Pro DC 2025", "anomalous": true },
{ "key": "Creator", "value": "Commerce Bank eStatement Services" },
{ "key": "Incremental revisions", "value": "2", "anomalous": true }
],
"attribution": { "section": "asset", "field": "total_verified_assets",
"currency": "USD", "amount_minor": 4000000 }, // 1.1 — echoed, never inferred
"verifit_eligible": true, // 1.1 — a borrower re-capture would add evidence
"created_at": "2026-07-27T20:12:04Z",
"completed_at": "2026-07-27T20:12:09Z"
}
Current engine build — field notes
headline,issuer,issue_date,regions,revisions,genai_heatmapare nullable/omittable — the current engine build does not produce them for every document. Null-check before rendering;LoanSummary.top_reasonis null wheneverheadlineis.metadatais a fact panel whosevalueis a display string. Adocforensicsdocument carries up to four rows, each only when the engine reported it:Capture("born digital", "photo"),Created with,Software kind("office suite") andSoftware note. A document the earlier engine scored carries up to 10 fixed rows (Format,Pages,File size,Producer,Creator,Created,Modified,Incremental revisions,Fonts embedded,XMP metadata).anomalousis reserved —falseon every row.math_checkson adocforensicsdocument lists every arithmetic relation the engine ran, on any verdict:ok: truefor one that held,ok: falsefor one that failed or could not be resolved (thencomputed/statedare empty andnotesays why). Filter onok === false. A document the earlier engine scored lists only failing checks, on SUSPICIOUS/FORGED only. An empty array means no relation was reported, not that totals were verified.formulais a plain-language summary;computed/statedare best-effort and may be empty.findingscan be present on aCLEANdocument, with nolead_findingand no indicator for them: they did not decide the verdict. Onlyfindings[lead_finding]decided one.modelreports the engine that scored the document —docforensics,image-forensics(PNG and JPEG on the sandbox; production on release — §Image answers) oreva-doc(the earlier engine). The names inoptions.modelsare what you ASK for; they do not choose the engine and are not what a scored document reports back. Open enum.statusisprocessing|completed|failed. Content that is not a PDF/PNG/JPEG is rejected at submit with a4xx(415 unsupported_type, or422 unreadable_documentwhen the name ends.pdf,.png,.jpgor.jpeg); a file that passes that check but cannot be opened by the engine ends asfailed, asynchronously, with anerrorobject carrying the same catalogued code.captureis a measurement, not a restatement of the media type:photofor an image upload,native_pdffor a PDF whose page objects the structural model read,scanfor a PDF whose pages are themselves images. Omitted when the class was not determined — while the document isprocessing, on afaileddocument, and on the rare completed document classified neither way. Treat absence as "not determined", never asnative_pdf. Changed 2026-09-25: until then every PDF, scans included, reportednative_pdf; the enum itself is unchanged.
Region coordinates — drawing tamper overlays
Each entry in regions[] locates a tampered/suspect area. Origin is the page's top-left; x/w are percentages (0–100) of rendered page width, y/h of rendered page height — resolution-independent, so the same box fits any zoom level:
// place an overlay div on a rendered page of size pageW × pageH px
left: region.x / 100 * pageW
top: region.y / 100 * pageH
width: region.w / 100 * pageW
height: region.h / 100 * pageH
// region.confidence (0..1) and region.severity drive the badge (94% · high)
// region.indicator_code links the box back to its explanation card
// region.role (since 2026-10-15, arithmetic contradictions only): "result" is the figure the
// calculation is checked against, "operand" a figure it reads; neither says which was edited
A CLEAN document (a paystub at risk 5/100, say) has an empty indicators array and instead lists checks_passed — revision history, TouchUp scan, producer-pipeline match, font-embedding consistency, overlay/script scan, timestamp consistency — so processors see what ran, not just an absence of flags.
Rendering rule: verdict + risk_score drive triage; indicators and regions drive the review UI. Score thresholds (e.g. route ≥ 60 to Fraud Review) are your policy decision — we recommend starting with the defaults in the integration guide and tuning on your own sandbox traffic.
Image answers — image-forensics (sandbox now; production on release)
On the sandbox a PNG or JPEG upload is answered by model: "image-forensics": an image model that looks for edits and AI generation, beside the docforensics checks, which still run on the same file. PDFs, scanned ones included, stay with docforensics; on production every file is still scored by docforensics. An image answer differs from a docforensics one here:
verdict— the image model can raise a document toSUSPICIOUS, never toFORGED:FORGEDcomes only from thedocforensicschecks.CLEANneeds every image check to complete with nothing found.risk_score— present only when thedocforensicschecks themselves found the imageSUSPICIOUSorFORGED. Omitted onCLEAN, on aSUSPICIOUSthe image model raised alone, and onINSUFFICIENT_EVIDENCE. A loan rollup counts an unscored flag at its band floor and indocuments_unscored.confidence— only onFORGED.review_tier—reviewon everySUSPICIOUS, including one the image model raised alone; it does not mean two independent checks agreed.next_step— omitted onCLEAN; present on every other verdict.indicators— thedocforensicsindicators, plusIMAGE_EDIT_REVIEW(title "Possible image edit or AI generation", severitymedium, noidentifier) when the image model found a possible edit. Model evidence asks for review; it never confirms forgery.reason_codes— addsIMAGE_EDIT_REVIEWwhen the image model found a possible edit,IMAGE_ASSESSMENT_INCOMPLETEwhen the image checks could not all complete and found nothing, andIMAGE_COVERAGE_LIMITEDwhen the image is a digitally created document (a screenshot, an exported page). When the image checks complete and thedocforensicschecks flagged nothing,REQUEST_NATIVE_PDFandNO_TEXT_LAYERare left out.- A digitally created document received as an image is never
CLEAN. With nothing found it isINSUFFICIENT_EVIDENCEcarryingIMAGE_COVERAGE_LIMITED, andnext_stepasks for the original, usually a PDF; a clean result on such an image is not evidence that it is genuine. With a finding it isSUSPICIOUSlike any other image. - When the image checks could not all complete and nothing was found, the verdict is
INSUFFICIENT_EVIDENCEwithIMAGE_ASSESSMENT_INCOMPLETE. checks_passed— onCLEAN, thedocforensicschecks that passed plusimage_assessment.captureisphoto;model_versionis the estate's badge (3.1on the sandbox).- If the image model stays unavailable through every retry, the document ends
failedwithserver_error, as for any scorer outage.
Retrieve & list
Returns the Document in any status. Poll at ≥ 2 s intervals if not using webhooks.
Cursor-paginated (limit 1–100, default 25; starting_after), newest first. Filter by external_ref or verdict — this is the query behind a "View flagged docs" screen. Returns slim DocumentSummary rows by default (view=summary) — id, name, verdict, risk_score, issuer, issue_date, capture, headline, review disposition — i.e. exactly one list row per object. view=full returns complete Documents.
UI → field mapping
Every element in the Fraud Review UI maps to one contract field — nothing in the interface requires client-side inference:
| UI element | Source |
|---|---|
| Verdict chip — "Forged · risk 87 / Clean · risk 3" | verdict + risk_score (summary row) |
| List secondary line — "Commerce Bank", "SAP Payroll import", "photo upload" | issuer + capture |
| Issue-date column — "11/17/2025" | issue_date (OCR-extracted) |
| Modal header — file · issuer · date · headline · model badge | file.name · issuer · issue_date · headline · model |
| "Why this verdict" explanation cards | indicators[] (sorted high-severity first) |
| Green "Verified clean" checklist | checks_passed[] |
| Tamper overlay boxes + "94% · high" badges | regions[] (percent coords, confidence, severity) |
| "Incremental revisions: 2" + before/after line | revisions |
| Cross-total verification rows (✓/✕, Δ note) | math_checks[] |
| Document metadata table with red "anomalous" tags | metadata[] (anomalous: true) |
| File integrity — SHA-256 / md5 | file.sha256 · file.md5 |
| Re-process button | POST /v1/documents/{id}/reprocess |
| Fraud Review queue row — loan risk, "3 flagged / 36", top reason | GET /v1/loans/{external_ref} |
| Loan banner — "Risk 47/100 · 2 forged · 1 suspicious of 36 docs" | LoanSummary.risk_score + verdict_counts |
| "41 min manual review saved on this loan" | LoanSummary.estimated_review_minutes_saved (heuristic, display-only) |
| "CheckReality V3.1" / model badges | model_version (on every document; one value per estate) · LoanSummary.models |
| Fraud Audit row — "36 screened · 3 flagged · 5 pages" | AuditReport counters + download_url |
| Coverage line — "36 of 36 screened" | LoanSummary.coverage — screened / expected_total / complete |
| Consistency card — "$40,000 of verified assets rests on flagged documents" | LoanSummary.flagged_amount |
| Queue tab — loans sorted by risk, assignee, state chip | GET /v1/loans — review_state · assignee |
| "Request re-capture" action | Document.verifit_eligible → POST /v1/verifit/links |
| Re-capture result panel — provenance, device, reconciliation | VerifitCapture.signal_groups[] + c2pa + contradicts_original |
Review, reprocess & loan rollups
Records the human disposition from the review queue: {"disposition": "in_review" | "cleared" | "escalated" | "confirmed_fraud", "note", "reviewer"}. The disposition is echoed on the Document, clears the doc from the loan's flagged count when cleared, appears in later Fraud Audit Reports, and — de-identified — feeds scoring calibration. Idempotent per document; a later call replaces the earlier one.
in_review is new in 1.1 — a "someone is looking at it" state, so you need no parallel store keyed on our document ids. The three existing values are unchanged.
Re-runs analysis with the current model (the UI's "Re-process" action). Attach the original document as the multipart file part; the bytes must match the document's recorded sha256 (no file: 400 source_file_required; mismatch: 400 invalid_request). Returns 202, status back to processing; the prior verdict stays readable until the new one lands. One still queued or running answers 409 analysis_in_progress.
Aggregate rollup for a queue row and the loan banner: loan-level risk_score, verdict_counts (clean/suspicious/forged), documents_flagged / documents_total / documents_processing / documents_unscored, models used, estimated_review_minutes_saved (display-only heuristic), top_reason (headline of the highest-risk unresolved document), and the flagged DocumentSummary rows so expanding a queue row costs no extra call. A SUSPICIOUS or FORGED document with no score counts toward risk_score at its verdict's band floor — SUSPICIOUS 40, FORGED 70 — and documents_unscored counts how many of the loan's completed documents have no score this way. An INSUFFICIENT_EVIDENCE document has no score by design and is no flag: it counts as 0 and is counted in documents_unscored. A cleared review removes a document from the flagged count — the queue drains as reviewers work.
1.1 adds four fields to the same object: coverage (§Coverage), flagged_amount (§Attribution), review_state and assignee (§Queue).
Fraud Review queue
A queue is a cross-loan view, so 1.1 exposes loans as a listable, filterable, sortable collection with their own triage state. Previously a queue could only be assembled by listing documents and grouping them client-side.
Returns LoanSummary rows for every external_ref with at least one completed document. Filters: has_unresolved (at least one flagged document without a cleared disposition), min_risk_score, review_state, assignee. Sort by -risk_score (default), risk_score, ±updated_at. Cursor-paginated with limit and starting_after.
{ "review_state": "in_review",
"assignee": "dana.whitfield@example.com", // opaque to us — never resolved to a person
"note": "Requested re-capture of the June statement" }
review_state ∈ open · in_review · cleared · escalated, defaulting to open once any document in the loan is flagged. It is loan-level workflow state, distinct from the per-document disposition — so the queue can be worked without holding a second state store keyed on our identifiers. An explicit null clears assignee or note; omitting the key leaves the current value alone. Idempotent; a later call replaces the earlier state.
Routing rules stay on your side, by design. Score thresholds are a per-lender policy decision, and we should not be the owner of a decision a lender must be able to justify. What the API provides is everything a rule needs to evaluate against — verdict, risk_score, unresolved-flag state and a queryable loan list. A rule such as route to Fraud Review when risk ≥ 40 or any document is FORGED is a client-side policy evaluated on the data above.
Coverage attestation
"Every document was screened" is only meaningful if it can be reconciled. The API knows the documents it received; it cannot know about one that was never sent. 1.1 closes that by letting you declare the expected set up front.
{ "expected_total": 36,
"items": [ // optional itemisation
{ "key": "bank_stmt_jun", "name": "June bank statement", "category": "bank_statement" },
{ "key": "paystub_2", "name": "Paystub 2", "category": "paystub" }
] }
Submit each document with the matching expected_key and the declared item resolves. LoanSummary.coverage then reports against the declaration:
To take a declaration back, DELETE /v1/loans/{external_ref}/expected-documents withdraws it: expected_total, unscreened and complete become absent again and missing empties. PUT can only replace a declaration with another, and expected_total: 0 asserts complete: true — a stronger claim than none. Idempotent; returns the rollup.
"coverage": {
"expected_total": 36, "submitted": 36, "screened": 36,
"processing": 0, "failed": 0, "unscreened": 0,
"complete": true, "missing": []
}
Until this endpoint is called, expected_total, unscreened and complete are absent from coverage entirely — not null, and never silently true. That distinction is the point: a coverage claim that cannot fail is not evidence. If you declare 36 and submit 34, coverage says so and the audit report prints it. loan.coverage_complete fires when the set closes.
Flagged-amount attribution
Counts answer "how many documents are flagged"; underwriting asks "how many dollars rest on them". Tell us what each document substantiates and we compute the exposure server-side, so the figure in your UI and the figure in the audit report cannot drift apart.
// on submit
"attribution": { "section": "asset", "field": "total_verified_assets",
"currency": "USD", "amount_minor": 4000000 }
// on GET /v1/loans/{external_ref}
"flagged_amount": {
"currency": "USD", "amount_minor": 4000000,
"basis": "Total verified assets substantiated by documents that are SUSPICIOUS or FORGED and not cleared",
"contributing_document_ids": ["doc_9f2c1a", "doc_3b71e4"]
}
section ∈ asset · income · liability · property · identity · other, and it is required — currency defaults to USD and amount_minor to 0, so an attribution naming only a section is valid. Every attributed document on one loan must agree on currency; a second currency is refused at submit with 422 attribution_currency_conflict, because a rollup that cannot add is worse than a submission that fails. field is your own field id. Amounts are integer minor units (cents) rather than decimals so sums are exact — elsewhere the API uses display strings for money (math_checks.computed), but those are never arithmetic operands and these are.
Attribution is never inferred. A document with no attribution contributes nothing to the sum, and flagged_amount is absent when no document in the loan carried one. Clearing a document's review removes it from the total.
Verifit — verified borrower re-capture
When a document is unreadable, a photo of a screen, or contradicted by its own arithmetic, the strongest next step is a fresh capture of the source document taken under provenance. Verifit mints a single-use link that opens a mobile capture page; the photo is C2PA-signed on the device, analyzed by the normal pipeline, and reconciled against the original submission.
{ "document_id": "doc_9f2c1a",
"borrower": { "display_name": "J. Sample", "phone": "+19705550147", "locale": "en-US" },
"message": { "template_id": "quality",
"body": "The resolution on your {document} wasn't high enough for us to process…" },
"delivery": "sms", // sms | email | link_only (no PII required)
"expires_in": "PT48H" } // default PT48H, max P7D, must be positive
→ 201
{ "id": "vfl_4c81", "object": "verifit_link", "status": "pending",
"url": "https://verifit.scam.ai/claim/<token>", "qr_png_url": "…",
"message_audit": { "safe": true, "matched_terms": [], "acknowledged_disclosure": false },
"expires_at": "2026-07-29T20:32:00Z" }
The audit-safe validator is server-side and blocking. The message body is scanned against a tip-off term list before the link is minted. On a match the call returns 422 message_would_disclose_flag with the matched terms and no link is created — the guarantee being that a borrower under review is never told they are under review. A client-side check cannot make that guarantee. Override requires an explicit "acknowledge_disclosure": true, which is recorded on the link and surfaces in the audit report.
GET /v1/verifit/templates returns presets (quality, routine, missing) that pass the validator, as {object: "list", data: […], has_more: false}; {document} is the only interpolation token. Custom copy is accepted and validated identically. GET /v1/verifit/links/{id} and POST /v1/verifit/links/{id}/revoke manage a link; status ∈ pending · consumed · expired · revoked. In production the link is backed by the live Verifit service and the GET reflects its live status — a session consumed or abandoned out of band shows up on the next read; in the sandbox a deterministic fixture stands in for that service.
image/png — the QR encoding the link's url, for printing or for a desktop hand-off to the claimant's phone. This is what qr_png_url points at. Authenticated like every other endpoint; an unknown link is 404 not_found.
{ "id": "vfc_77a2", "object": "verifit_capture", "status": "completed",
"link_id": "vfl_4c81",
"document_id": "doc_9f2c1a", // the ORIGINAL submission
"recaptured_document_id": "doc_be40f2", // an ordinary Document — GET /v1/documents/{id}
"captured_at": "2026-07-27T20:32:07Z", "response_time": "PT6M14S",
"device": "iPhone 15 Pro · rear camera", "photo_count": 2,
"headline": "Authentic capture — and it contradicts the submitted upload",
"c2pa": { "spec_version": "2.3", "validation_state": "Trusted",
"signature_alg": "ES256", "timestamp_authority": "RFC3161",
"edits_since_capture": 0, "manifest_url": "…" },
"signal_groups": [
{ "id": "provenance", "signals": [ { "label": "Signature", "value": "ECDSA P-256 · valid", "status": "pass" } ] },
{ "id": "device_integrity", "signals": [ … ] },
{ "id": "forensics_rerun", "signals": [ … ] },
{ "id": "reconciliation", "signals": [ { "label": "Ending balance — re-captured", "value": "$10,521.19", "status": "flag" } ] }
],
"contradicts_original": true }
application/json — the same object as VerifitCapture.c2pa on its own, so an auditor can fetch the provenance without the reconciliation around it. This is what c2pa.manifest_url points at. Authenticated; an unknown capture is 404.
The four signal_groups ids are stable; signals within a group are additive, and each carries status ∈ pass · flag · unavailable. validation_state uses C2PA's own vocabulary so it can be checked against that spec rather than against our wording.
Two rules for the integration. A capture produces an ordinary Document — recaptured_document_id goes through the normal pipeline, so verdicts, indicators and regions need no special-casing downstream. And contradicts_original is the boolean to branch on; headline and each signal's value are display copy and may be reworded without notice.
Webhooks
Configure an HTTPS endpoint in the dashboard (or per-request via webhook_url). The payload embeds the full object — no follow-up GET needed.
| Event | Fires when | data |
|---|---|---|
document.completed | A document finishes analysis, any verdict | Document |
document.failed | Accepted but could not be scored: unreadable, encrypted, over the page or pixel limit, or scoring stayed unavailable through every retry | Document |
audit_report.completed | Report finished generating | AuditReport |
audit_report.failed | Report generation failed | AuditReport |
verifit.capture.completed | 1.1 Borrower completed a re-capture and it has been analyzed | VerifitCapture |
verifit.capture.failed | 1.1 Capture could not be analyzed | VerifitCapture |
verifit.link.expired | 1.1 Link passed expires_at unused | VerifitLink |
loan.coverage_complete | 1.1 screened reached the declared expected_total | LoanSummary |
POST /your/endpoint
X-ScamAI-Signature: t=1785182400,v1=5f8a…c21
{ "id": "evt_7d31", "type": "document.completed",
"created_at": "2026-07-27T20:12:09Z",
"data": { "id": "doc_9f2c1a", "verdict": "FORGED", "…": "…" } }
Verify before trusting: recompute HMAC-SHA256 of <t>.<raw body> with your signing secret, compare to v1 in constant time, and reject if |now − t| > 300 s.
const [t, v1] = sig.split(",").map(kv => kv.split("=")[1]);
const expect = crypto.createHmac("sha256", process.env.SCAMAI_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`).digest("hex");
const valid = crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(v1))
&& Math.abs(Date.now()/1000 - t) < 300;
Delivery contract: at-least-once, possibly out of order, retried with exponential backoff for 24 h on non-2xx. Deduplicate on evt_ id and treat handlers as idempotent. Requests carry User-Agent: ScamAI-CheckReality-Webhooks/1.1.
Current build: only document.completed, audit_report.completed, verifit.capture.completed and loan.coverage_complete are emitted — the *.failed and verifit.link.expired events are defined for forward compatibility. loan.coverage_complete fires from the expected-documents declaration. Treat the webhook as a nudge and GET the object for its final state rather than trusting the embedded payload to be terminal.
Audit reports & evidence
Aggregates a loan file's completed documents into the exportable Fraud Audit PDF (documents screened, findings, hashes, math checks). Pass {"external_ref": "loan_84620"} or an explicit document_ids array. Returns 202; if no completed documents match, 400 invalid_request — nothing to report on. Accepts Idempotency-Key: a replay returns the stored report with Idempotency-Replayed: true rather than minting a second one. On completion, download_url points at the download endpoint below and does not expire. coverage appears only on reports created with external_ref.
1.1 adds format ∈ pdf (default, unchanged response) · json · both. The JSON forms carry the same evidence the PDF prints, machine-readable:
POST /v1/audit-reports { "external_ref": "loan_84620", "format": "both" }
→ { "id": "rpt_2a71", "object": "audit_report", "status": "completed",
"documents_screened": 36, "documents_flagged": 3, "pages": 5,
"generated_at": "2026-07-27T21:04:11Z",
"models": [ { "name": "eva-doc", "version": "2.6" },
{ "name": "docforge", "version": "2.6" } ],
"coverage": { "expected_total": 36, "screened": 36, "unscreened": 0, "complete": true },
"findings": [ { "document_id": "doc_9f2c1a", "verdict": "FORGED", "risk_score": 87,
"headline": "ending balance digitally altered after initial save",
"indicators": [ … ], "math_checks": [ … ],
"metadata_anomalies": [ … ], "review_disposition": "escalated" } ],
"manifest": [ { "document_id": "doc_9f2c1a", "name": "25 (4).pdf", "category": "bank_statement",
"verdict": "FORGED", "risk_score": 87, "sha256": "e10c2473…" } ],
"manifest_digest": "9d4f…",
"manifest_digest_algorithm": "sha256-of-sorted-sha256-newline-joined",
"download_url": "https://api.scam.ai/v1/audit-reports/rpt_2a71/download?sig=9d4f…" }
findings[]carries exactly the indicators, math checks and metadata anomalies the Document carried — nothing is re-derived for the report, so a reviewer cross-checking the PDF against the evidence panel sees identical values.manifest[]lists every document in the report, including those with no findings. That is the coverage attestation;coveragestates it against the declared set.manifest_digestfolds the file set into one value a loan buyer can recompute independently.
The digest construction is specified exactly, because a value that cannot be recomputed is not evidence:
It is independent of generation time: regenerating a report over the same file set with the same model versions reproduces the same digest.
The file download_url points at. Authenticated like every other endpoint — send your key. Served as application/pdf with Content-Disposition: attachment; filename="<report id>.pdf".
The sig query parameter is the first 16 hex characters of manifest_digest. It is derived from the document digests, so it is stable, non-secret and does not expire: it names the file set, it does not authorise the download. A report is a fraud finding about a borrower, so the key is required and the link cannot be forwarded to someone outside the account.
Errors
Every error response is this envelope — one error member, nothing at the top level. type is the coarse class, code the stable machine key to branch on.
{ "error": { "type": "invalid_request",
"code": "message_would_disclose_flag",
"message": "…",
"doc_url": "https://docs.scam.ai/errors/message_would_disclose_flag",
"matched_terms": ["fraud"] } }
doc_url is always present. Context keys (e.g. matched_terms) are added inside error, alongside the standard four — read them from body.error.matched_terms, never from body.matched_terms.
| HTTP | Type | Typical codes |
|---|---|---|
| 400 | invalid_request | missing_file, source_file_required (a reprocess without the original document), invalid_option, malformed_json, invalid_request |
| 401 | authentication | invalid_api_key (bad/malformed/wrong-environment key), browser_origin_forbidden |
| 404 | not_found | unknown id (sandbox/production ids are disjoint), or an unknown route |
| 405 | invalid_request | method_not_allowed — a known route called with a verb it does not answer to, such as PUT /v1/documents; the Allow response header names what would work |
| 409 | conflict | not_reviewable, file_not_retained, link_already_active, link_already_consumed, capture_not_ready (the capture's evidence is still being produced; retry shortly), sandbox_only (a sandbox-only endpoint called against a live-backed deployment), analysis_in_progress (a reprocess called while the document is still queued or running; wait for completed or failed) |
| 410 | conflict | link_expired — mint a new link |
| 413 | invalid_request | request_too_large — the JSON request body itself (not a file) is over 1 MB |
| 413 / 415 | file_error | file_too_large (size, page count or image pixels over limit, an uploaded or fetched file), unsupported_type, file_encrypted (only ever on a failed document, never at submit — see below) |
| 422 | processing_error | url_fetch_failed (the file_url could not be fetched: not http(s), a private or internal host, DNS/connect/TLS failure, 60 s timeout, non-2xx, empty body, or a redirect onto a private host or a fourth redirect — reason in message), unreadable_document — raised synchronously at submit when the content matches no known PDF/PNG/JPEG signature and the name ends .pdf, .png, .jpg or .jpeg (any other name is 415 unsupported_type), or asynchronously (as a failed document's error) when the content is one of those formats but the engine cannot open it. A file the engine cannot open because it is encrypted is file_encrypted (type file_error) rather than unreadable_document |
| 422 | invalid_request | message_would_disclose_flag (no Verifit link is minted; matched_terms returned), expected_total_below_submitted, attribution_currency_conflict (a second currency on one loan's attributions), idempotency_key_reuse (an Idempotency-Key presented again with a different payload) |
| 429 | rate_limited | honor Retry-After |
| 5xx | server_error | The API returns 500. A 502, 503 or 504 comes from the network edge and may not have a JSON body. Retry any of them with backoff and the same Idempotency-Key. |
Rate limits
Each key is a token bucket: 600 requests per minute sustained with a burst of 1,200 (per the commercial agreement; raised on request). A key that has been idle can send 1,200 requests at once, then 10 per second — the allowance refills continuously, so a caller holding under 10/s never sees a 429.
X-RateLimit-Limit reports the burst capacity, not the per-minute refill: it is the most the key can spend at one instant. X-RateLimit-Remaining is what is left in the bucket and X-RateLimit-Reset the seconds until it is full again (on a 429, the seconds until the next request will be accepted). Every response also carries X-CheckReality-Env (sandbox | production) and X-Response-Time-Ms, plus X-Request-Id, X-Correlation-Id and, usually, X-Trace-Id; quote X-Request-Id (and the doc_ id) when you contact us about a request. On 429, back off per Retry-After; submissions are safe to retry with the same Idempotency-Key.
Versioning & forward compatibility
The path version (/v1) changes only for breaking changes, with ≥ 6 months of parallel support. Within v1, changes are additive — spec 1.3 is the latest example (a new verdict value and new response fields). Additive means new paths, new optional request fields, new response fields and new enum members, with nothing removed or reshaped. Build your client to these three rules and new detectors will never break you:
- Tolerate unknown fields — new response fields may appear at any time.
- Treat enums as open — new
indicator.code,error.code, andmodelvalues will ship; render unknown indicators generically fromtitle+severity. - Contract vs. copy —
code,severity,verdict,risk_score, and coordinates are stable machine surface;what_it_is/what_it_means/how_detected/headlineare display copy and may be reworded without notice. Never parse them.
Sandbox & go-live
Sandbox keys score real documents with the real engines — the next release, before production gets it (§2026-10-26). There is no sample corpus and file names carry no meaning: a file called 25 (4).pdf is scored, not recognised, and renaming a document never changes its verdict. Send your own documents — that is what the sandbox is for.
Sandbox-only helper: POST /v1/verifit/links/{id}/simulate-capture produces a completed borrower capture end-to-end and answers 201, so you can integration-test verifit.capture.completed and GET /v1/verifit/captures/{id} without a phone. Where the deployment is backed by the live Verifit service it answers 409 sandbox_only ("This endpoint is only available in the sandbox environment.") and creates nothing. A link already used is 409 link_already_consumed; one past expires_at is 410 link_expired.
Current sandbox build limitations
Contract semantics unchanged — these will close before GA:
- Every document is queued and scored by a worker — sandbox and production alike. A native PDF of a few pages is usually
completedwithin seconds; a scanned or photographed page takes longer because the engine first reads its text with OCR.?wait=truebehaves as documented in both cases. POST /documents/{id}/reprocessre-scores from the file supplied with the request: attach the original document as multipartfile— no file answers400 source_file_required, a sha256 mismatch400 invalid_request— and one still queued or running answers409 analysis_in_progress— wait forcompletedorfailed, then reprocess.VerifitLink.qr_png_urlandc2pa.manifest_urlserve the sandbox fixture's QR and manifest — deterministic stand-ins, not a real capture.- Sandbox state is durable (Postgres + object storage, as in production): ids, verdicts and loans survive a redeploy.
Go-live checklist:
- Webhook endpoint verifies signatures and dedupes on event id; unknown event types are ignored rather than rejected.
external_refattached to every submission — without it, listing, loan rollups and audit reports cannot group documents. This is the single most important field.categorysent where known. Leavemodelsat its default: the file's content chooses the engine, not this option.- Unknown indicator codes render generically from
title+severity— never branch on a closed code list. - 429/5xx retries use
Idempotency-Key. - Score-routing thresholds signed off by your compliance team — routing is client-side policy (§Queue).
If you are adopting the 1.1 surface, two additions are worth wiring at the same time: call PUT /v1/loans/{external_ref}/expected-documents when the loan's document checklist is known, or the coverage claim stays unevidenced; and send attribution on submissions that substantiate an asset or income figure, or flagged_amount is never present.
Questions during implementation: dennisng@scam.ai · shared Slack channel to be set up at kickoff.
Code reference
The codes a Document can carry, listed here so you need not call anything to read them. Both lists are open: tolerate a code you do not know, and render an unknown indicator from its title and severity.
Reason codes (reason_codes)
UPPERCASE routing codes: what to ask for and why — never a finding. "Where" says which estate emits it.
| Code | What it says | Where |
|---|---|---|
REQUEST_NATIVE_PDF | Ask for the issuer's original file | both estates |
REQUEST_BANK_CONNECTION | Ask for a bank connection or the issuer's original file | both estates |
NO_PAGE_EXAMINED | No page could be examined | both estates |
NO_TEXT_LAYER | No text layer: a scan or an image | both estates |
RESOLUTION_TOO_LOW | The image resolution was too low to read the figures reliably | both estates |
PAGE_CAP | Only the first pages were examined; the rest were not checked | both estates |
EXAMINER_ERROR | Some checks could not finish on this file | both estates |
ENCRYPTED_NEEDS_PASSWORD | Password-protected; ask for the password or an unlocked copy | both estates |
UNSUPPORTED_SECURITY_HANDLER | Locked with a protection the service cannot open | both estates |
ISSUER_NOT_IN_REGISTRY | The named bank is not in the FDIC, NCUA or OCC registries or their name history | both estates |
ISSUER_LOOKUP_INCONCLUSIVE | The issuer could not be identified from the page | both estates |
OCR_LOW_CONFIDENCE | The scan could not be read clearly enough to check the arithmetic | both estates |
SINGLE_REVISION_REWRITE | The file was re-saved by another program, so its edit history cannot be checked | both estates |
RASTERISED_NATIVE_DIGITAL | Reserved; this service does not emit it | both estates |
TRUNCATED_UPLOAD | The upload ends early, so this copy is incomplete; ask for the file again | both estates |
OUTSIDE_LIMITS | Outside the service limits; refused before any check ran | both estates |
TEMPLATE_OR_SPECIMEN | Template or specimen cues (information, not evidence of editing) | both estates |
ANALYSIS_TIME_LIMIT | The checks took longer than the service's time limit and were stopped | both estates |
NOT_AN_ISSUED_DOCUMENT | Origin cues: may not be a document a bank or employer issued | both estates |
UNSUPPORTED_FILE_FORMAT | Not a PDF, PNG or JPEG; ask for the document in one of those formats | both estates |
REFDATA_UNAVAILABLE | The institution registry snapshot could not be read, so the issuer was not checked | sandbox since 2026-10-15; production on release |
REFDATA_STALE | The registry snapshot is older than this document, so a recent change may be missing | sandbox since 2026-10-15; production on release |
ISSUER_NAME_AMBIGUOUS | More than one institution has this name; checks that need the exact one abstain | sandbox since 2026-10-15; production on release |
ISSUER_NAME_NOT_IN_FORCE_ON_DATE | The institution did not carry this name on the document's date (renamed, merged or closed) | sandbox since 2026-10-15; production on release |
NOT_A_LENDING_DOCUMENT | Earlier versions only: not a bank statement, pay stub or W-2 (retired in 0.10.0) | both estates |
IMAGE_EDIT_REVIEW | The image model found a possible edit or AI generation; a reviewer should look at the image (model evidence never confirms forgery) | image-forensics answers: sandbox now; production on release |
IMAGE_ASSESSMENT_INCOMPLETE | Some image checks could not complete, so the image was not fully assessed | image-forensics answers: sandbox now; production on release |
IMAGE_COVERAGE_LIMITED | A digitally created document (a screenshot or an exported page) arrived as an image: a result without a finding is not evidence that it is genuine; ask for the original, usually a PDF | image-forensics answers: sandbox now; production on release |
Indicator codes (indicators[].code, checks_passed)
One public code per docforensics check, with the engine's identifier. Severity is what an indicator carries on a flagged document; beside a CLEAN verdict, and on a note saying what kind of document this is, it is low. Titles are the sandbox engine's — three read differently on production (§2026-10-15); render the title the response carries. checks_passed uses the same codes, plus image_assessment on a CLEAN image-forensics answer. Documents scored by the earlier engine (eva-doc) carry that engine's own codes, which are not listed here.
| Identifier | Code | Title | Severity | Where |
|---|---|---|---|---|
F001 | prior_revision_value_change | Earlier saved version | high | both estates |
F002 | redaction_or_cover_over_amount | Redaction or white box over an amount | high | both estates |
F003 | text_replaced_under_white_cover | White box over earlier text | high | both estates |
F004 | xref_style_changed_between_saves | Save history | medium | both estates |
F005 | amount_column_font_outlier | Fonts in an amount column | medium | both estates |
F006 | single_use_font_on_amount | Font used by one amount only | medium | both estates |
F007 | text_still_under_white_cover | Text under a white box | medium | both estates |
F008 | overlapping_amounts_one_line | Two numbers in one spot | medium | both estates |
F009 | file_dates_producer_mismatch | File dates and creating-software names | medium | both estates |
F010 | non_embedded_font_on_amount | Font-embedding outlier on a credit amount | high | both estates |
F011 | value_hidden_from_view | Hidden layer or form value | high | both estates |
F012 | editing_software_named_in_file | Editing software named in the file | medium | both estates |
F013 | author_field_names_person | Author field | medium | both estates |
F014 | timestamp_and_history_mismatch | File dates and edit history | medium | both estates |
F015 | create_date_outside_printed_period | File creation date | medium | both estates |
F016 | writer_style_producer_mismatch | Written by the named creating software | medium | both estates |
F017 | trailer_changed_between_saves | File identifiers between saves | medium | both estates |
F018 | xref_index_mismatch | File's internal index | medium | both estates |
F019 | object_syntax_outlier | Writing style across the file | medium | both estates |
F020 | object_rewritten_by_later_save | Pages rewritten by a later save | medium | both estates |
F021 | last_save_syntax_mismatch | Last save's writing style | medium | both estates |
F022 | page_compression_outlier | Page compression vs other pages | medium | both estates |
F023 | page_content_style_outlier | Page drawing style vs other pages | medium | both estates |
F024 | page_dictionary_outlier | Page build vs other pages | medium | both estates |
F025 | page_resource_name_mismatch | Page font and image names vs other pages | medium | both estates |
F026 | unshared_page_fonts | Fonts on one page vs the rest | medium | both estates |
F027 | stream_filter_chain_outlier | Encoding of one part vs the rest | medium | both estates |
F028 | embedded_font_integrity_or_style_outlier | Embedded font integrity and dates | medium | both estates |
F029 | conflicting_font_definitions | Duplicate font definitions | medium | both estates |
F030 | hidden_or_lookalike_characters | Look-alike or invisible characters | medium | both estates |
F031 | text_reader_mismatch | Copied text vs what the page shows | medium | both estates |
F032 | untagged_text_on_tagged_page | Accessibility tags | medium | both estates |
F033 | hidden_text_present | Hidden text in the file | medium | both estates |
F034 | annotation_over_page_text | Notes and stickers over page text | medium | both estates |
F035 | text_under_redaction_bar | Text under dark bars | medium | both estates |
F036 | stroke_over_amount_digit | Hand-drawn stroke over an amount | medium | both estates |
F037 | stored_form_value_mismatch | Form data differs from what the page shows | high | both estates |
F038 | text_outside_visible_area | Content outside the visible page area | medium | both estates |
F039 | page_application_residue | Acrobat in-place edit residue on a credit amount | medium | both estates |
F040 | stream_deflater_outlier | Content stream written by a different deflater | medium | both estates |
F041 | hidden_text_under_visible_amount | Hidden text differs from the visible amount | medium | both estates |
F042 | annotation_over_credit_amount | Annotation-borne amount over a credit cell | medium | both estates |
F043 | catalog_private_object | Private object attached to the document catalog | medium | both estates |
F044 | signature_byte_range_malformed | Impossible signature byte range | medium | both estates |
F045 | font_duplicate_digit_outline | Two digits drawn with one outline | medium | both estates |
F046 | annotation_print_screen_mismatch | Annotation prints differently than it shows | medium | both estates |
F047 | script_library_default_metadata | Statement generated by a script library | medium | both estates |
F048 | page_thumbnail_writer_mismatch | Page thumbnail written by another tool | medium | both estates |
F049 | xref_entry_offset_mismatch | Index entry points away from its object | medium | both estates |
F050 | reporting_period_date_mismatch | Period date that differs from the rest | medium | both estates |
F051 | modify_date_timezone_mismatch | Time zone of the last save | medium | both estates |
F052 | white_box_overlay_amount_stamp | Overlay stamp over an amount | medium | both estates |
F053 | amount_split_from_currency_sign | Amount cut from its dollar sign, or digits spaced unevenly | medium | both estates |
F055 | hidden_text_reads_as_instruction | Hidden text reads as an instruction | medium | sandbox since 2026-10-15; production on release |
F058 | amount_redrawn_over_deleted_text | Amount redrawn over deleted text | medium | sandbox since 2026-10-15; production on release |
F059 | text_typed_over_scanned_page | Text typed over a scanned page | medium | sandbox since 2026-10-15; production on release |
F060 | first_revision_empty_template | First saved version was an empty template | medium | sandbox since 2026-10-15; production on release |
F062 | amount_drawn_out_of_column_order | Amount drawn out of its column's order | medium | sandbox since 2026-10-15; production on release |
I001 | rendered_text_layer_mismatch | Rendered page vs text layer | medium | both estates |
I002 | image_container_origin | How the image file was made | medium | both estates |
I003 | embedded_image_origin | Embedded images | medium | both estates |
I004 | image_resave_markers | Image written out a second time | medium | both estates |
I005 | screen_capture_markers | Screen capture rather than a photo or scan | medium | both estates |
I006 | digit_glyph_page_mismatch | Digits drawn like the rest of the page | medium | both estates |
I007 | stroke_sharpness_outlier | Stroke sharpness of an amount vs its page | medium | both estates |
I009 | image_editor_named | Image editor named in the file | medium | sandbox since 2026-10-15; production on release |
I010 | image_edit_history | Image edit history in the file | medium | sandbox since 2026-10-15; production on release |
I011 | image_date_gap | Image taken and saved at different times | medium | sandbox since 2026-10-15; production on release |
I012 | ai_generation_marker | AI generation mark in the image file | medium | sandbox since 2026-10-15; production on release |
I013 | c2pa_manifest_present | Content credentials (C2PA) in the image file | medium | sandbox since 2026-10-15; production on release |
M001 | learned_model_score | Structural model score | medium | both estates |
N001 | statement_balance_arithmetic_mismatch | Statement arithmetic | high | both estates |
N002 | payroll_tax_and_net_arithmetic_mismatch | Payroll arithmetic | high | both estates |
N003 | pay_calendar_arithmetic_mismatch | Pay calendar | high | both estates |
N004 | running_balance_chain_mismatch | Running balance, row by row | high | both estates |
N005 | w2_box_arithmetic_range_mismatch | W-2 box arithmetic | medium | both estates |
N006 | printed_date_mismatch | Dates | medium | both estates |
N007 | amount_format_outlier | Amount formatting | medium | both estates |
N008 | cross_page_mismatch | Pages repeat or do not belong together | medium | both estates |
N009 | ocr_arithmetic_mismatch | OCR-confirmed arithmetic failure | high | both estates |
N010 | payroll_line_arithmetic_mismatch | Payroll line arithmetic fails | medium | both estates |
N011 | w2_box_arithmetic_mismatch | W-2 box arithmetic fails | medium | both estates |
N012 | statement_summary_mismatch | Statement totals or daily-balance ladder fail | medium | both estates |
N013 | recurring_amount_digit_jump | Digit-jump on recurring amounts | medium | both estates |
N015 | transaction_row_repeat_or_order | Transaction rows repeat or run out of order | medium | sandbox since 2026-10-15; production on release |
N016 | medicare_social_security_pair_mismatch | Medicare and Social Security disagree | medium | sandbox since 2026-10-15; production on release |
N017 | ytd_payroll_column_mismatch | Year-to-date payroll columns fail | medium | sandbox since 2026-10-15; production on release |
N018 | w2_withholding_exceeds_wages | W-2 withholding boxes exceed their wages | medium | sandbox since 2026-10-15; production on release |
N019 | pay_basis_regular_pay_mismatch | Pay basis does not give the regular pay | medium | sandbox since 2026-10-15; production on release |
N020 | deposit_distribution_net_pay_mismatch | Deposit distribution does not add up to net pay | medium | sandbox since 2026-10-15; production on release |
N021 | amount_in_words_vs_figure_mismatch | Amount in words against the figure | medium | sandbox since 2026-10-15; production on release |
R001 | issuer_not_in_registry | Issuer registry | high | both estates |
R002 | fiction_reserved_phone_number | Contact phone number | medium | both estates |
R003 | print_or_web_render_origin | Creating software origin | medium | both estates |
R004 | placeholder_or_invalid_contact_details | Placeholder or invalid contact details | medium | both estates |
R005 | word_processor_or_design_tool_writer | Office or creative-suite writer on an issuer document | medium | both estates |
R006 | producer_names_the_issuer | Creating software claims to be the issuer | medium | both estates |
R007 | sample_or_template_markers | Template or specimen cues | medium | both estates |
R008 | payroll_date_on_fed_closed_day | Federal Reserve calendar | medium | sandbox since 2026-10-15; production on release |
R009 | insurer_legend_contradicts_registry | Deposit-insurer legend | medium | sandbox since 2026-10-15; production on release |
S001 | capture_format_class | Document format (true PDF, scan, photo) | medium | both estates |
S002 | document_type_mismatch | Document type read from the content | medium | both estates |
S003 | structural_limits_exceeded | File size and shape outside normal limits | medium | both estates |
S004 | ocr_read_quality | OCR lane quality | medium | both estates |
S005 | printed_pages_missing | Printed page numbering against the pages in the file | medium | sandbox since 2026-10-15; production on release |
T001 | digital_signature_present | Digital signature present | medium | both estates |
T003 | signature_validity | Digital signature check | medium | both estates |
T004 | active_content_present | Scripts, actions or attachments inside the file | medium | both estates |
TRACKB | model_layer_coverage | Old-vs-new evidence checks | medium | both estates |
X001 | balance_carry_over_mismatch | Statement balances do not carry over | medium | both estates |
X002 | document_reused_across_applicants | The same document under another applicant | medium | both estates |
X003 | template_match_different_issuer | One layout, two issuer names | medium | both estates |
X004 | account_reused_across_applicants | Account or employer reused across applicants | medium | both estates |
X005 | issuer_build_mismatch | Issuer build baseline | medium | both estates |
X006 | minority_build_in_template_group | Odd one out in a template group | medium | both estates |
X007 | net_pay_deposit_not_found | Pay stub against the statement it was paid into | medium | both estates |
X013 | ytd_roll_forward_mismatch | Year-to-date does not roll forward between pay stubs | medium | sandbox since 2026-10-15; production on release |
X014 | w2_year_end_stub_mismatch | W-2 against the year-end pay stub | medium | sandbox since 2026-10-15; production on release |
X015 | submission_coverage_finding | Submission coverage findings | medium | sandbox since 2026-10-15; production on release |
X017 | members_share_base_file | Members built from one base file | medium | sandbox since 2026-10-15; production on release |
X018 | same_period_conflicting_amounts | One period, two different amounts | medium | sandbox since 2026-10-15; production on release |
| — | IMAGE_EDIT_REVIEW | Possible image edit or AI generation | medium | image-forensics answers: sandbox now; production on release |