ScamAI · CheckReality · v1 API · spec 1.1.1-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-dev.scam.ai/v1 with an sk_test_ key — same API, same engine, unmetered. 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 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.

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

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.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 (document forensics, default) · docforge (GenAI-image detection for photos/scans).
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. 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, over size or page 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. 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 cached verdict at no charge. 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.

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 ("photo upload" label)
  "model": "eva-doc",
  "model_version": "2.6",             // the UI's V2.6 badge; changes on reprocess
  "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 of up to 10 fixed rows (Format, Pages, File size, Producer, Creator, Created, Modified, Incremental revisions, Fonts embedded, XMP metadata) whose value is a display string ("1.4 MB", "All 7 embedded"). anomalous is reserved — currently false on every row.
  • math_checks is produced only for SUSPICIOUS/FORGED documents and only failing checks are emitted — an empty array means no contradiction was reported, not that totals were verified. formula is a plain-language summary; computed/stated are best-effort and may be empty.
  • model reports the engine that scored the document — pdf-native-forensics or docforge-v14. The product names eva-doc / docforge are what you ASK for in options.models; they 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); 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 currently assigned from the file type — PDFs are native_pdf, images are photo; scan is reserved.

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

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.

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 V2.6" / "Real_pdf + DocForge" badgesmodel_version (per doc) · 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). Returns 202, status back to processing; the prior verdict stays readable until the new one lands. 409 file_not_retained if the bytes are not held. Resubmit instead.

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, 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 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 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, 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 or page count 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, 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. 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.1 is the current example: 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 same forensics engine production uses. 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 it is scored on a GPU. ?wait=true behaves as documented in both cases.
  • POST /documents/{id}/reprocess re-scores the document; one whose bytes are not held answers 409 file_not_retained, 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, and models: ["docforge"] for photos and scans — eva-doc is the default and is the wrong model for a phone photo of a statement.
  • 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.