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.
Absent fields are omitted, never null. A field that does not apply to a response is left out of the JSON entirely, recursively. Test for the key rather than branching on null, and read a missing key as "not applicable". The one place a null carries meaning is a request: on POST /v1/loans/{external_ref}/review, "assignee": null unassigns the loan and "note": null clears the note, where omitting either key leaves the current value alone.
Two integration shapes work well inside a LOS pipeline:
- Webhook-driven (recommended): submit on document sync, update your
Fraud Reviewstate whendocument.completedarrives. - Long-poll:
POST /documents?wait=trueblocks up to 30 s and usually returns the finished verdict inline — simplest for low volume.
New in spec 1.1
All additions below are additive — no field removed, no enum member dropped, no existing response shape changed. A client built against 1.0 keeps working unmodified.
| Capability | Surface |
|---|---|
| Cross-loan triage queue | GET /v1/loans · POST /v1/loans/{external_ref}/review · in_review disposition — §Queue |
| Coverage attestation | PUT /v1/loans/{external_ref}/expected-documents · LoanSummary.coverage — §Coverage |
| Flagged-amount attribution | SubmitOptions.attribution · LoanSummary.flagged_amount — §Attribution |
| Verified borrower re-capture | /v1/verifit/* with C2PA provenance and a server-side audit-safe validator — §Verifit |
| Recomputable audit evidence | format: json · findings[] · manifest[] · manifest_digest — §Audit |
Renamed: Verifi is now Verifit (2026-09-09)
The borrower re-capture product changed its name. Nothing about how it works changed. What changes for you, and when the old names stop working:
| Was | Is | Old name works until |
|---|---|---|
/v1/verifi/* | /v1/verifit/* — the old prefix is an authenticated alias answering with Deprecation: true and Link: </v1/verifit>; rel="successor-version" | 2026-12-31 |
Document.verifi_eligible, Document.verifi_capture_id | verifit_eligible, verifit_capture_id — both names are emitted with the same value | 2026-12-31 |
object: "verifi_link" / "verifi_capture" | "verifit_link" / "verifit_capture" — on every path, including the alias | Changed now |
https://verifi.scam.ai/claim/… | https://verifit.scam.ai/claim/… — the old hostname is retired; a link minted before the rename must be re-issued | Retired 2026-09-09 |
Partner webhooks: Verifi-Signature, Verifi-Event-Id, Verifi-Event-Type | Verifit-Signature, Verifit-Event-Id, Verifit-Event-Type — every delivery carries both sets with identical values | 2026-12-31 |
C2PA assertion org.verifi.inspection | org.verifit.inspection on images signed from now on; images signed earlier keep their label and stay valid | Never removed from signed images |
Changes on 2026-09-09 (post-rename fixes)
Found by the production e2e run after the rename. Two are corrections to behaviour you may have coded around; the rest tighten guarantees.
| Area | Before | Now | You need to act if… |
|---|---|---|---|
POST /v1/documents with file_url | The URL was never fetched; every URL submission ended 422 unreadable_document | Fetched once at submission (60 s, no cookies, at most three redirects, every hop re-checked). Failures are 422 url_fetch_failed with the reason; a body over 200 MiB is 413 file_too_large | You fell back to multipart because URL submission "did not work" — it does now |
POST /v1/documents/{id}/reprocess | created_at moved to the reprocess time | created_at never changes; completed_at moves | You sorted or de-duplicated on created_at after a reprocess |
GET /v1/audit-reports/{id}/download | Any or no sig served the file; a format: json report still served a PDF | sig required (400) and checked (404); json reports have no file (404) | You rebuilt the download URL yourself instead of using download_url |
| Audit PDF bytes | Each download embedded its own creation time — two different files | Byte-identical for the same completed report; safe to hash and store | Nothing; you may now content-address it |
Document.model, AuditReport.models[].name | Internal engine id pdf-native-forensics on some documents | Always the published name eva-doc, matching the PDF | You matched on the internal id |
GET /v1/verifit/links/{id}/qr.png | Served for revoked and expired links | 404 once the link is not pending | You print QR codes after revoking |
message.template_id | Any string accepted and echoed | Must be a preset from GET /v1/verifit/templates; otherwise 404 | You sent your own ids in template_id — move them to your own records |
borrower in production | Undocumented: phone or email required for every delivery | Documented (unchanged behaviour); the sandbox still accepts a mint without one | You validated in the sandbox only |
doc_url, /openapi.json | 404 | /errors/<code> is a page per code; /openapi.json serves the spec as JSON | Nothing |
Changes on 2026-09-08
Behaviour changes shipping with the next production release of api.scam.ai. No field or endpoint is removed; one endpoint is added; two responses change for requests that were previously accepted, or answered incorrectly.
| Area | Before | Now | You need to act if… |
|---|---|---|---|
Idempotency-Key (POST /v1/documents, /v1/verifit/links, /v1/audit-reports) | A reused key replayed the stored response whatever the payload | The key is bound to the payload it first carried; a different payload is 422 idempotency_key_reuse | You derive keys from something coarser than the request — a retry loop that mutates the body, or a per-loan key |
expires_in on POST /v1/verifit/links | P0D / PT0M minted a link that had already expired | Zero durations are 400 invalid_request | You ever send a zero duration (nothing else changes) |
Verifit link status | Could still read pending after expires_at had passed | expired once expires_at passes; the one-live-link-per-document rule uses the same view | You branch on status — it now agrees with expires_at |
POST /v1/loans/{external_ref}/review | note: null was ignored | note: null clears the note, as assignee: null unassigns | You send note: null meaning "no change" — omit the key instead |
DELETE /v1/loans/{external_ref}/expected-documents | No way to withdraw a declaration | Withdraws it; coverage.expected_total, unscreened and complete become absent again | Optional — use it to retract a declaration made in error |
| Malformed JSON body | A non-standard 400 shape, answered even without a key | 400 malformed_json in the standard envelope; 401 invalid_api_key without a key | Your client parses the old shape |
AuditReport.pages | Estimated | Measured from the rendered PDF (values may be lower) | Anything paginates on it |
GET /v1/verifit/links/{id}/qr.png | No cache policy on the response | Cache-Control: private, no-store | You cache the PNG in a shared cache or CDN — honour the header |
Authentication
API keys are accepted in either header — Authorization: Bearer sk_live_… or X-API-Key: sk_live_…. The two are equivalent and both work on every endpoint; use whichever suits your client. sk_test_… for sandbox, sk_live_… for production.
Headers only: the key is not a body field and not a query parameter, since GET endpoints have no body and a key in the query string ends up in access logs, browser history and referrer headers. Keys are server-side only — a request carrying a browser Origin header is rejected 401 (browser_origin_forbidden). Rotate keys in the dashboard; two keys can be live simultaneously for zero-downtime rotation.
When both headers are present, X-API-Key takes precedence. Keys must be shaped sk_live_… / sk_test_… — a malformed key, an unknown key, or a live key presented to the sandbox host all return 401 invalid_api_key with a message naming the specific problem. Rate limits apply per key.
Submit a document
Upload the file directly, or pass a pre-signed URL so bytes move straight from your document store. Attach external_ref (your loan id) to every submission — it powers listing and audit reports.
curl https://api.scam.ai/v1/documents?wait=true \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: req-84620" \
-F "file=@25 (4).pdf" \
-F 'options={
"external_ref": "loan_84620",
"category": "bank_statement",
"models": ["eva-doc"]
}'
| Option | Type | Notes |
|---|---|---|
external_ref | string | Your loan/file id. Groups documents; echoed on webhooks. |
category | enum | bank_statement · paystub · tax_form · id_document · utility_bill · other. Optional hint — improves issuer-pipeline and cross-total checks. |
models | array | eva-doc (document forensics, default) · docforge (GenAI-image detection for photos/scans). |
webhook_url | uri | Per-request override of the account webhook. Must be an absolute, public http(s) URL — a malformed URL or one pointed at a private/internal host (localhost, an RFC1918 address, the cloud metadata address, .internal) is rejected 400 invalid_option. An empty string means "use the account default", exactly as omitting the field does. |
attribution | object | 1.1 What figure this document substantiates — {section, field, currency, amount_minor}. section is required; currency defaults to USD and amount_minor to 0. Echoed on the Document and summed into LoanSummary.flagged_amount. See §Attribution. |
expected_key | string | 1.1 Links the submission to a declared expected-document item so coverage.missing resolves. See §Coverage. |
Limits: PDF/PNG/JPG · 200 MiB (209,715,200 bytes) · 200 pages. 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.
| Verdict | Meaning | Typical score | Recommended routing |
|---|---|---|---|
CLEAN | No tampering evidence found. | 0–39 | Auto-pass; retain verdict in the loan file. |
SUSPICIOUS | Evidence of alteration, but not conclusive (e.g. re-saved through a consumer PDF editor). | 40–69 | Human review — queue in Fraud Review; request re-capture or source document. |
FORGED | Conclusive tampering evidence (recoverable edit trail, arithmetic contradiction). | 70–100 | Escalate; do not rely on the document. Not by itself grounds for adverse action. |
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_heatmapare nullable/omittable — the current engine build does not produce them for every document. Null-check before rendering;LoanSummary.top_reasonis null wheneverheadlineis.metadatais a fact panel of up to 10 fixed rows (Format,Pages,File size,Producer,Creator,Created,Modified,Incremental revisions,Fonts embedded,XMP metadata) whosevalueis a display string ("1.4 MB", "All 7 embedded").anomalousis reserved — currentlyfalseon every row.math_checksis 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.formulais a plain-language summary;computed/statedare best-effort and may be empty.modelreports the engine that scored the document —pdf-native-forensicsordocforge-v14. The product nameseva-doc/docforgeare what you ASK for inoptions.models; they are not what a scored document reports back. Open enum.statusisprocessing|completed|failed. Content that is not a PDF/PNG/JPEG is rejected at submit with a4xx(415 unsupported_typeor422 unreadable_document); a file that passes that check but cannot be opened by the engine ends asfailed, asynchronously, with anerrorobject carrying the same catalogued code.captureis currently assigned from the file type — PDFs arenative_pdf, images arephoto;scanis 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
Returns the Document in any status. Poll at ≥ 2 s intervals if not using webhooks.
Cursor-paginated (limit 1–100, default 25; starting_after), newest first. Filter by external_ref or verdict — this is the query behind a "View flagged docs" screen. Returns slim DocumentSummary rows by default (view=summary) — id, name, verdict, risk_score, issuer, issue_date, capture, headline, review disposition — i.e. exactly one list row per object. view=full returns complete Documents.
UI → field mapping
Every element in the Fraud Review UI maps to one contract field — nothing in the interface requires client-side inference:
| UI element | Source |
|---|---|
| Verdict chip — "Forged · risk 87 / Clean · risk 3" | verdict + risk_score (summary row) |
| List secondary line — "Commerce Bank", "SAP Payroll import", "photo upload" | issuer + capture |
| Issue-date column — "11/17/2025" | issue_date (OCR-extracted) |
| Modal header — file · issuer · date · headline · model badge | file.name · issuer · issue_date · headline · model |
| "Why this verdict" explanation cards | indicators[] (sorted high-severity first) |
| Green "Verified clean" checklist | checks_passed[] |
| Tamper overlay boxes + "94% · high" badges | regions[] (percent coords, confidence, severity) |
| "Incremental revisions: 2" + before/after line | revisions |
| Cross-total verification rows (✓/✕, Δ note) | math_checks[] |
| Document metadata table with red "anomalous" tags | metadata[] (anomalous: true) |
| File integrity — SHA-256 / md5 | file.sha256 · file.md5 |
| Re-process button | POST /v1/documents/{id}/reprocess |
| Fraud Review queue row — loan risk, "3 flagged / 36", top reason | GET /v1/loans/{external_ref} |
| Loan banner — "Risk 47/100 · 2 forged · 1 suspicious of 36 docs" | LoanSummary.risk_score + verdict_counts |
| "41 min manual review saved on this loan" | LoanSummary.estimated_review_minutes_saved (heuristic, display-only) |
| "CheckReality V2.6" / "Real_pdf + DocForge" badges | model_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 chip | GET /v1/loans — review_state · assignee |
| "Request re-capture" action | Document.verifit_eligible → POST /v1/verifit/links |
| Re-capture result panel — provenance, device, reconciliation | VerifitCapture.signal_groups[] + c2pa + contradicts_original |
Review, reprocess & loan rollups
Records the human disposition from the review queue: {"disposition": "in_review" | "cleared" | "escalated" | "confirmed_fraud", "note", "reviewer"}. The disposition is echoed on the Document, clears the doc from the loan's flagged count when cleared, appears in later Fraud Audit Reports, and — de-identified — feeds scoring calibration. Idempotent per document; a later call replaces the earlier one.
in_review is new in 1.1 — a "someone is looking at it" state, so you need no parallel store keyed on our document ids. The three existing values are unchanged.
Re-runs analysis with the current model (the UI's "Re-process" action). 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.
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.
Returns LoanSummary rows for every external_ref with at least one completed document. Filters: has_unresolved (at least one flagged document without a cleared disposition), min_risk_score, review_state, assignee. Sort by -risk_score (default), risk_score, ±updated_at. Cursor-paginated with limit and starting_after.
{ "review_state": "in_review",
"assignee": "dana.whitfield@example.com", // opaque to us — never resolved to a person
"note": "Requested re-capture of the June statement" }
review_state ∈ open · in_review · cleared · escalated, defaulting to open once any document in the loan is flagged. It is loan-level workflow state, distinct from the per-document disposition — so the queue can be worked without holding a second state store keyed on our identifiers. An explicit null clears assignee or note; omitting the key leaves the current value alone. Idempotent; a later call replaces the earlier state.
Routing rules stay on your side, by design. Score thresholds are a per-lender policy decision, and we should not be the owner of a decision a lender must be able to justify. What the API provides is everything a rule needs to evaluate against — verdict, risk_score, unresolved-flag state and a queryable loan list. A rule such as route to Fraud Review when risk ≥ 40 or any document is FORGED is a client-side policy evaluated on the data above.
Coverage attestation
"Every document was screened" is only meaningful if it can be reconciled. The API knows the documents it received; it cannot know about one that was never sent. 1.1 closes that by letting you declare the expected set up front.
{ "expected_total": 36,
"items": [ // optional itemisation
{ "key": "bank_stmt_jun", "name": "June bank statement", "category": "bank_statement" },
{ "key": "paystub_2", "name": "Paystub 2", "category": "paystub" }
] }
Submit each document with the matching expected_key and the declared item resolves. LoanSummary.coverage then reports against the declaration:
To take a declaration back, DELETE /v1/loans/{external_ref}/expected-documents withdraws it: expected_total, unscreened and complete become absent again and missing empties. PUT can only replace a declaration with another, and expected_total: 0 asserts complete: true — a stronger claim than none. Idempotent; returns the rollup.
"coverage": {
"expected_total": 36, "submitted": 36, "screened": 36,
"processing": 0, "failed": 0, "unscreened": 0,
"complete": true, "missing": []
}
Until this endpoint is called, expected_total, unscreened and complete are absent from coverage entirely — not null, and never silently true. That distinction is the point: a coverage claim that cannot fail is not evidence. If you declare 36 and submit 34, coverage says so and the audit report prints it. loan.coverage_complete fires when the set closes.
Flagged-amount attribution
Counts answer "how many documents are flagged"; underwriting asks "how many dollars rest on them". Tell us what each document substantiates and we compute the exposure server-side, so the figure in your UI and the figure in the audit report cannot drift apart.
// on submit
"attribution": { "section": "asset", "field": "total_verified_assets",
"currency": "USD", "amount_minor": 4000000 }
// on GET /v1/loans/{external_ref}
"flagged_amount": {
"currency": "USD", "amount_minor": 4000000,
"basis": "Total verified assets substantiated by documents that are SUSPICIOUS or FORGED and not cleared",
"contributing_document_ids": ["doc_9f2c1a", "doc_3b71e4"]
}
section ∈ asset · income · liability · property · identity · other, and it is required — currency defaults to USD and amount_minor to 0, so an attribution naming only a section is valid. Every attributed document on one loan must agree on currency; a second currency is refused at submit with 422 attribution_currency_conflict, because a rollup that cannot add is worse than a submission that fails. field is your own field id. Amounts are integer minor units (cents) rather than decimals so sums are exact — elsewhere the API uses display strings for money (math_checks.computed), but those are never arithmetic operands and these are.
Attribution is never inferred. A document with no attribution contributes nothing to the sum, and flagged_amount is absent when no document in the loan carried one. Clearing a document's review removes it from the total.
Verifit — verified borrower re-capture
When a document is unreadable, a photo of a screen, or contradicted by its own arithmetic, the strongest next step is a fresh capture of the source document taken under provenance. Verifit mints a single-use link that opens a mobile capture page; the photo is C2PA-signed on the device, analyzed by the normal pipeline, and reconciled against the original submission.
{ "document_id": "doc_9f2c1a",
"borrower": { "display_name": "J. Sample", "phone": "+19705550147", "locale": "en-US" },
"message": { "template_id": "quality",
"body": "The resolution on your {document} wasn't high enough for us to process…" },
"delivery": "sms", // sms | email | link_only (no PII required)
"expires_in": "PT48H" } // default PT48H, max P7D, must be positive
→ 201
{ "id": "vfl_4c81", "object": "verifit_link", "status": "pending",
"url": "https://verifit.scam.ai/claim/<token>", "qr_png_url": "…",
"message_audit": { "safe": true, "matched_terms": [], "acknowledged_disclosure": false },
"expires_at": "2026-07-29T20:32:00Z" }
The audit-safe validator is server-side and blocking. The message body is scanned against a tip-off term list before the link is minted. On a match the call returns 422 message_would_disclose_flag with the matched terms and no link is created — the guarantee being that a borrower under review is never told they are under review. A client-side check cannot make that guarantee. Override requires an explicit "acknowledge_disclosure": true, which is recorded on the link and surfaces in the audit report.
GET /v1/verifit/templates returns presets (quality, routine, missing) that pass the validator, as {object: "list", data: […], has_more: false}; {document} is the only interpolation token. Custom copy is accepted and validated identically. GET /v1/verifit/links/{id} and POST /v1/verifit/links/{id}/revoke manage a link; status ∈ pending · consumed · expired · revoked. In production the link is backed by the live Verifit service and the GET reflects its live status — a session consumed or abandoned out of band shows up on the next read; in the sandbox a deterministic fixture stands in for that service.
image/png — the QR encoding the link's url, for printing or for a desktop hand-off to the claimant's phone. This is what qr_png_url points at. Authenticated like every other endpoint; an unknown link is 404 not_found.
{ "id": "vfc_77a2", "object": "verifit_capture", "status": "completed",
"link_id": "vfl_4c81",
"document_id": "doc_9f2c1a", // the ORIGINAL submission
"recaptured_document_id": "doc_be40f2", // an ordinary Document — GET /v1/documents/{id}
"captured_at": "2026-07-27T20:32:07Z", "response_time": "PT6M14S",
"device": "iPhone 15 Pro · rear camera", "photo_count": 2,
"headline": "Authentic capture — and it contradicts the submitted upload",
"c2pa": { "spec_version": "2.3", "validation_state": "Trusted",
"signature_alg": "ES256", "timestamp_authority": "RFC3161",
"edits_since_capture": 0, "manifest_url": "…" },
"signal_groups": [
{ "id": "provenance", "signals": [ { "label": "Signature", "value": "ECDSA P-256 · valid", "status": "pass" } ] },
{ "id": "device_integrity", "signals": [ … ] },
{ "id": "forensics_rerun", "signals": [ … ] },
{ "id": "reconciliation", "signals": [ { "label": "Ending balance — re-captured", "value": "$10,521.19", "status": "flag" } ] }
],
"contradicts_original": true }
application/json — the same object as VerifitCapture.c2pa on its own, so an auditor can fetch the provenance without the reconciliation around it. This is what c2pa.manifest_url points at. Authenticated; an unknown capture is 404.
The four signal_groups ids are stable; signals within a group are additive, and each carries status ∈ pass · flag · unavailable. validation_state uses C2PA's own vocabulary so it can be checked against that spec rather than against our wording.
Two rules for the integration. A capture produces an ordinary Document — recaptured_document_id goes through the normal pipeline, so verdicts, indicators and regions need no special-casing downstream. And contradicts_original is the boolean to branch on; headline and each signal's value are display copy and may be reworded without notice.
Webhooks
Configure an HTTPS endpoint in the dashboard (or per-request via webhook_url). The payload embeds the full object — no follow-up GET needed.
| Event | Fires when | data |
|---|---|---|
document.completed | A document finishes analysis, any verdict | Document |
document.failed | Accepted but could not be scored: unreadable, encrypted, over the page limit, or scoring stayed unavailable through every retry | Document |
audit_report.completed | Report finished generating | AuditReport |
audit_report.failed | Report generation failed | AuditReport |
verifit.capture.completed | 1.1 Borrower completed a re-capture and it has been analyzed | VerifitCapture |
verifit.capture.failed | 1.1 Capture could not be analyzed | VerifitCapture |
verifit.link.expired | 1.1 Link passed expires_at unused | VerifitLink |
loan.coverage_complete | 1.1 screened reached the declared expected_total | LoanSummary |
POST /your/endpoint
X-ScamAI-Signature: t=1785182400,v1=5f8a…c21
{ "id": "evt_7d31", "type": "document.completed",
"created_at": "2026-07-27T20:12:09Z",
"data": { "id": "doc_9f2c1a", "verdict": "FORGED", "…": "…" } }
Verify before trusting: recompute HMAC-SHA256 of <t>.<raw body> with your signing secret, compare to v1 in constant time, and reject if |now − t| > 300 s.
const [t, v1] = sig.split(",").map(kv => kv.split("=")[1]);
const expect = crypto.createHmac("sha256", process.env.SCAMAI_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`).digest("hex");
const valid = crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(v1))
&& Math.abs(Date.now()/1000 - t) < 300;
Delivery contract: at-least-once, possibly out of order, retried with exponential backoff for 24 h on non-2xx. Deduplicate on evt_ id and treat handlers as idempotent. Requests carry User-Agent: ScamAI-CheckReality-Webhooks/1.1.
Current build: only document.completed, audit_report.completed, verifit.capture.completed and loan.coverage_complete are emitted — the *.failed and verifit.link.expired events are defined for forward compatibility. loan.coverage_complete fires from the expected-documents declaration. Treat the webhook as a nudge and GET the object for its final state rather than trusting the embedded payload to be terminal.
Audit reports & evidence
Aggregates a loan file's completed documents into the exportable Fraud Audit PDF (documents screened, findings, hashes, math checks). Pass {"external_ref": "loan_84620"} or an explicit document_ids array. Returns 202; if no completed documents match, 400 invalid_request — nothing to report on. Accepts Idempotency-Key: a replay returns the stored report with Idempotency-Replayed: true rather than minting a second one. On completion, download_url points at the download endpoint below and does not expire. coverage appears only on reports created with external_ref.
1.1 adds format ∈ pdf (default, unchanged response) · json · both. The JSON forms carry the same evidence the PDF prints, machine-readable:
POST /v1/audit-reports { "external_ref": "loan_84620", "format": "both" }
→ { "id": "rpt_2a71", "object": "audit_report", "status": "completed",
"documents_screened": 36, "documents_flagged": 3, "pages": 5,
"generated_at": "2026-07-27T21:04:11Z",
"models": [ { "name": "eva-doc", "version": "2.6" },
{ "name": "docforge", "version": "2.6" } ],
"coverage": { "expected_total": 36, "screened": 36, "unscreened": 0, "complete": true },
"findings": [ { "document_id": "doc_9f2c1a", "verdict": "FORGED", "risk_score": 87,
"headline": "ending balance digitally altered after initial save",
"indicators": [ … ], "math_checks": [ … ],
"metadata_anomalies": [ … ], "review_disposition": "escalated" } ],
"manifest": [ { "document_id": "doc_9f2c1a", "name": "25 (4).pdf", "category": "bank_statement",
"verdict": "FORGED", "risk_score": 87, "sha256": "e10c2473…" } ],
"manifest_digest": "9d4f…",
"manifest_digest_algorithm": "sha256-of-sorted-sha256-newline-joined",
"download_url": "https://api.scam.ai/v1/audit-reports/rpt_2a71/download?sig=9d4f…" }
findings[]carries exactly the indicators, math checks and metadata anomalies the Document carried — nothing is re-derived for the report, so a reviewer cross-checking the PDF against the evidence panel sees identical values.manifest[]lists every document in the report, including those with no findings. That is the coverage attestation;coveragestates it against the declared set.manifest_digestfolds the file set into one value a loan buyer can recompute independently.
The digest construction is specified exactly, because a value that cannot be recomputed is not evidence:
It is independent of generation time: regenerating a report over the same file set with the same model versions reproduces the same digest.
The file download_url points at. Authenticated like every other endpoint — send your key. Served as application/pdf with Content-Disposition: attachment; filename="<report id>.pdf".
The sig query parameter is the first 16 hex characters of manifest_digest. It is derived from the document digests, so it is stable, non-secret and does not expire: it names the file set, it does not authorise the download. A report is a fraud finding about a borrower, so the key is required and the link cannot be forwarded to someone outside the account.
Errors
Every error response is this envelope — one error member, nothing at the top level. type is the coarse class, code the stable machine key to branch on.
{ "error": { "type": "invalid_request",
"code": "message_would_disclose_flag",
"message": "…",
"doc_url": "https://docs.scam.ai/errors/message_would_disclose_flag",
"matched_terms": ["fraud"] } }
doc_url is always present. Context keys (e.g. matched_terms) are added inside error, alongside the standard four — read them from body.error.matched_terms, never from body.matched_terms.
| HTTP | Type | Typical codes |
|---|---|---|
| 400 | invalid_request | missing_file, invalid_option, malformed_json, invalid_request |
| 401 | authentication | invalid_api_key (bad/malformed/wrong-environment key), browser_origin_forbidden |
| 404 | not_found | unknown id (sandbox/production ids are disjoint), or an unknown route |
| 405 | invalid_request | method_not_allowed — a known route called with a verb it does not answer to, such as PUT /v1/documents; the Allow response header names what would work |
| 409 | conflict | not_reviewable, file_not_retained, link_already_active, link_already_consumed, capture_not_ready (the capture's evidence is still being produced; retry shortly), sandbox_only (a sandbox-only endpoint called against a live-backed deployment), analysis_in_progress (a reprocess called while the document is still queued or running; wait for completed or failed) |
| 410 | conflict | link_expired — mint a new link |
| 413 | invalid_request | request_too_large — the JSON request body itself (not a file) is over 1 MB |
| 413 / 415 | file_error | file_too_large (size 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) |
| 422 | processing_error | url_fetch_failed (the file_url could not be fetched: not http(s), a private or internal host, DNS/connect/TLS failure, 60 s timeout, non-2xx, empty body, or a redirect onto a private host or a fourth redirect — reason in message), unreadable_document — raised synchronously at submit when the content matches no known PDF/PNG/JPEG signature, or asynchronously (as a failed document's error) when the content is one of those formats but the engine cannot open it. A file the engine cannot open because it is encrypted is file_encrypted (type file_error) rather than unreadable_document |
| 422 | invalid_request | message_would_disclose_flag (no Verifit link is minted; matched_terms returned), expected_total_below_submitted, attribution_currency_conflict (a second currency on one loan's attributions), idempotency_key_reuse (an Idempotency-Key presented again with a different payload) |
| 429 | rate_limited | honor Retry-After |
| 5xx | server_error | The API returns 500. A 502, 503 or 504 comes from the network edge and may not have a JSON body. Retry any of them with backoff and the same Idempotency-Key. |
Rate limits
Each key is a token bucket: 600 requests per minute sustained with a burst of 1,200 (per the commercial agreement; raised on request). A key that has been idle can send 1,200 requests at once, then 10 per second — the allowance refills continuously, so a caller holding under 10/s never sees a 429.
X-RateLimit-Limit reports the burst capacity, not the per-minute refill: it is the most the key can spend at one instant. X-RateLimit-Remaining is what is left in the bucket and X-RateLimit-Reset the seconds until it is full again (on a 429, the seconds until the next request will be accepted). Every response also carries X-CheckReality-Env (sandbox | production) and X-Response-Time-Ms. 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:
- Tolerate unknown fields — new response fields may appear at any time.
- Treat enums as open — new
indicator.code,error.code, andmodelvalues will ship; render unknown indicators generically fromtitle+severity. - Contract vs. copy —
code,severity,verdict,risk_score, and coordinates are stable machine surface;what_it_is/what_it_means/how_detected/headlineare display copy and may be reworded without notice. Never parse them.
Sandbox & go-live
Sandbox keys score real documents with the 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
completedwithin seconds; a scanned or photographed page takes longer because it is scored on a GPU.?wait=truebehaves as documented in both cases. POST /documents/{id}/reprocessre-scores the document; one whose bytes are not held answers409 file_not_retained, and one still queued or running answers409 analysis_in_progress— wait forcompletedorfailed, then reprocess.VerifitLink.qr_png_urlandc2pa.manifest_urlserve the sandbox fixture's QR and manifest — deterministic stand-ins, not a real capture.- Sandbox state is durable (Postgres + object storage, as in production): ids, verdicts and loans survive a redeploy.
Go-live checklist:
- Webhook endpoint verifies signatures and dedupes on event id; unknown event types are ignored rather than rejected.
external_refattached to every submission — without it, listing, loan rollups and audit reports cannot group documents. This is the single most important field.categorysent where known, andmodels: ["docforge"]for photos and scans —eva-docis 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.