Scam AI · CheckReality · v1 API · spec 1.3.0-draft

Document Forensics API Reference

Everything your team needs to submit borrower documents, receive explainable forgery verdicts, render them in the Fraud Review workflow, request a verified borrower re-capture, and export loan-level audit reports with recomputable evidence. Machine-readable spec: checkreality-openapi.yaml · openapi.json. Error codes: /errors/<code>. LLM-friendly: llms.txt · reference.md · llms-full.txt.

Draft for integration review — surface may change until countersigned

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.

submit analyze render POST /documents ──202──▶ processing ──────▶ document.completed webhook │ (or GET /documents/{id}) │ ?wait=true ◀── completed inline when fast enough ├─ same SHA-256 already analyzed ──200──▶ cached verdict, instant └─ same SHA-256, prior analysis failed ──202──▶ analyzed again, billed if it completes loan file done ──▶ POST /audit-reports ──▶ audit_report.completed ──▶ PDF + JSON evidence flagged doc ──▶ POST /verifit/links ──▶ borrower re-captures ──▶ verifit.capture.completed

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 Review state when document.completed arrives.
  • Long-poll: POST /documents?wait=true blocks 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.

AreaWhat changesYou need to act if…
Sandbox hostThe 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_versionOne 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 listsEvery 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.
OpenAPIThis 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 idsEvery 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.

AreaWhat changesYou need to act if…
model_versionReads 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_codesFour 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 codes121 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 cuesInformation, 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 unreadImage 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 photoWhen 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 sizeA 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 scanLonger. 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.
findingsCan 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_textUnchanged. The engine reordered its arithmetic evidence internally; the API keeps publishing each check's pair in the order it always has.Nothing.
regionsAn 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.

AreaWhat changesYou need to act if…
Fourth verdictINSUFFICIENT_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 codesDocuments 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 fieldsconfidence, 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.
modelReads 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.

CapabilitySurface
Cross-loan triage queueGET /v1/loans · POST /v1/loans/{external_ref}/review · in_review disposition — §Queue
Coverage attestationPUT /v1/loans/{external_ref}/expected-documents · LoanSummary.coverage — §Coverage
Flagged-amount attributionSubmitOptions.attribution · LoanSummary.flagged_amount — §Attribution
Verified borrower re-capture/v1/verifit/* with C2PA provenance and a server-side audit-safe validator — §Verifit
Recomputable audit evidenceformat: 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.

AreaBeforeNowYou need to act if…
POST /v1/documents resubmission of a document whose analysis ended failedAnswered 200 with the cached failure — the contract's promised cache-hit behaviour for a completed document, wrongly applied to a failed one tooNot 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_scoreA SUSPICIOUS or FORGED document with no score contributed 0 to the loan's risk, sinking it to the bottom of -risk_score triageContributes its verdict's band floor instead — SUSPICIOUS 40, FORGED 70; INSUFFICIENT_EVIDENCE (no score by design, no flag) counts as 0A 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_unscoredDid not existNew 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:

WasIsOld 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_idverifit_eligible, verifit_capture_id — both names are emitted with the same value2026-12-31
object: "verifi_link" / "verifi_capture""verifit_link" / "verifit_capture" — on every path, including the aliasChanged 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-issuedRetired 2026-09-09
Partner webhooks: Verifi-Signature, Verifi-Event-Id, Verifi-Event-TypeVerifit-Signature, Verifit-Event-Id, Verifit-Event-Type — every delivery carries both sets with identical values2026-12-31
C2PA assertion org.verifi.inspectionorg.verifit.inspection on images signed from now on; images signed earlier keep their label and stay validNever 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.

AreaBeforeNowYou need to act if…
POST /v1/documents with file_urlThe URL was never fetched; every URL submission ended 422 unreadable_documentFetched 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_largeYou fell back to multipart because URL submission "did not work" — it does now
POST /v1/documents/{id}/reprocesscreated_at moved to the reprocess timecreated_at never changes; completed_at movesYou sorted or de-duplicated on created_at after a reprocess
GET /v1/audit-reports/{id}/downloadAny or no sig served the file; a format: json report still served a PDFsig required (400) and checked (404); json reports have no file (404)You rebuilt the download URL yourself instead of using download_url
Audit PDF bytesEach download embedded its own creation time — two different filesByte-identical for the same completed report; safe to hash and storeNothing; you may now content-address it
Document.model, AuditReport.models[].nameInternal engine id pdf-native-forensics on some documentsAlways the published name eva-doc, matching the PDFYou matched on the internal id
GET /v1/verifit/links/{id}/qr.pngServed for revoked and expired links404 once the link is not pendingYou print QR codes after revoking
message.template_idAny string accepted and echoedMust be a preset from GET /v1/verifit/templates; otherwise 404You sent your own ids in template_id — move them to your own records
borrower in productionUndocumented: phone or email required for every deliveryDocumented (unchanged behaviour); the sandbox still accepts a mint without oneYou validated in the sandbox only
doc_url, /openapi.json404/errors/<code> is a page per code; /openapi.json serves the spec as JSONNothing

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.

AreaBeforeNowYou need to act if…
Idempotency-Key (POST /v1/documents, /v1/verifit/links, /v1/audit-reports)A reused key replayed the stored response whatever the payloadThe key is bound to the payload it first carried; a different payload is 422 idempotency_key_reuseYou 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/linksP0D / PT0M minted a link that had already expiredZero durations are 400 invalid_requestYou ever send a zero duration (nothing else changes)
Verifit link statusCould still read pending after expires_at had passedexpired once expires_at passes; the one-live-link-per-document rule uses the same viewYou branch on status — it now agrees with expires_at
POST /v1/loans/{external_ref}/reviewnote: null was ignorednote: null clears the note, as assignee: null unassignsYou send note: null meaning "no change" — omit the key instead
DELETE /v1/loans/{external_ref}/expected-documentsNo way to withdraw a declarationWithdraws it; coverage.expected_total, unscreened and complete become absent againOptional — use it to retract a declaration made in error
Malformed JSON bodyA non-standard 400 shape, answered even without a key400 malformed_json in the standard envelope; 401 invalid_api_key without a keyYour client parses the old shape
AuditReport.pagesEstimatedMeasured from the rendered PDF (values may be lower)Anything paginates on it
GET /v1/verifit/links/{id}/qr.pngNo cache policy on the responseCache-Control: private, no-storeYou 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

POST/v1/documents

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"]
      }'
OptionTypeNotes
external_refstringYour loan/file id. Groups documents; echoed on webhooks.
categoryenumbank_statement · paystub · tax_form · id_document · utility_bill · other. Optional hint — improves issuer-pipeline and cross-total checks.
modelsarrayeva-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_urluriPer-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.
attributionobject1.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_keystring1.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.

VerdictMeaningTypical scoreRecommended routing
CLEANNo tampering evidence found.0–39Auto-pass; retain verdict in the loan file.
SUSPICIOUSEvidence of alteration, but not conclusive (e.g. re-saved through a consumer PDF editor).40–69Human review — queue in Fraud Review; request re-capture or source document.
FORGEDConclusive tampering evidence (recoverable edit trail, arithmetic contradiction).70–100Escalate; 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 omittedFollow 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_heatmap are nullable/omittable — the current engine build does not produce them for every document. Null-check before rendering; LoanSummary.top_reason is null whenever headline is.
  • metadata is a fact panel whose value is a display string. A docforensics document carries up to four rows, each only when the engine reported it: Capture ("born digital", "photo"), Created with, Software kind ("office suite") and Software 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). anomalous is reserved — false on every row.
  • math_checks on a docforensics document 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 (then computed/stated are empty and note says why). Filter on ok === 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. formula is a plain-language summary; computed/stated are best-effort and may be empty.
  • findings can be present on a CLEAN document, with no lead_finding and no indicator for them: they did not decide the verdict. Only findings[lead_finding] decided one.
  • model reports the engine that scored the document — docforensics, image-forensics (PNG and JPEG on the sandbox; production on release — §Image answers) or eva-doc (the earlier engine). The names in options.models are what you ASK for; they do not choose the engine and are not what a scored document reports back. Open enum.
  • status is processing | completed | failed. Content that is not a PDF/PNG/JPEG is rejected at submit with a 4xx (415 unsupported_type, or 422 unreadable_document when the name ends .pdf, .png, .jpg or .jpeg); a file that passes that check but cannot be opened by the engine ends as failed, asynchronously, with an error object carrying the same catalogued code.
  • capture is a measurement, not a restatement of the media type: photo for an image upload, native_pdf for a PDF whose page objects the structural model read, scan for a PDF whose pages are themselves images. Omitted when the class was not determined — while the document is processing, on a failed document, and on the rare completed document classified neither way. Treat absence as "not determined", never as native_pdf. Changed 2026-09-25: until then every PDF, scans included, reported native_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 to SUSPICIOUS, never to FORGED: FORGED comes only from the docforensics checks. CLEAN needs every image check to complete with nothing found.
  • risk_score — present only when the docforensics checks themselves found the image SUSPICIOUS or FORGED. Omitted on CLEAN, on a SUSPICIOUS the image model raised alone, and on INSUFFICIENT_EVIDENCE. A loan rollup counts an unscored flag at its band floor and in documents_unscored.
  • confidence — only on FORGED.
  • review_tier — review on every SUSPICIOUS, including one the image model raised alone; it does not mean two independent checks agreed.
  • next_step — omitted on CLEAN; present on every other verdict.
  • indicators — the docforensics indicators, plus IMAGE_EDIT_REVIEW (title "Possible image edit or AI generation", severity medium, no identifier) when the image model found a possible edit. Model evidence asks for review; it never confirms forgery.
  • reason_codes — adds IMAGE_EDIT_REVIEW when the image model found a possible edit, IMAGE_ASSESSMENT_INCOMPLETE when the image checks could not all complete and found nothing, and IMAGE_COVERAGE_LIMITED when the image is a digitally created document (a screenshot, an exported page). When the image checks complete and the docforensics checks flagged nothing, REQUEST_NATIVE_PDF and NO_TEXT_LAYER are left out.
  • A digitally created document received as an image is never CLEAN. With nothing found it is INSUFFICIENT_EVIDENCE carrying IMAGE_COVERAGE_LIMITED, and next_step asks for the original, usually a PDF; a clean result on such an image is not evidence that it is genuine. With a finding it is SUSPICIOUS like any other image.
  • When the image checks could not all complete and nothing was found, the verdict is INSUFFICIENT_EVIDENCE with IMAGE_ASSESSMENT_INCOMPLETE.
  • checks_passed — on CLEAN, the docforensics checks that passed plus image_assessment.
  • capture is photo; model_version is the estate's badge (3.1 on the sandbox).
  • If the image model stays unavailable through every retry, the document ends failed with server_error, as for any scorer outage.

Retrieve & list

GET/v1/documents/{id}

Returns the Document in any status. Poll at ≥ 2 s intervals if not using webhooks.

GET/v1/documents?external_ref=loan_84620

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 elementSource
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 badgefile.name · issuer · issue_date · headline · model
"Why this verdict" explanation cardsindicators[] (sorted high-severity first)
Green "Verified clean" checklistchecks_passed[]
Tamper overlay boxes + "94% · high" badgesregions[] (percent coords, confidence, severity)
"Incremental revisions: 2" + before/after linerevisions
Cross-total verification rows (✓/✕, Δ note)math_checks[]
Document metadata table with red "anomalous" tagsmetadata[] (anomalous: true)
File integrity — SHA-256 / md5file.sha256 · file.md5
Re-process buttonPOST /v1/documents/{id}/reprocess
Fraud Review queue row — loan risk, "3 flagged / 36", top reasonGET /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 badgesmodel_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 chipGET /v1/loans — review_state · assignee
"Request re-capture" actionDocument.verifit_eligible → POST /v1/verifit/links
Re-capture result panel — provenance, device, reconciliationVerifitCapture.signal_groups[] + c2pa + contradicts_original

Review, reprocess & loan rollups

POST/v1/documents/{id}/review

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.

POST/v1/documents/{id}/reprocess

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.

GET/v1/loans/{external_ref}

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.

GET/v1/loans?has_unresolved=true&sort=-risk_score

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.

POST/v1/loans/{external_ref}/review
{ "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.

PUT/v1/loans/{external_ref}/expected-documents
{ "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.

POST/v1/verifit/links
{ "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.

GET/v1/verifit/links/{id}/qr.png

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.

GET/v1/verifit/captures/{id}
{ "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 }
GET/v1/verifit/captures/{id}/c2pa

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.

EventFires whendata
document.completedA document finishes analysis, any verdictDocument
document.failedAccepted but could not be scored: unreadable, encrypted, over the page or pixel limit, or scoring stayed unavailable through every retryDocument
audit_report.completedReport finished generatingAuditReport
audit_report.failedReport generation failedAuditReport
verifit.capture.completed1.1 Borrower completed a re-capture and it has been analyzedVerifitCapture
verifit.capture.failed1.1 Capture could not be analyzedVerifitCapture
verifit.link.expired1.1 Link passed expires_at unusedVerifitLink
loan.coverage_complete1.1 screened reached the declared expected_totalLoanSummary
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

POST/v1/audit-reports

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; coverage states it against the declared set.
  • manifest_digest folds 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:

take each document's lowercase-hex sha256 ──▶ sort the list lexicographically ──▶ join with "\n" (no trailing newline) ──▶ UTF-8 encode ──▶ SHA-256 ──▶ lowercase hex

It is independent of generation time: regenerating a report over the same file set with the same model versions reproduces the same digest.

GET/v1/audit-reports/{id}
GET/v1/audit-reports/{id}/download

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.

HTTPTypeTypical codes
400invalid_requestmissing_file, source_file_required (a reprocess without the original document), invalid_option, malformed_json, invalid_request
401authenticationinvalid_api_key (bad/malformed/wrong-environment key), browser_origin_forbidden
404not_foundunknown id (sandbox/production ids are disjoint), or an unknown route
405invalid_requestmethod_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
409conflictnot_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)
410conflictlink_expired — mint a new link
413invalid_requestrequest_too_large — the JSON request body itself (not a file) is over 1 MB
413 / 415file_errorfile_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)
422processing_errorurl_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
422invalid_requestmessage_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)
429rate_limitedhonor Retry-After
5xxserver_errorThe 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:

  1. Tolerate unknown fields — new response fields may appear at any time.
  2. Treat enums as open — new indicator.code, error.code, and model values will ship; render unknown indicators generically from title + severity.
  3. Contract vs. copy — code, severity, verdict, risk_score, and coordinates are stable machine surface; what_it_is / what_it_means / how_detected / headline are 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 completed within seconds; a scanned or photographed page takes longer because the engine first reads its text with OCR. ?wait=true behaves as documented in both cases.
  • POST /documents/{id}/reprocess re-scores from the file supplied with the request: attach the original document as multipart file — no file answers 400 source_file_required, a sha256 mismatch 400 invalid_request — and one still queued or running answers 409 analysis_in_progress — wait for completed or failed, then reprocess.
  • VerifitLink.qr_png_url and c2pa.manifest_url serve 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_ref attached to every submission — without it, listing, loan rollups and audit reports cannot group documents. This is the single most important field.
  • category sent where known. Leave models at 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.

CodeWhat it saysWhere
REQUEST_NATIVE_PDFAsk for the issuer's original fileboth estates
REQUEST_BANK_CONNECTIONAsk for a bank connection or the issuer's original fileboth estates
NO_PAGE_EXAMINEDNo page could be examinedboth estates
NO_TEXT_LAYERNo text layer: a scan or an imageboth estates
RESOLUTION_TOO_LOWThe image resolution was too low to read the figures reliablyboth estates
PAGE_CAPOnly the first pages were examined; the rest were not checkedboth estates
EXAMINER_ERRORSome checks could not finish on this fileboth estates
ENCRYPTED_NEEDS_PASSWORDPassword-protected; ask for the password or an unlocked copyboth estates
UNSUPPORTED_SECURITY_HANDLERLocked with a protection the service cannot openboth estates
ISSUER_NOT_IN_REGISTRYThe named bank is not in the FDIC, NCUA or OCC registries or their name historyboth estates
ISSUER_LOOKUP_INCONCLUSIVEThe issuer could not be identified from the pageboth estates
OCR_LOW_CONFIDENCEThe scan could not be read clearly enough to check the arithmeticboth estates
SINGLE_REVISION_REWRITEThe file was re-saved by another program, so its edit history cannot be checkedboth estates
RASTERISED_NATIVE_DIGITALReserved; this service does not emit itboth estates
TRUNCATED_UPLOADThe upload ends early, so this copy is incomplete; ask for the file againboth estates
OUTSIDE_LIMITSOutside the service limits; refused before any check ranboth estates
TEMPLATE_OR_SPECIMENTemplate or specimen cues (information, not evidence of editing)both estates
ANALYSIS_TIME_LIMITThe checks took longer than the service's time limit and were stoppedboth estates
NOT_AN_ISSUED_DOCUMENTOrigin cues: may not be a document a bank or employer issuedboth estates
UNSUPPORTED_FILE_FORMATNot a PDF, PNG or JPEG; ask for the document in one of those formatsboth estates
REFDATA_UNAVAILABLEThe institution registry snapshot could not be read, so the issuer was not checkedsandbox since 2026-10-15; production on release
REFDATA_STALEThe registry snapshot is older than this document, so a recent change may be missingsandbox since 2026-10-15; production on release
ISSUER_NAME_AMBIGUOUSMore than one institution has this name; checks that need the exact one abstainsandbox since 2026-10-15; production on release
ISSUER_NAME_NOT_IN_FORCE_ON_DATEThe 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_DOCUMENTEarlier versions only: not a bank statement, pay stub or W-2 (retired in 0.10.0)both estates
IMAGE_EDIT_REVIEWThe 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_INCOMPLETESome image checks could not complete, so the image was not fully assessedimage-forensics answers: sandbox now; production on release
IMAGE_COVERAGE_LIMITEDA 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 PDFimage-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.

IdentifierCodeTitleSeverityWhere
F001prior_revision_value_changeEarlier saved versionhighboth estates
F002redaction_or_cover_over_amountRedaction or white box over an amounthighboth estates
F003text_replaced_under_white_coverWhite box over earlier texthighboth estates
F004xref_style_changed_between_savesSave historymediumboth estates
F005amount_column_font_outlierFonts in an amount columnmediumboth estates
F006single_use_font_on_amountFont used by one amount onlymediumboth estates
F007text_still_under_white_coverText under a white boxmediumboth estates
F008overlapping_amounts_one_lineTwo numbers in one spotmediumboth estates
F009file_dates_producer_mismatchFile dates and creating-software namesmediumboth estates
F010non_embedded_font_on_amountFont-embedding outlier on a credit amounthighboth estates
F011value_hidden_from_viewHidden layer or form valuehighboth estates
F012editing_software_named_in_fileEditing software named in the filemediumboth estates
F013author_field_names_personAuthor fieldmediumboth estates
F014timestamp_and_history_mismatchFile dates and edit historymediumboth estates
F015create_date_outside_printed_periodFile creation datemediumboth estates
F016writer_style_producer_mismatchWritten by the named creating softwaremediumboth estates
F017trailer_changed_between_savesFile identifiers between savesmediumboth estates
F018xref_index_mismatchFile's internal indexmediumboth estates
F019object_syntax_outlierWriting style across the filemediumboth estates
F020object_rewritten_by_later_savePages rewritten by a later savemediumboth estates
F021last_save_syntax_mismatchLast save's writing stylemediumboth estates
F022page_compression_outlierPage compression vs other pagesmediumboth estates
F023page_content_style_outlierPage drawing style vs other pagesmediumboth estates
F024page_dictionary_outlierPage build vs other pagesmediumboth estates
F025page_resource_name_mismatchPage font and image names vs other pagesmediumboth estates
F026unshared_page_fontsFonts on one page vs the restmediumboth estates
F027stream_filter_chain_outlierEncoding of one part vs the restmediumboth estates
F028embedded_font_integrity_or_style_outlierEmbedded font integrity and datesmediumboth estates
F029conflicting_font_definitionsDuplicate font definitionsmediumboth estates
F030hidden_or_lookalike_charactersLook-alike or invisible charactersmediumboth estates
F031text_reader_mismatchCopied text vs what the page showsmediumboth estates
F032untagged_text_on_tagged_pageAccessibility tagsmediumboth estates
F033hidden_text_presentHidden text in the filemediumboth estates
F034annotation_over_page_textNotes and stickers over page textmediumboth estates
F035text_under_redaction_barText under dark barsmediumboth estates
F036stroke_over_amount_digitHand-drawn stroke over an amountmediumboth estates
F037stored_form_value_mismatchForm data differs from what the page showshighboth estates
F038text_outside_visible_areaContent outside the visible page areamediumboth estates
F039page_application_residueAcrobat in-place edit residue on a credit amountmediumboth estates
F040stream_deflater_outlierContent stream written by a different deflatermediumboth estates
F041hidden_text_under_visible_amountHidden text differs from the visible amountmediumboth estates
F042annotation_over_credit_amountAnnotation-borne amount over a credit cellmediumboth estates
F043catalog_private_objectPrivate object attached to the document catalogmediumboth estates
F044signature_byte_range_malformedImpossible signature byte rangemediumboth estates
F045font_duplicate_digit_outlineTwo digits drawn with one outlinemediumboth estates
F046annotation_print_screen_mismatchAnnotation prints differently than it showsmediumboth estates
F047script_library_default_metadataStatement generated by a script librarymediumboth estates
F048page_thumbnail_writer_mismatchPage thumbnail written by another toolmediumboth estates
F049xref_entry_offset_mismatchIndex entry points away from its objectmediumboth estates
F050reporting_period_date_mismatchPeriod date that differs from the restmediumboth estates
F051modify_date_timezone_mismatchTime zone of the last savemediumboth estates
F052white_box_overlay_amount_stampOverlay stamp over an amountmediumboth estates
F053amount_split_from_currency_signAmount cut from its dollar sign, or digits spaced unevenlymediumboth estates
F055hidden_text_reads_as_instructionHidden text reads as an instructionmediumsandbox since 2026-10-15; production on release
F058amount_redrawn_over_deleted_textAmount redrawn over deleted textmediumsandbox since 2026-10-15; production on release
F059text_typed_over_scanned_pageText typed over a scanned pagemediumsandbox since 2026-10-15; production on release
F060first_revision_empty_templateFirst saved version was an empty templatemediumsandbox since 2026-10-15; production on release
F062amount_drawn_out_of_column_orderAmount drawn out of its column's ordermediumsandbox since 2026-10-15; production on release
I001rendered_text_layer_mismatchRendered page vs text layermediumboth estates
I002image_container_originHow the image file was mademediumboth estates
I003embedded_image_originEmbedded imagesmediumboth estates
I004image_resave_markersImage written out a second timemediumboth estates
I005screen_capture_markersScreen capture rather than a photo or scanmediumboth estates
I006digit_glyph_page_mismatchDigits drawn like the rest of the pagemediumboth estates
I007stroke_sharpness_outlierStroke sharpness of an amount vs its pagemediumboth estates
I009image_editor_namedImage editor named in the filemediumsandbox since 2026-10-15; production on release
I010image_edit_historyImage edit history in the filemediumsandbox since 2026-10-15; production on release
I011image_date_gapImage taken and saved at different timesmediumsandbox since 2026-10-15; production on release
I012ai_generation_markerAI generation mark in the image filemediumsandbox since 2026-10-15; production on release
I013c2pa_manifest_presentContent credentials (C2PA) in the image filemediumsandbox since 2026-10-15; production on release
M001learned_model_scoreStructural model scoremediumboth estates
N001statement_balance_arithmetic_mismatchStatement arithmetichighboth estates
N002payroll_tax_and_net_arithmetic_mismatchPayroll arithmetichighboth estates
N003pay_calendar_arithmetic_mismatchPay calendarhighboth estates
N004running_balance_chain_mismatchRunning balance, row by rowhighboth estates
N005w2_box_arithmetic_range_mismatchW-2 box arithmeticmediumboth estates
N006printed_date_mismatchDatesmediumboth estates
N007amount_format_outlierAmount formattingmediumboth estates
N008cross_page_mismatchPages repeat or do not belong togethermediumboth estates
N009ocr_arithmetic_mismatchOCR-confirmed arithmetic failurehighboth estates
N010payroll_line_arithmetic_mismatchPayroll line arithmetic failsmediumboth estates
N011w2_box_arithmetic_mismatchW-2 box arithmetic failsmediumboth estates
N012statement_summary_mismatchStatement totals or daily-balance ladder failmediumboth estates
N013recurring_amount_digit_jumpDigit-jump on recurring amountsmediumboth estates
N015transaction_row_repeat_or_orderTransaction rows repeat or run out of ordermediumsandbox since 2026-10-15; production on release
N016medicare_social_security_pair_mismatchMedicare and Social Security disagreemediumsandbox since 2026-10-15; production on release
N017ytd_payroll_column_mismatchYear-to-date payroll columns failmediumsandbox since 2026-10-15; production on release
N018w2_withholding_exceeds_wagesW-2 withholding boxes exceed their wagesmediumsandbox since 2026-10-15; production on release
N019pay_basis_regular_pay_mismatchPay basis does not give the regular paymediumsandbox since 2026-10-15; production on release
N020deposit_distribution_net_pay_mismatchDeposit distribution does not add up to net paymediumsandbox since 2026-10-15; production on release
N021amount_in_words_vs_figure_mismatchAmount in words against the figuremediumsandbox since 2026-10-15; production on release
R001issuer_not_in_registryIssuer registryhighboth estates
R002fiction_reserved_phone_numberContact phone numbermediumboth estates
R003print_or_web_render_originCreating software originmediumboth estates
R004placeholder_or_invalid_contact_detailsPlaceholder or invalid contact detailsmediumboth estates
R005word_processor_or_design_tool_writerOffice or creative-suite writer on an issuer documentmediumboth estates
R006producer_names_the_issuerCreating software claims to be the issuermediumboth estates
R007sample_or_template_markersTemplate or specimen cuesmediumboth estates
R008payroll_date_on_fed_closed_dayFederal Reserve calendarmediumsandbox since 2026-10-15; production on release
R009insurer_legend_contradicts_registryDeposit-insurer legendmediumsandbox since 2026-10-15; production on release
S001capture_format_classDocument format (true PDF, scan, photo)mediumboth estates
S002document_type_mismatchDocument type read from the contentmediumboth estates
S003structural_limits_exceededFile size and shape outside normal limitsmediumboth estates
S004ocr_read_qualityOCR lane qualitymediumboth estates
S005printed_pages_missingPrinted page numbering against the pages in the filemediumsandbox since 2026-10-15; production on release
T001digital_signature_presentDigital signature presentmediumboth estates
T003signature_validityDigital signature checkmediumboth estates
T004active_content_presentScripts, actions or attachments inside the filemediumboth estates
TRACKBmodel_layer_coverageOld-vs-new evidence checksmediumboth estates
X001balance_carry_over_mismatchStatement balances do not carry overmediumboth estates
X002document_reused_across_applicantsThe same document under another applicantmediumboth estates
X003template_match_different_issuerOne layout, two issuer namesmediumboth estates
X004account_reused_across_applicantsAccount or employer reused across applicantsmediumboth estates
X005issuer_build_mismatchIssuer build baselinemediumboth estates
X006minority_build_in_template_groupOdd one out in a template groupmediumboth estates
X007net_pay_deposit_not_foundPay stub against the statement it was paid intomediumboth estates
X013ytd_roll_forward_mismatchYear-to-date does not roll forward between pay stubsmediumsandbox since 2026-10-15; production on release
X014w2_year_end_stub_mismatchW-2 against the year-end pay stubmediumsandbox since 2026-10-15; production on release
X015submission_coverage_findingSubmission coverage findingsmediumsandbox since 2026-10-15; production on release
X017members_share_base_fileMembers built from one base filemediumsandbox since 2026-10-15; production on release
X018same_period_conflicting_amountsOne period, two different amountsmediumsandbox since 2026-10-15; production on release
—IMAGE_EDIT_REVIEWPossible image edit or AI generationmediumimage-forensics answers: sandbox now; production on release