# CheckReality — Document Forgery Detection API > ScamAI CheckReality detects digital tampering and forgery in documents > (bank statements, pay stubs, tax forms, IDs) via API. Clients submit > PDF/PNG/JPG files and receive risk signals: a verdict (CLEAN / SUSPICIOUS / > FORGED), a 0-100 risk score, explainable forensic indicators, coordinates of > tampered regions, arithmetic cross-checks, and recomputable audit evidence. > Powered by the `eva-doc` document-forensics model and `docforge` GenAI-image > detection. Status: spec 1.2.0-draft; reference last verified against the live sandbox build on 2026-09-15 (96 contract checks, 0 failures), contract corrections applied 2026-08-30. Contact: dennisng@scam.ai · https://scam.ai ## Docs - [API Reference (Markdown)](https://docs.scam.ai/checkreality-api-reference.md): full integration reference — lifecycle, auth, endpoints, Document object, webhooks, errors, versioning rules - [OpenAPI 3.1 spec (YAML)](https://docs.scam.ai/checkreality-openapi.yaml): machine-readable source of truth for all endpoints and schemas ## Optional - [API Reference (HTML)](https://docs.scam.ai/): human-formatted version of the reference --- # CheckReality Document Forensics API Reference (v1 API, spec 1.2.0-draft) ScamAI CheckReality — document forgery detection for lending workflows. This is the partner integration reference. Machine-readable source of truth: [checkreality-openapi.yaml](https://docs.scam.ai/checkreality-openapi.yaml). Status: draft for integration review — surface may change until countersigned. Reference last verified against the live sandbox build on 2026-09-15 (96 contract checks, 0 failures); contract corrections applied 2026-08-30; behaviour changes of 2026-09-08 listed below. Contact: dennisng@scam.ai. ## 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 | ## New in spec 1.1 All additions are additive — no field removed, no enum member dropped, no existing response shape changed. A client built against 1.0 keeps working. | Capability | Surface | |---|---| | Cross-loan triage queue | `GET /v1/loans`, `POST /v1/loans/{external_ref}/review`, `in_review` disposition | | Coverage attestation | `PUT /v1/loans/{external_ref}/expected-documents`, `LoanSummary.coverage` | | Flagged-amount attribution | `SubmitOptions.attribution`, `LoanSummary.flagged_amount` | | Verified borrower re-capture | `/v1/verifit/*` with C2PA provenance and a server-side audit-safe validator | | Recomputable audit evidence | `POST /v1/audit-reports` `format: json\|both` → `findings[]`, `manifest[]`, `manifest_digest` | **Corrections — 2026-08-30** (spec `1.1.1-draft`). The surface did not change; the description of it did. The error envelope is documented as the object it is on the wire; `POST /v1/verifit/links/{id}/simulate-capture` answers `201`; `GET /v1/audit-reports/{id}/download`, `GET /v1/verifit/links/{id}/qr.png` and `GET /v1/verifit/captures/{id}/c2pa` are documented; fields that do not apply are **omitted, never `null`**; `attribution.section` is required; the audit-report download link does not expire. **Changes — 2026-09-19** (spec `1.2.0-draft`). `POST /v1/documents/{id}/reprocess` requires the original document as the multipart `file` part (verified against the document's recorded sha256): no file answers `400 source_file_required`, a mismatch `400 invalid_request`. New error code `source_file_required`. ## 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 — the same API and the same forensics engine, unmetered. A `sk_live_` key is refused on the sandbox host and an `sk_test_` key is refused in production. - All requests/responses are JSON except file upload (multipart) and the audit-report download (a PDF file). - Analysis is **asynchronous**: `POST /documents` returns `202` with `status: "processing"`; a worker scores the document and results arrive via signed webhook (`document.completed` / `document.failed`) or polling `GET /documents/{id}`. - `POST /documents?wait=true` long-polls up to 30 s and returns the finished verdict inline when the worker completes in time (a native PDF of a few pages usually does); otherwise the `processing` document comes back and you poll or wait for the webhook. - **Polling at volume:** poll per loan, not per document. `GET /v1/loans/{external_ref}` rolls up every document on the file in one call; with tens of documents per loan, per-document polling burns the key's 600/min sustained allowance on status checks. - Byte-identical resubmission (same SHA-256, same model set, same filename, same `external_ref`) returns `200` with the cached verdict instantly, at no charge. Changing any of the four re-analyzes. - Loan file done → `POST /audit-reports` → `audit_report.completed` webhook → PDF download. **Absent fields.** A field that does not apply to a response is **omitted from the JSON entirely**, recursively — never sent as `null`. Test for the key, not for `null`, and read a missing key as "not applicable". The one place a `null` carries meaning is a *request*: `"assignee": null` on `POST /v1/loans/{external_ref}/review` unassigns the loan, where omitting the key leaves the current assignee alone. ## Authentication API keys are accepted in either header, whichever suits your client. The two are equivalent and both work on every endpoint: ``` Authorization: Bearer sk_live_… X-API-Key: sk_live_… ``` Use `sk_test_…` for sandbox and `sk_live_…` for production. Headers only. The key is not a body field and not a query parameter: GET endpoints have no body, and a key in the query string ends up in access logs, browser history and referrer headers. Requests presenting the key any other way are rejected `401`. Keys are server-side only — a request carrying a browser `Origin` header is rejected `401` (`browser_origin_forbidden`). Two keys can be live simultaneously for zero-downtime rotation. Details: 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` with code `invalid_api_key` and a message naming the specific problem. Rate limits are applied per key. ## Submit a document `POST /v1/documents` — multipart file upload, or JSON `{"file_url": "..."}` with a pre-signed HTTPS URL (fetched once, within 60 s, no cookies). Attach an `Idempotency-Key` header for safe retries: 64 characters at most (longer is rejected `400 invalid_request`), replays are keyed on the header value within a 24 h window and are marked with an `Idempotency-Replayed: true` response header. The key is bound to the request that first carried it: the same key with a different payload is refused `422 idempotency_key_reuse` rather than answered with the stored response. The same header, with the same rules, is accepted on `POST /v1/verifit/links` and `POST /v1/audit-reports`. Options (multipart `options` field or JSON `options` object): | Option | Type | Notes | |---|---|---| | `external_ref` | string | Your loan/file id. Groups documents; echoed on webhooks; powers listing and audit reports. | | `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* — `{section, field, currency, amount_minor}`: what figure this document substantiates. `section` is **required**; `currency` defaults to `USD` and `amount_minor` to `0`. Echoed on the Document, summed into `LoanSummary.flagged_amount`. | | `expected_key` | string | *1.1* — links the submission to a declared expected-document item so `coverage.missing` resolves. | Limits: PDF/PNG/JPG, 200 MiB (209,715,200 bytes), 200 pages. `file.pages` is the page count of the file. The file type is detected from the file's content (PDF, PNG or JPEG signatures), not from its name. Content in another format (GIF, WEBP, TIFF, HEIC, BMP) is rejected with `415 unsupported_type`; content that matches no known format is rejected with `422 unreadable_document`. A file that passes this 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`; both are reported on the document, never 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. Over the size or page limit → `413 file_too_large`; empty upload → `400 missing_file`; malformed multipart bodies (missing boundary, truncated body) are `400 invalid_request`. 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 rejected `422 url_fetch_failed`. A PDF's page count is read at submit whenever the file can be parsed, so an over-limit PDF gets `413 file_too_large` 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 later: the document is accepted, then ends `failed` with `file_too_large` after scoring instead of answering 413 directly. 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`. ## Risk signal → routing Branch on `verdict`; use `risk_score` (0–100, higher = riskier) for ordering and thresholds within a band. Score bands 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. | 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. | ## The Document object Example — a flagged bank statement: ```json { "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": "e10c2473…" }, "issuer": "Commerce Bank", "issue_date": "2025-11-17", "capture": "native_pdf", "model": "eva-doc", "model_version": "2.6", "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 directly inside a finished PDF.", "what_it_means": "Bank-generated statements are never edited in Acrobat after export.", "how_detected": "The piece-info dictionary contains a TouchUp_TextEdit entry dated after the document was produced." }, { "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" } ], "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" } ], "metadata": [ { "key": "Producer", "value": "Adobe Acrobat Pro DC 2025", "anomalous": true }, { "key": "Incremental revisions", "value": "2", "anomalous": true } ], "attribution": { "section": "asset", "field": "total_verified_assets", "currency": "USD", "amount_minor": 4000000 }, "verifit_eligible": true, "created_at": "2026-07-27T20:12:04Z", "completed_at": "2026-07-27T20:12:09Z" } ``` Field notes: - `status` — `processing` | `completed` | `failed`. Malformed requests, unsupported types and oversize uploads are still rejected synchronously at submit with a `4xx` — the file type is detected from its content, not its name, so content matching no known PDF/PNG/JPEG signature is rejected here too, as `422 unreadable_document`; a document that is accepted (its content IS one of those formats) but cannot be scored — the engine cannot open it, a PDF that needs a password, more pages than the limit, or a scorer outage that outlived the retries — ends as `failed`, carrying an `error` object `{type, code, message, doc_url}` with the same codes as the error envelope (`unreadable_document`, `file_encrypted`, `file_too_large`, `file_not_retained`, `server_error`). A PDF that cannot be opened without its password is `file_encrypted` rather than `unreadable_document`, and is only ever reported here — encryption is discovered by trying to open the file, which happens during analysis. A failed document has no verdict fields; `POST /documents/{id}/reprocess` re-queues it with the original file attached (multipart `file` part) — but not while a document is already `queued` or `processing` (its own or a prior reprocess's), which answers `409 analysis_in_progress` rather than enqueueing a second job. - `issuer` — detected document source ("Commerce Bank", "SAP Payroll import"); **omitted** if not confidently identified. The current engine build does not emit it for live-scored documents. - `issue_date` — date printed on the document, OCR-extracted; omitted when not found, same caveat as `issuer`. - `capture` — `native_pdf` | `photo` | `scan`. Currently assigned from the file type: PDFs are `native_pdf`, images are `photo`; `scan` is reserved. - `model` — the engine that scored the document: `eva-doc`. Open enum. - `model_version` — model release that produced the verdict (the UI's "V2.6" badge). - `indicators` — sorted high severity first. Live-scored documents carry the engine's snake_case reason codes — e.g. `touch_up_text_edit`, `has_piece_info`, `font_character_remapping`, `create_date_mismatch`, `signature_invalid` (~90 codes) — with `severity` `high`, `medium` or `low`. The UPPERCASE codes in the example (`TOUCHUP_TEXTEDIT`, `REVISION_TEXT_DIFF`, `CROSS_TOTAL_MISMATCH`, …) are illustrative. Either way the enum is open — render unknown codes generically from `title` + `severity`; never fail on them and never branch on a closed code list. - `metadata` — a fact panel of up to 10 fixed rows: `Format`, `Pages`, `File size`, `Producer`, `Creator`, `Created`, `Modified`, `Incremental revisions`, `Fonts embedded`, `XMP metadata`. `value` is a display string ("1.4 MB", "All 7 embedded", "Not present"), not raw metadata. `anomalous` is reserved: the current engine build sets it `false` on every row. - `math_checks` — arithmetic examiner output, produced only for SUSPICIOUS/FORGED documents; only failing checks are emitted (`ok` is `false` when present), `formula` is a plain-language summary, and `computed`/`stated` are best-effort extractions that may be empty strings. An empty array means no arithmetic contradiction was reported — not that totals were verified. - `headline`, `regions`, `revisions`, `genai_heatmap` — **omitted**, not `null`, when the engine does not produce them, which is not every document. Check for the key before rendering; `LoanSummary.top_reason` is likewise absent whenever `headline` is. - `checks_passed` — for CLEAN documents, the named checks for the model that ran (revision history, TouchUp scan, producer-pipeline match, font-embedding consistency, overlay/script scan, timestamp consistency). - `attribution` *(1.1)* — echoed from the submission, never inferred. - `verifit_eligible` *(1.1)* — true when a borrower re-capture would add evidence (capture is photo/scan, or the verdict is SUSPICIOUS/FORGED on a re-photographable document class). Drives the "Request re-capture" action; `POST /v1/verifit/links` does not require it. - `verifit_capture_id` *(1.1)* — set when this Document was produced by a Verifit capture. - `verifi_eligible`, `verifi_capture_id` *(deprecated)* — the same two values under the product's pre-2026-09-09 name. Emitted alongside the `verifit_*` fields until 2026-12-31, then removed. - Region coordinates: origin top-left; `x`/`w` are percentages (0–100) of rendered page width, `y`/`h` of page height. Overlay math: `left = x/100 * pageW`, etc. `confidence` (0..1) and `severity` drive the badge; `indicator_code` links a box to its explanation. ## Retrieve & list - `GET /v1/documents/{id}` — the Document in any status. Poll ≥ 2 s intervals if not using webhooks; prefer `GET /v1/loans/{external_ref}` when a loan has many documents (one call rolls up the whole file). - `GET /v1/documents?external_ref=…&verdict=…&limit=…&starting_after=…` — cursor-paginated, newest first. Returns slim `DocumentSummary` rows by default (`view=summary`): id, name, status, verdict, risk_score, issuer, issue_date, capture, headline, review disposition — verdict fields are withheld while a row is still `processing`. `view=full` returns complete Documents. `limit` is 1–100 (default 25); outside that range the request is rejected `400 invalid_request` rather than clamped. An unknown `starting_after` cursor is ignored and the first page is returned. ## Review, reprocess & loan rollups - `POST /v1/documents/{id}/review` — record human disposition: `{"disposition": "in_review" | "cleared" | "escalated" | "confirmed_fraud", "note", "reviewer"}`. Echoed on the Document; `cleared` removes the doc from the loan's flagged count; appears in later audit reports; idempotent per document. An unknown `disposition` is `400 invalid_request`. `in_review` is new in 1.1 — a "someone is looking at it" state, so no parallel store keyed on our ids is needed. - `POST /v1/documents/{id}/reprocess` — re-run with the current model. **Attach the original document as the multipart `file` part**; the supplied bytes must match the document's recorded sha256 (no file: `400 source_file_required`; mismatch: `400 invalid_request`). `202`; prior verdict readable until the new one lands. `created_at` is the submission time and never changes; `completed_at` moves to the new completion. - `GET /v1/loans/{external_ref}` — loan rollup: `risk_score`, `verdict_counts` (clean/suspicious/forged), `documents_flagged`/`documents_total`/`documents_processing`, `models` used, `estimated_review_minutes_saved` (display-only heuristic), `top_reason`, and flagged `DocumentSummary` rows. 1.1 adds `coverage`, `flagged_amount`, `review_state` and `assignee` to the same object. ## Fraud Review queue (1.1) - `GET /v1/loans?has_unresolved=true&sort=-risk_score` — LoanSummary rows for every `external_ref` seen on a submission or carrying declared loan state. `updated_at` reflects the newest document submission. Filters: `has_unresolved`, `min_risk_score`, `review_state`, `assignee`. Sort: `-risk_score` (default), `risk_score`, `±updated_at`. Cursor-paginated (`limit` 1–100, default 25; `starting_after`). - `POST /v1/loans/{external_ref}/review` — `{"review_state": "in_review", "assignee": "…", "note": "…"}`. `review_state` ∈ `open` · `in_review` · `cleared` · `escalated`, defaulting to `open` once any document is flagged. Loan-level workflow state, distinct from the per-document disposition. `assignee` is opaque — we never resolve it to a person. An explicit `null` clears `assignee` or `note`; omitting the key leaves the current value alone. Idempotent. **Routing rules stay client-side by design.** Score thresholds are a per-lender policy decision; the API supplies everything a rule evaluates against (verdict, risk_score, unresolved-flag state, queryable loan list). A rule such as *route to Fraud Review when risk ≥ 40 or any document is FORGED* is client-side policy. ## Coverage attestation (1.1) `PUT /v1/loans/{external_ref}/expected-documents` declares what the loan file should contain: ```json { "expected_total": 36, "items": [ { "key": "bank_stmt_jun", "name": "June bank statement", "category": "bank_statement" } ] } ``` Submit documents with the matching `expected_key` and the declared item resolves. `LoanSummary.coverage` then reports `expected_total`, `submitted`, `screened`, `processing`, `failed`, `unscreened`, `complete`, and `missing[]` when itemised. Until the endpoint is called, `expected_total`, `unscreened` and `complete` are **absent from `coverage` entirely** — not `null`, and never silently `true`; we cannot attest to a document that was never sent. Test for the key. `loan.coverage_complete` fires when the set closes. `422 expected_total_below_submitted` if the declaration is below what has already been submitted. `DELETE /v1/loans/{external_ref}/expected-documents` withdraws the declaration: `expected_total`, `unscreened` and `complete` become absent again and `missing` empties. Use it to retract a declaration made in error — `PUT` can only replace one with another, and `expected_total: 0` asserts `complete: true`, a stronger claim than none. Idempotent; returns the rollup. ## Flagged-amount attribution (1.1) Send `attribution` on submit; `LoanSummary.flagged_amount` sums it over documents whose verdict is SUSPICIOUS or FORGED and whose disposition is not `cleared`: ```json "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. Amounts are integer minor units (cents) so sums are exact — elsewhere money is a display string (`math_checks.computed`), but those are never arithmetic operands and these are. Never inferred: no attribution, no contribution; `flagged_amount` is **absent** when no document carried one. ## Verifit — verified borrower re-capture (1.1) > **Production needs a claimant identity.** A live re-capture requires `borrower.phone` (E.164) or `borrower.email` for **every** delivery, `link_only` included — `400 invalid_request` without one. The sandbox accepts a mint with no borrower, so an integration validated there must add it before its first production mint. `message.template_id`, when sent, must be one of the presets from `GET /v1/verifit/templates` (`404` otherwise). > **Renamed 2026-09-09.** This product was called *Verifi*. `/v1/verifi/*` still answers as an authenticated alias of `/v1/verifit/*` — same handlers, same responses, plus `Deprecation: true` and `Link: ; rel="successor-version"` — until 2026-12-31. Claimant links now open on `https://verifit.scam.ai/claim/`; the old `verifi.scam.ai` hostname is retired — links minted before the rename must be re-issued. Move your base paths and any hostname allow-lists before the sunset. A single-use link opens a mobile capture page for one document. The photo is C2PA-signed on device, analyzed by the normal pipeline, and reconciled against the original submission. - `POST /v1/verifit/links` — `{document_id, borrower{display_name, phone, email, locale}, message{template_id, body}, delivery, expires_in}`. `delivery` ∈ `sms` (requires `borrower.phone`) · `email` (requires `borrower.email`) · `link_only` (no PII required). `expires_in` default `PT48H`, max `P7D`, accepted forms `PnD` / `PTnH` / `PTnHnM` only, and must be positive (`P0D` and `PT0M` are `400 invalid_request`). Accepts `Idempotency-Key`. Returns `201` with `url`, `qr_png_url`, `message_audit`, `expires_at`; `status` ∈ `pending` · `consumed` · `expired` · `revoked`. `url` is the claimant capture page — `https://verifit.scam.ai/claim/`. - `GET /v1/verifit/templates` — presets (`quality`, `routine`, `missing`) that pass the validator, as `{object: "list", data: [...], has_more: false}`. `{document}` is the only interpolation token; custom copy is validated identically. - `GET /v1/verifit/links/{id}`, `POST /v1/verifit/links/{id}/revoke`. In production the link is backed by the live Verifit service and `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` (this is what `qr_png_url` points at). Authenticated like every other endpoint; `404 not_found` for an unknown link. Served only while the link is `pending`; once revoked, expired or consumed it is `404`, like an unknown id. - `GET /v1/verifit/captures/{id}` — the capture: `recaptured_document_id`, `captured_at`, `response_time`, `device`, `photo_count`, `headline`, `c2pa`, `signal_groups[]`, `contradicts_original`. - `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; `404` for an unknown capture. **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; a match returns `422 message_would_disclose_flag` with `matched_terms` and **no link is created**. The guarantee is that a borrower under review is never told they are under review — a client-side check cannot make that guarantee. Override requires explicit `"acknowledge_disclosure": true`, recorded on the link and surfaced in the audit report. Two integration rules: 1. **A capture produces an ordinary Document.** `recaptured_document_id` goes through the normal pipeline — verdict, indicators and regions need no special-casing. 2. **Branch on `contradicts_original`** (true when any signal group contains a flag), not on display copy. The four `signal_groups` ids — `provenance`, `device_integrity`, `forensics_rerun`, `reconciliation` — are stable; signals within a group are additive, each with `status` ∈ `pass` · `flag` · `unavailable`. `c2pa.validation_state` uses C2PA's own vocabulary (`Trusted` · `Valid` · `Invalid` · `Unverified`). ## Webhooks Events: `document.completed`, `document.failed`, `audit_report.completed`, `audit_report.failed`, and in 1.1 `verifit.capture.completed`, `verifit.capture.failed`, `verifit.link.expired`, `loan.coverage_complete`. Ignore unknown event types rather than rejecting them. Payload embeds the full object. Signature header: `X-ScamAI-Signature: t=,v1=." with your signing secret>`. Verify in constant time; reject if `|now - t| > 300 s`. Delivery is at-least-once, possibly out of order, retried on any non-2xx (redirects included), a connection failure, or no response within 5 s — deduplicate on the `evt_` id. The schedule after a failed attempt: 1 min, 5 min, 15 min, 30 min, 1 h, 2 h, 4 h, 8 h, 8 h (ten attempts over ~24 h), then the event is abandoned. Every attempt carries a fresh signature over the same body, plus `X-ScamAI-Event-Id`, `X-ScamAI-Event-Type` and `X-ScamAI-Delivery-Attempt` headers. Requests carry `User-Agent: ScamAI-CheckReality-Webhooks/1.1`. Where events go: the per-request `webhook_url` option, else the account webhook configured for your key; with neither, nothing is sent. Current build: `document.completed`, `document.failed`, `audit_report.completed`, `verifit.capture.completed` and `loan.coverage_complete` are emitted — `audit_report.failed` and `verifit.link.expired` are defined for forward compatibility. `loan.coverage_complete` fires from the expected-documents declaration. The embedded payload is the object as it was when the event was emitted; `GET` the object for its current state if anything may have changed since (a review, a reprocess). ## Audit reports & evidence - `POST /v1/audit-reports` with `{"external_ref": "…"}` or `{"document_ids": […]}` → `202`. Aggregates completed documents into the exportable Fraud Audit PDF (documents screened, findings, hashes, math checks). If no completed documents match, the request is rejected `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. - `GET /v1/audit-reports/{id}` — status; `download_url` is present once the report is `completed` and `format` includes `pdf`. Counters: `documents_screened`, `documents_flagged`, `pages` (measured from the rendered PDF). `coverage` appears only on reports created with `external_ref` (a `document_ids` selection has no declared set to attest against). - `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=".pdf"`. The `sig` query parameter is the first 16 hex characters of `manifest_digest` — 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, and a link cannot be forwarded to someone without a key. Request the URL exactly as published: `sig` is required (`400` without it) and must match (`404` otherwise); a `format: json` report has no file and its `/download` is `404`. The same completed report downloads to the same bytes every time. 1.1 adds `format` ∈ `pdf` (default, unchanged response) · `json` · `both`. The JSON forms carry the same evidence the PDF prints: - `findings[]` — exactly the indicators, math checks and metadata anomalies the Document carried. Nothing is re-derived, so the PDF and the evidence panel cannot disagree. - `manifest[]` — **every** document in the report, including those with no findings. That is the coverage attestation; `coverage` states it against the declared set. - `manifest_digest` — one value a loan buyer can recompute from the file set. - `models[]`, `generated_at`. Digest construction (`manifest_digest_algorithm: sha256-of-sorted-sha256-newline-joined`), specified exactly because a value that cannot be recomputed is not evidence: > take each document's lowercase-hex `sha256` → sort lexicographically → join > with `\n` (no trailing newline) → UTF-8 encode → SHA-256 → lowercase hex. Independent of generation time: regenerating over the same file set with the same model versions reproduces the same digest. ## Errors Every error response is this envelope — one `error` member, nothing at the top level: ```json { "error": { "type": "invalid_request", "code": "message_would_disclose_flag", "message": "…", "doc_url": "https://docs.scam.ai/errors/message_would_disclose_flag", "matched_terms": ["fraud"] } } ``` `doc_url` is always present. Context keys (e.g. `matched_terms`) are added **inside** `error`, alongside the standard four — read them from `body.error.matched_terms`, never from `body.matched_terms`. | HTTP | type | Typical codes | |---|---|---| | 400 | `invalid_request` | `missing_file`, `source_file_required` (a reprocess without the original document), `invalid_option`, `malformed_json`, `invalid_request` | | 401 | `authentication` | `invalid_api_key` (bad/malformed/wrong-environment key), `browser_origin_forbidden` | | 404 | `not_found` | unknown id (sandbox/production ids are disjoint), or an unknown route | | 405 | `invalid_request` | `method_not_allowed` — a known route called with a verb it does not answer to, such as `PUT /v1/documents`; the `Allow` response header names what would work | | 409 | `conflict` | `not_reviewable`, `file_not_retained`, `link_already_active`, `link_already_consumed`, `capture_not_ready` (the capture's evidence is still being produced; retry shortly), `sandbox_only` (a sandbox-only endpoint called against a live-backed deployment), `analysis_in_progress` (a `reprocess` called while the document is still queued or running; wait for `completed` or `failed`) | | 410 | `conflict` | `link_expired` — mint a new link | | 413 | `invalid_request` | `request_too_large` — the JSON request body itself (not a file) is over 1 MB | | 413/415 | `file_error` | `file_too_large` (size **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, timeout after 60 s, non-2xx, empty body, or a redirect onto a private host or a fourth redirect — the reason is 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 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** (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** — the most the key can spend at one instant, not the per-minute refill. `X-RateLimit-Remaining` is what is left in the bucket, and `X-RateLimit-Reset` is the seconds until it is full again (on a `429`, the seconds until the next request will be accepted). `Retry-After` accompanies a `429` and carries the same number. Every response also carries `X-CheckReality-Env` (`sandbox` | `production`) and `X-Response-Time-Ms`. ## Versioning & forward compatibility Path version `/v1` changes only for breaking changes (≥ 6 months parallel support). Within v1, changes are additive — spec 1.1 is the current example: new paths, new optional request fields, new response fields, new enum members, nothing removed or reshaped. Client rules: 1. Tolerate unknown fields. 2. Treat enums as open — new `indicator.code`, `error.code`, `model` values will ship; render unknown indicators from `title` + `severity`. 3. Contract vs. copy — `code`, `severity`, `verdict`, `risk_score`, coordinates are stable machine surface; `what_it_is` / `what_it_means` / `how_detected` / `headline` are display copy that may be reworded. Never parse them. ## Sandbox & go-live checklist 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 that has already been 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 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): ids, verdicts and loans survive a redeploy. Before go-live: - Webhook endpoint verifies signatures, dedupes on event id, and ignores unknown event types. - `external_ref` attached to every submission — without it, listing, loan rollups and audit reports cannot group documents. The single most important field. - `category` sent where known; `models: ["docforge"]` for photos and scans (`eva-doc` is the default and is the wrong model for a phone photo). - 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 compliance — routing is client-side policy. Adopting the 1.1 surface, wire these at the same time: - `PUT /v1/loans/{external_ref}/expected-documents` when the document checklist is known, or the coverage claim stays unevidenced. - `attribution` on submissions that substantiate an asset or income figure, or `flagged_amount` is never present. ```yaml openapi: 3.1.0 info: title: CheckReality Document Forensics API version: "1.2.0-draft" description: > ScamAI CheckReality — document forgery detection for lending workflows. Submit borrower documents (PDF/PNG/JPG), receive forensic Verdicts with explainable indicators, region annotations, revision diffs, arithmetic cross-checks, and file-integrity hashes. Draft for partner integration review. 1.1 adds, additively: cross-loan triage (GET /loans, loan-level review state), coverage attestation (PUT /loans/{external_ref}/expected-documents plus LoanSummary.coverage), flagged-amount attribution (SubmitOptions.attribution, LoanSummary.flagged_amount), machine-readable audit evidence (AuditReport.findings, manifest and manifest_digest), and Verifit borrower re-capture (/verifit/*). No field was removed, no enum member dropped, and no existing response shape changed — 1.0 clients keep working unmodified. 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, do not branch on an explicit null. The one place a null carries meaning is a REQUEST: on POST /loans/{external_ref}/review, assignee: null unassigns and note: null clears the note, where omitting either key leaves the current value alone. Changes on 2026-09-08 (with the next production release): Idempotency-Key is bound to the payload it first carried (422 idempotency_key_reuse on reuse with a different payload); expires_in must be positive; a Verifit link reads expired once expires_at passes; note: null clears the loan review note; DELETE /loans/{external_ref}/expected-documents withdraws a declaration; malformed JSON is 400 malformed_json in the standard envelope (401 without a key); AuditReport.pages is measured from the rendered PDF; qr.png is served with Cache-Control: private, no-store. contact: email: dennisng@scam.ai servers: - url: https://api.scam.ai/v1 description: Production. Requires an sk_live_ key; sk_test_ keys are refused. - url: https://api-dev.scam.ai/v1 description: Sandbox. Requires an sk_test_ key; sk_live_ keys are refused. Same API and same forensics engine, unmetered. security: - bearerAuth: [] - apiKeyAuth: [] paths: /documents: post: operationId: submitDocument summary: Submit a document for forensic analysis description: > Accepts a file (multipart) or a fetchable URL (JSON). Analysis is asynchronous: returns 202 with status "processing". Pass ?wait=true to long-poll up to 30s and receive the completed Document when ready. Re-submitting a byte-identical file (same SHA-256, same model set, same filename, same external_ref) returns the cached completed Document immediately; changing any of the four re-analyzes. parameters: - name: wait in: query schema: { type: boolean, default: false } description: > Long-poll up to 30s for completion before responding. Must be true or false; any other value is 400 invalid_request. - name: Idempotency-Key in: header schema: { type: string, maxLength: 64 } description: > Safe-retry key, 64 characters at most — a longer value is rejected 400 invalid_request. Replays are keyed on the header value within a 24h window and are marked with an Idempotency-Replayed: true response header. The key is bound to the request that first carried it: presenting it again with a different payload (other bytes, name, options or file_url) is refused 422 idempotency_key_reuse rather than answered with the stored response. requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary description: > PDF, PNG, or JPG — the type is detected from the file's content (its signature), not its name. Max 200 MiB (209,715,200 bytes) / 200 pages. Empty upload → 400 missing_file. Content in another recognised format (GIF, WEBP, TIFF, HEIC, BMP) → 415 unsupported_type; content that matches no known format → 422 unreadable_document. options: $ref: "#/components/schemas/SubmitOptions" application/json: schema: type: object required: [file_url] properties: file_url: type: string format: uri description: > Pre-signed HTTPS URL (e.g. LOS document store). Fetched once, at submission, with a 60 s budget and no cookies; at most three redirects are followed, and every hop is re-checked against the same rule as the submitted URL before it is fetched. Must be an absolute http(s) URL on a public host — not localhost, an RFC1918 address, the cloud metadata address or .internal. Any failure to fetch — a refused host, a redirect onto one, a fourth redirect, DNS, connect, TLS, timeout, a non-2xx, an empty body — is 422 url_fetch_failed with the reason in the message. A body over 200 MiB is 413 file_too_large, exactly like an upload. The file type is detected from the fetched content, exactly like an upload — the fetched file name (Content-Disposition, else the last URL path segment, else document.pdf) is not consulted for typing. The JSON request itself must be under 1 MB, or it is 413 request_too_large. options: $ref: "#/components/schemas/SubmitOptions" responses: "202": description: Accepted for analysis (or completed, when ?wait=true and analysis finished in time). headers: Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } content: application/json: schema: { $ref: "#/components/schemas/Document" } "200": description: Duplicate SHA-256 — cached completed Document returned. headers: Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } content: application/json: schema: { $ref: "#/components/schemas/Document" } "400": description: > Malformed request: an empty or missing file part (missing_file), an unparseable or invalid options value (invalid_option), or a malformed multipart body — a missing boundary, a truncated body with no closing boundary, or an unparseable part header — is 400 invalid_request. A client disconnect mid-upload is 500 server_error, not this. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "401": { $ref: "#/components/responses/Unauthorized" } "413": { $ref: "#/components/responses/TooLarge" } "415": { $ref: "#/components/responses/Unsupported" } "422": description: > The file_url could not be fetched (code url_fetch_failed) — it must be an absolute http(s) URL on a public host, reachable without cookies and through at most three redirects, each checked against that same host rule. Content that matches no known PDF/PNG/JPEG signature is rejected here too (unreadable_document) — a file that passes this check but cannot be opened by the engine fails asynchronously with the same code. A second currency among one loan's attributions is attribution_currency_conflict. An Idempotency-Key presented again with a different payload is idempotency_key_reuse. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } get: operationId: listDocuments summary: List documents description: > Returns slim DocumentSummary rows by default — exactly the fields a document-list screen renders (verdict chip, issuer line, issue date). Pass view=full for complete Document objects. parameters: - name: view in: query schema: { type: string, enum: [summary, full], default: summary } - name: external_ref in: query schema: { type: string } description: Filter to one loan file (your identifier). - name: verdict in: query schema: { $ref: "#/components/schemas/Verdict" } - name: limit in: query schema: { type: integer, default: 25, minimum: 1, maximum: 100 } - name: starting_after in: query schema: { type: string } description: Cursor — id of the last item of the previous page. responses: "200": description: Paginated document list, newest first. content: application/json: schema: type: object properties: object: { const: list } data: type: array items: oneOf: - { $ref: "#/components/schemas/DocumentSummary" } - { $ref: "#/components/schemas/Document" } has_more: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /documents/{id}: get: operationId: getDocument summary: Retrieve a document and its verdict parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: The Document, in any status. content: application/json: schema: { $ref: "#/components/schemas/Document" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /documents/{id}/reprocess: post: operationId: reprocessDocument summary: Re-run analysis, supplying the original file description: > Re-analyzes with the current model version (the UI's "Re-process" action). The request MUST include the original document as a multipart `file` part; the supplied bytes are verified against the document's recorded sha256 and a mismatch is refused. Returns 202 with the Document back in status "processing". Verdict history is preserved and the previous verdict remains readable until the new one completes. created_at is the submission time and never changes; completed_at moves to the new completion. parameters: - name: id in: path required: true schema: { type: string } requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary description: The original document's bytes — must match the document's recorded sha256. responses: "202": description: Re-analysis started. content: application/json: schema: { $ref: "#/components/schemas/Document" } "400": description: > Either the request carried no file (error code source_file_required), or the file's sha256 does not match the document's recorded digest (error code invalid_request) — reprocessing only ever analyses the document's own bytes. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: > The document is still "queued" or "processing" from an earlier submit or reprocess — wait for status "completed" or "failed", then reprocess; error code analysis_in_progress. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /documents/{id}/review: post: operationId: reviewDocument summary: Record a human review disposition description: > Closes the loop from the Fraud Review queue. The disposition is echoed on the Document, rolls up into the loan summary, appears in subsequent Fraud Audit Reports, and (de-identified) helps calibrate scoring. Idempotent per document — a later call replaces the earlier disposition. parameters: - name: id in: path required: true schema: { type: string } requestBody: required: true content: application/json: schema: type: object required: [disposition] properties: disposition: type: string enum: [in_review, cleared, escalated, confirmed_fraud] description: > in_review (added in 1.1) marks "someone is looking at it" so a queue needs no parallel state store keyed on our ids. note: { type: string, maxLength: 2000 } reviewer: { type: string, maxLength: 128, description: Your user id/email for the audit trail. } responses: "200": description: Updated Document with review attached. content: application/json: schema: { $ref: "#/components/schemas/Document" } "400": description: The body does not match the schema, for example an unknown disposition. Error code invalid_request. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Document not yet completed. Error code not_reviewable. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /loans: get: operationId: listLoans summary: List loan rollups for cross-loan triage description: > The query behind a Fraud Review queue. Returns LoanSummary rows for every external_ref with at least one completed document, newest activity first unless sorted. Without this, a cross-loan queue can only be assembled by listing documents and grouping client-side. parameters: - name: has_unresolved in: query schema: { type: boolean } description: Only loans with at least one flagged document lacking a cleared disposition. - name: min_risk_score in: query schema: { type: integer, minimum: 0, maximum: 100 } - name: review_state in: query schema: { $ref: "#/components/schemas/LoanReviewState" } - name: assignee in: query schema: { type: string } - name: sort in: query schema: type: string enum: [-risk_score, risk_score, -updated_at, updated_at] default: -risk_score - name: limit in: query schema: { type: integer, default: 25, minimum: 1, maximum: 100 } - name: starting_after in: query schema: { type: string } description: Cursor — external_ref of the last item of the previous page. responses: "200": description: Paginated loan rollups. content: application/json: schema: type: object properties: object: { const: list } data: type: array items: { $ref: "#/components/schemas/LoanSummary" } has_more: { type: boolean } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /loans/{external_ref}/review: post: operationId: setLoanReviewState summary: Set loan-level triage state and assignee description: > Loan-level workflow state, distinct from the per-document disposition set by POST /documents/{id}/review. Lets a Fraud Review queue be worked without the client maintaining a parallel state store. `assignee` is an opaque string — your user id or email; we never resolve it to a person. Idempotent; a later call replaces the earlier state. parameters: - name: external_ref in: path required: true schema: { type: string } requestBody: required: true content: application/json: schema: type: object properties: review_state: { $ref: "#/components/schemas/LoanReviewState" } assignee: type: [string, "null"] maxLength: 128 description: > Null differs from omission: an explicit null unassigns the loan, omitting the key leaves the current assignee alone. note: type: [string, "null"] maxLength: 2000 description: > Same rule as assignee: an explicit null clears the note, omitting the key leaves the current note alone. responses: "200": description: Updated loan rollup. content: application/json: schema: { $ref: "#/components/schemas/LoanSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /loans/{external_ref}/expected-documents: put: operationId: setExpectedDocuments summary: Declare the expected document set for a loan description: > Turns "100% of documents were screened" from an unverifiable claim into a reconcilable one. Declare what the loan file is expected to contain; LoanSummary.coverage then reports screened against expected and names what is missing. Without this call, coverage.expected_total is null and coverage.complete is null — we can only attest to documents we received. Idempotent; a later call replaces the earlier declaration. parameters: - name: external_ref in: path required: true schema: { type: string } requestBody: required: true content: application/json: schema: type: object required: [expected_total] properties: expected_total: type: integer minimum: 0 description: Total documents the completed loan file should contain. items: type: array description: > Optional itemisation. When supplied, coverage.missing names the keys with no matching submission, so the audit report can list what was not screened rather than only counting. items: type: object required: [key] properties: key: { type: string, maxLength: 128, description: Your checklist item id. } name: { type: string } category: { type: string } document_id: { type: string, description: Set once submitted; or link via SubmitOptions.expected_key. } responses: "200": description: Updated loan rollup with recomputed coverage. content: application/json: schema: { $ref: "#/components/schemas/LoanSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "422": description: Declared expected_total is below the number already submitted. Code expected_total_below_submitted. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } delete: operationId: withdrawExpectedDocuments summary: Withdraw the expected document set for a loan description: > Takes the declaration back. coverage.expected_total, unscreened and complete become absent again and missing empties — the loan returns to "we can only attest to documents we received". Use this to retract a declaration made in error; PUT can only replace one with another, and expected_total: 0 asserts complete: true, a stronger claim than none. Idempotent; withdrawing from a loan with no declaration is a 200. parameters: - name: external_ref in: path required: true schema: { type: string } responses: "200": description: Updated loan rollup with coverage recomputed against no declaration. content: application/json: schema: { $ref: "#/components/schemas/LoanSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /loans/{external_ref}: get: operationId: getLoanSummary summary: Loan-level risk rollup description: > Aggregate view for a Fraud Review queue row — computed over every Document sharing this external_ref. parameters: - name: external_ref in: path required: true schema: { type: string } responses: "200": description: Loan rollup. content: application/json: schema: { $ref: "#/components/schemas/LoanSummary" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /audit-reports: post: operationId: createAuditReport summary: Generate a loan-level Fraud Audit Report (PDF) description: > Aggregates completed Documents into an exportable audit PDF — documents screened, findings, hashes, and math checks — suitable for retention in the loan record. Asynchronous; poll or receive audit_report.completed webhook. parameters: - name: Idempotency-Key in: header schema: { type: string, maxLength: 64 } description: > Safe-retry key, 64 characters at most — a longer value is rejected 400 invalid_request. A replay returns the stored report and carries Idempotency-Replayed: true rather than minting a second one. The key is bound to the request body that first carried it; the same key with a different body is refused 422 idempotency_key_reuse. requestBody: required: true content: application/json: schema: type: object properties: external_ref: type: string description: Include every completed Document with this ref. document_ids: type: array items: { type: string } description: Explicit document list (alternative to external_ref). format: type: string enum: [pdf, json, both] default: pdf description: > json/both populate findings, manifest and manifest_digest on the AuditReport. pdf alone keeps the 1.0 response shape. oneOf: - required: [external_ref] - required: [document_ids] responses: "202": description: Report generation started. headers: Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } content: application/json: schema: { $ref: "#/components/schemas/AuditReport" } "400": description: No completed documents matched the selection — nothing to report on. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /audit-reports/{id}: get: operationId: getAuditReport summary: Retrieve an audit report parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: Report status; download_url present when completed (it does not expire — see the download endpoint). content: application/json: schema: { $ref: "#/components/schemas/AuditReport" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /audit-reports/{id}/download: get: operationId: downloadAuditReport summary: Download the report file description: > The target of AuditReport.download_url. Authenticated like every other endpoint — send your key; the sig in the URL is an identifier, not an access grant. Served as application/pdf with Content-Disposition: attachment; filename=".pdf". Request it exactly as published: sig is required (400 invalid_request without it) and must name this report's file set (404 not_found otherwise). A report created with format json has no file — its download is 404; read findings, manifest and manifest_digest from GET /audit-reports/{id} instead. The same completed report downloads to the same bytes every time. parameters: - name: id in: path required: true schema: { type: string } - name: sig in: query required: true schema: { type: string } description: > The first 16 hex characters of manifest_digest, as minted into download_url. Derived from the document digests, so it is stable, non-secret and never expires; it names the file set rather than authorising the download. Missing → 400; wrong → 404. responses: "200": description: The report file (PDF), as an attachment. content: application/pdf: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/templates: get: operationId: listVerifitTemplates summary: List borrower-facing re-capture message presets description: > Presets that pass the audit-safe validator. `{document}` is the only interpolation token. Clients may send custom copy instead; it is validated identically. responses: "200": description: Available templates. content: application/json: schema: type: object properties: object: { const: list } data: type: array items: { $ref: "#/components/schemas/VerifitTemplate" } has_more: type: boolean description: Always false — the preset list is short and unpaginated. "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/links: post: operationId: createVerifitLink summary: Mint a single-use borrower re-capture link description: > Issues a short-lived, single-use URL (and QR) that opens a mobile capture page for one document. The borrower photographs the source document; the capture is C2PA-signed on device, analyzed as a new Document, and reconciled against the original submission. The message body is validated SERVER-SIDE against a tip-off term list before the link is minted. A match returns 422 with code message_would_disclose_flag and matched_terms, and NO link is created. This is the control, not a client-side convenience: the guarantee is that a borrower under review is never told they are under review. Override requires acknowledge_disclosure: true, which is recorded. parameters: - name: Idempotency-Key in: header schema: { type: string, maxLength: 64 } description: > Safe-retry key, 64 characters at most — a longer value is rejected 400 invalid_request. A replay returns the stored link and carries Idempotency-Replayed: true rather than minting a second one. The key is bound to the request body that first carried it; the same key with a different body is refused 422 idempotency_key_reuse. requestBody: required: true content: application/json: schema: type: object required: [document_id, message] properties: document_id: type: string description: The document to re-capture. Must be completed. borrower: type: object description: > In production a live re-capture needs a claimant identity: phone (E.164) or email is required for EVERY delivery, link_only included (400 invalid_request without one). The sandbox accepts a mint without a borrower. properties: display_name: { type: string, maxLength: 128 } phone: { type: string, description: E.164. Required when delivery = sms; in production, phone or email is required for every delivery. } email: { type: string, format: email, description: Required when delivery = email; in production, phone or email is required for every delivery. } locale: { type: string, description: BCP 47, e.g. en-US. Default en-US. } message: type: object required: [body] properties: template_id: { type: string, description: "One of the ids from GET /verifit/templates (quality, routine, missing). Recorded for your audit trail; body is always authoritative. Any other value is 404 not_found." } body: { type: string, maxLength: 1000 } delivery: type: string enum: [sms, email, link_only] default: link_only description: > sms requires borrower.phone; email requires borrower.email. link_only returns the URL and QR without contacting the borrower — no PII required. expires_in: type: string description: > ISO 8601 duration, default PT48H, maximum P7D. Accepted forms are PnD / PTnH / PTnHnM only, and the duration must be positive — P0D and PT0M are rejected 400 invalid_request, since a link that expires on creation can never be used. acknowledge_disclosure: type: boolean default: false description: > Bypass the audit-safe validator. Recorded on the link and in the audit report. Use only with documented lender approval. responses: "201": description: Link created. headers: Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } content: application/json: schema: { $ref: "#/components/schemas/VerifitLink" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: Document not completed, or an active link already exists for it. Codes not_reviewable, link_already_active. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "422": description: > Message would disclose the flag (code message_would_disclose_flag) — matched terms are returned and no link is minted. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/links/{id}: get: operationId: getVerifitLink summary: Retrieve a re-capture link description: > In production the link is backed by the live Verifit service and status reflects that service's own state, so a session consumed or abandoned out of band shows up on the next read. In the sandbox a deterministic fixture stands in for it. parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: The link. content: application/json: schema: { $ref: "#/components/schemas/VerifitLink" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/links/{id}/revoke: post: operationId: revokeVerifitLink summary: Revoke an unused re-capture link parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: Link revoked. content: application/json: schema: { $ref: "#/components/schemas/VerifitLink" } "401": { $ref: "#/components/responses/Unauthorized" } "409": description: Already consumed. Code link_already_consumed. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/links/{id}/simulate-capture: post: operationId: simulateVerifitCapture summary: "Sandbox only: simulate a completed borrower capture" description: > Available on the sandbox host only. Consumes a pending link and produces a completed capture end-to-end — the recaptured Document, the verifit.capture.completed webhook and GET /verifit/captures/{id} all behave as they would after a real borrower capture. Lets you integration-test the Verifit flow without a phone. Where the deployment is backed by the live Verifit service, the endpoint answers 409 sandbox_only and creates nothing. parameters: - name: id in: path required: true schema: { type: string } responses: "201": description: The simulated capture — 201, because it creates one. content: application/json: schema: { $ref: "#/components/schemas/VerifitCapture" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "409": description: > Link already used (code link_already_consumed), or this deployment is backed by the live Verifit service and has no simulator (code sandbox_only — "This endpoint is only available in the sandbox environment."). content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "410": description: Link expired (code link_expired) — mint a new one. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/links/{id}/qr.png: get: operationId: getVerifitLinkQr summary: QR code for the link description: > The target of VerifitLink.qr_png_url — a PNG of the QR encoding the link's url, for printing or for a desktop hand-off to the claimant's phone. Authenticated like every other endpoint. Served only while the link is pending: once it is revoked, expired or consumed the response is 404 not_found, the same as an unknown id — there is no claim page left to hand out. parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: The QR image. content: image/png: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/captures/{id}/c2pa: get: operationId: getVerifitCaptureManifest summary: Content Credentials for a capture description: > The target of VerifitCapture.c2pa.manifest_url. Returns the same object as VerifitCapture.c2pa, on its own, so an auditor can fetch the provenance without the reconciliation around it. Authenticated like every other endpoint. parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: The capture's C2PA manifest. content: application/json: schema: { $ref: "#/components/schemas/C2PAManifest" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } /verifit/captures/{id}: get: operationId: getVerifitCapture summary: Retrieve a completed re-capture and its reconciliation parameters: - name: id in: path required: true schema: { type: string } responses: "200": description: The capture. content: application/json: schema: { $ref: "#/components/schemas/VerifitCapture" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } "429": { $ref: "#/components/responses/RateLimited" } "500": { $ref: "#/components/responses/ServerError" } components: securitySchemes: bearerAuth: type: http scheme: bearer description: > Server-side only. Authorization: Bearer sk_live_… (production) or sk_test_… (sandbox). Never ship keys to browsers or mobile apps. apiKeyAuth: type: apiKey in: header name: X-API-Key description: > Equivalent to bearerAuth and accepted on every endpoint. Matches the header convention used elsewhere on api.scam.ai. Server-side only. Takes precedence when both headers are present. Keys must be shaped sk_live_/sk_test_; a malformed, unknown or wrong-environment key returns 401 invalid_api_key. schemas: SubmitOptions: type: object properties: external_ref: type: string maxLength: 128 description: Your loan/file identifier — groups documents for listing and audit reports. category: type: string enum: [bank_statement, paystub, tax_form, id_document, utility_bill, other] description: Optional hint; improves issuer-pipeline checks and cross-total parsing. models: type: array items: { type: string, enum: [eva-doc, docforge] } default: [eva-doc] description: eva-doc = PDF forensics; docforge = GenAI-image detection for photos/scans. webhook_url: type: string format: uri description: > Per-request override of the account-level webhook endpoint. 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: $ref: "#/components/schemas/Attribution" expected_key: type: string maxLength: 128 description: > Links this submission to a declared expected-document item, so coverage.missing resolves. See PUT /loans/{external_ref}/expected-documents. Verdict: type: string enum: [CLEAN, SUSPICIOUS, FORGED] description: > Routing semantics — CLEAN: no indicators fired, checks_passed enumerates what ran (typical scores 0–15). SUSPICIOUS: indicators fired but evidence is not conclusive — route to human review (typical 40–70). FORGED: conclusive tampering evidence — escalate (typical 75–100). Bands are observed, not contractual; branch on verdict, use risk_score for ordering/thresholds within a band. Severity: type: string enum: [high, medium] Document: type: object required: [id, object, status, created_at] properties: id: { type: string, description: "doc_…" } object: { const: document } status: type: string enum: [processing, completed, failed] description: > processing until a worker has scored the document. Malformed requests, unsupported types and oversize uploads are still rejected synchronously at submit with a 4xx — a PDF's page count is included when it can be read from the file: over `max_pages` is a 413 file_too_large at submit, before a worker ever sees it. When the count cannot be read there (encrypted, malformed), that same limit is still enforced, just later — after scoring, the document ends here as "failed" with file_too_large instead. A document that is accepted but cannot otherwise be scored also ends as "failed" with an `error` object and no verdict fields. A password-protected PDF is one of those: whether a file opens without a password is only knowable by trying, so it is never a rejection at submit — the document ends "failed" with file_encrypted. A PDF carrying only an OWNER password ("printing restricted") opens without one and is analysed normally. error: $ref: "#/components/schemas/DocumentError" external_ref: { type: string } category: { type: string } issuer: type: string description: > Detected document source for the list's secondary line, e.g. "Commerce Bank", "Capital One 360", "SAP Payroll import". OMITTED when not confidently identified; the current engine build does not emit it for live-scored documents. issue_date: type: string format: date description: > Date printed on the document (statement/pay date), OCR-extracted. OMITTED if not found; same current-build caveat as issuer. capture: type: string enum: [native_pdf, photo, scan] description: > Input modality. Currently assigned from the file type — PDFs are native_pdf, images are photo; scan is reserved. review: type: object description: Present after POST /documents/{id}/review. properties: disposition: { type: string, enum: [in_review, cleared, escalated, confirmed_fraud] } note: { type: string } reviewer: { type: string } reviewed_at: { type: string, format: date-time } attribution: $ref: "#/components/schemas/Attribution" description: Echoed from SubmitOptions. Never inferred. verifit_eligible: type: boolean description: > True when a borrower re-capture would add evidence — capture is photo or scan, or the verdict is SUSPICIOUS/FORGED on a document class the borrower can re-photograph. Drives the "Request re-capture" action; POST /verifit/links does not require it. verifit_capture_id: type: string description: Set when this document was produced BY a Verifit capture. verifi_eligible: type: boolean deprecated: true description: > Pre-rename name of `verifit_eligible` (the product was Verifi until 2026-09-09). Same value; emitted alongside it until 2026-12-31. verifi_capture_id: type: string deprecated: true description: > Pre-rename name of `verifit_capture_id`. Same value; emitted alongside it until 2026-12-31. file: type: object properties: name: { type: string } type: { type: string, enum: [pdf, png, jpg] } pages: type: integer description: Pages in the file. size_bytes: { type: integer } sha256: { type: string } md5: { type: string, description: Legacy convenience hash — do not use for integrity. } model: type: string description: > Engine that scored the document — pdf-native-forensics (document forensics) or docforge-v14 (image path). 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 — tolerate unknown values. model_version: type: string description: Model release that produced this verdict (e.g. "2.6") — the UI's version badge. verdict: { $ref: "#/components/schemas/Verdict" } risk_score: type: integer minimum: 0 maximum: 100 description: Weighted risk, higher = riskier. Thresholds are yours to choose; see integration guide. headline: type: string description: > One-line human summary, e.g. "ending balance digitally altered after initial save". OMITTED — the current engine build does not emit it for live-scored documents (LoanSummary.top_reason is absent whenever headline is). indicators: type: array items: { $ref: "#/components/schemas/Indicator" } description: Sorted high severity first — render in order. Empty when verdict is CLEAN. checks_passed: type: array items: { type: string } description: For CLEAN documents — the named checks that ran and passed. regions: type: array items: { $ref: "#/components/schemas/Region" } description: May be empty or OMITTED — the current engine build does not produce regions for every document. revisions: type: object description: > PDFs only — incremental-revision analysis. OMITTED rather than null when absent; the current engine build does not produce it for every document. properties: count: { type: integer } diffs: type: array items: type: object properties: from_revision: { type: integer } to_revision: { type: integer } before: { type: string } after: { type: string } math_checks: type: array items: { $ref: "#/components/schemas/MathCheck" } description: > Arithmetic examiner output — produced only for SUSPICIOUS/FORGED documents, and only failing checks are emitted. An empty array means no arithmetic contradiction was reported, not that totals were verified. metadata: type: array description: > Fact panel of up to 10 fixed rows — Format, Pages, File size, Producer, Creator, Created, Modified, Incremental revisions, Fonts embedded, XMP metadata. value is a display string ("1.4 MB", "All 7 embedded", "Not present"), not raw metadata. anomalous is reserved — the current engine build sets it false on every row. items: type: object properties: key: { type: string } value: { type: string } anomalous: { type: boolean } genai_heatmap: type: array items: { type: array, items: { type: number } } description: > docforge only — per-patch AI-generation likelihood rows, 0..1. OMITTED rather than null when absent; currently not produced for every document. created_at: { type: string, format: date-time } completed_at: { type: string, format: date-time } DocumentError: type: object description: > Present only when status is "failed". The same shape and codes as the error envelope, so one handler covers a synchronous rejection and an asynchronous failure. Codes seen here: unreadable_document, file_encrypted, file_too_large (page limit), file_not_retained, server_error (the scorer stayed unavailable through every retry). required: [type, code, message] properties: type: { type: string } code: { type: string } message: { type: string } doc_url: { type: string, format: uri } DocumentSummary: type: object description: Slim list-row representation — everything a document table renders, nothing more. properties: id: { type: string } object: { const: document_summary } status: { type: string, enum: [processing, completed, failed] } error: $ref: "#/components/schemas/DocumentError" external_ref: { type: string } name: { type: string, description: Original file name. } category: { type: string } issuer: { type: string } issue_date: { type: string, format: date } capture: { type: string, enum: [native_pdf, photo, scan] } verdict: { $ref: "#/components/schemas/Verdict" } risk_score: { type: integer } headline: { type: string } review_disposition: { type: string, enum: [in_review, cleared, escalated, confirmed_fraud] } created_at: { type: string, format: date-time } completed_at: { type: string, format: date-time } LoanSummary: type: object description: Aggregate rollup for a Fraud Review queue row. properties: object: { const: loan_summary } external_ref: { type: string } risk_score: type: integer description: Loan-level risk — max document score, weighted by unresolved flags. documents_total: { type: integer } documents_flagged: { type: integer, description: SUSPICIOUS or FORGED without a "cleared" disposition. } documents_processing: { type: integer } verdict_counts: type: object description: Per-verdict tallies for the loan banner ("31 clean · 2 forged · 1 suspicious"). properties: clean: { type: integer } suspicious: { type: integer } forged: { type: integer } models: type: array items: { type: string } description: Models used across this loan's documents (e.g. ["eva-doc","docforge"]). estimated_review_minutes_saved: type: integer description: > Estimated manual-review minutes saved on this loan ("41 min saved"). Heuristic — per-category review-time baselines minus time spent on flagged docs. Display-only; not an audit figure. top_reason: type: string description: Headline of the highest-risk unresolved document, e.g. "ending balance digitally altered after initial save". flagged: type: array description: The flagged summaries, highest risk first — expands a queue row without a second call. items: { $ref: "#/components/schemas/DocumentSummary" } coverage: $ref: "#/components/schemas/Coverage" flagged_amount: $ref: "#/components/schemas/FlaggedAmount" description: Absent — not null — until some document in the loan carries an attribution. review_state: $ref: "#/components/schemas/LoanReviewState" assignee: type: string description: > Opaque client-supplied identifier. Never resolved to a person by us. Absent while the loan is unassigned. review_note: { type: string, description: Absent until a review note is recorded. } reviewed_at: { type: string, format: date-time, description: Absent until the loan is reviewed. } updated_at: { type: string, format: date-time } LoanReviewState: type: string enum: [open, in_review, cleared, escalated] description: > Loan-level triage state for a Fraud Review queue. Distinct from the per-document disposition. Defaults to open once any document in the loan is flagged. Coverage: type: object description: > Screened against expected. expected_total, unscreened and complete are ABSENT — not null — until the expected set is declared via PUT /loans/{external_ref}/expected-documents; we cannot attest to documents we never received. Test for the key, not for null, and never read an absent complete as true. properties: expected_total: type: integer description: Present only once the expected set has been declared. submitted: { type: integer } screened: { type: integer, description: Documents in status completed. } processing: { type: integer } failed: { type: integer } unscreened: type: integer description: max(0, expected_total − screened). Absent whenever expected_total is. complete: type: boolean description: screened == expected_total and failed == 0. Absent whenever expected_total is. missing: type: array description: Declared items with no completed submission. Populated only when items were itemised. items: type: object properties: key: { type: string } name: { type: string } category: { type: string } Attribution: type: object description: > What figure in the loan file this document substantiates. Supplied by the client on submit and echoed back; we do not infer it. Aggregated into LoanSummary.flagged_amount so a UI can say how many dollars of verified assets rest on flagged documents. section is the only required member; currency defaults to USD and amount_minor to 0, so an attribution naming a section alone is valid. Every attributed document on one loan must agree on currency — a second currency is rejected at submit with 422 attribution_currency_conflict, because a rollup that cannot add is worse than a submission that fails. required: [section] properties: section: type: string enum: [asset, income, liability, property, identity, other] description: Required. field: { type: string, maxLength: 128, description: "Your field id, e.g. total_verified_assets." } currency: { type: string, default: USD, description: ISO 4217, e.g. USD. Defaults to USD. } amount_minor: type: integer default: 0 description: > Amount in minor units (cents) — integer, never a float, so sums are exact. 4521000 = $45,210.00. Defaults to 0. FlaggedAmount: type: object description: > Sum of Attribution.amount_minor over documents in this loan whose verdict is SUSPICIOUS or FORGED and whose disposition is not cleared. Absent when no submitted document carried an attribution. properties: currency: { type: string } amount_minor: { type: integer } basis: { type: string, description: Human-readable description of what was summed. } contributing_document_ids: type: array items: { type: string } Indicator: type: object required: [code, title, severity] properties: code: type: string description: > Stable machine key — the contract surface. Live-scored documents carry the engine's snake_case reason codes, e.g. touch_up_text_edit, has_piece_info, font_character_remapping, create_date_mismatch, signature_invalid (~90 codes). The UPPERCASE codes in the example (TOUCHUP_TEXTEDIT, REVISION_TEXT_DIFF, CROSS_TOTAL_MISMATCH, …) are illustrative. Open enum either way — render unknown codes generically from title/severity; never fail on them and never branch on a closed code list. title: { type: string } severity: { $ref: "#/components/schemas/Severity" } what_it_is: { type: string, description: Display copy — may change without notice. } what_it_means: { type: string, description: Display copy — may change without notice. } how_detected: { type: string, description: Display copy — may change without notice. } Region: type: object description: > Flagged (tampered/suspect) area for viewer overlays. Coordinate system — origin at the page's top-left; x and w are percentages (0–100) of rendered page width, y and h percentages of rendered page height. Resolution-independent: multiply by your rendered page's pixel dimensions to draw the box. properties: page: { type: integer, minimum: 1 } x: { type: number } y: { type: number } w: { type: number } h: { type: number } confidence: { type: number, minimum: 0, maximum: 1 } severity: { $ref: "#/components/schemas/Severity" } label: { type: string } indicator_code: type: string description: Links the region to the indicator that produced it. MathCheck: type: object description: One OCR'd-and-recomputed arithmetic assertion. properties: formula: { type: string, description: "Plain-language summary of the failed assertion, e.g. \"line items do not sum to the stated total\"." } computed: { type: string, description: Best-effort extraction — may be an empty string. } stated: { type: string, description: Best-effort extraction — may be an empty string. } ok: { type: boolean, description: Always false when present — passing checks are not emitted. } note: { type: string, description: "e.g. Δ $40,000.00 appears nowhere in the transaction detail" } AuditReport: type: object properties: id: { type: string, description: "rpt_…" } object: { const: audit_report } status: { type: string, enum: [processing, completed, failed] } external_ref: { type: string } documents_screened: { type: integer } documents_flagged: { type: integer } pages: type: integer description: Page count of the rendered PDF, measured from the file the download serves. download_url: type: string format: uri description: > Points at GET /audit-reports/{id}/download, carrying sig — the first 16 hex characters of manifest_digest. It does NOT expire and it is not an access grant: the download is authenticated like every other endpoint. Present once status is completed and format includes pdf. created_at: { type: string, format: date-time } generated_at: { type: string, format: date-time } models: type: array items: type: object properties: name: { type: string } version: { type: string } coverage: $ref: "#/components/schemas/Coverage" description: Present only on reports created with external_ref — a document_ids selection has no declared set to attest against. findings: type: array description: Present when format includes json. Mirrors the PDF's findings section exactly. items: { $ref: "#/components/schemas/ReportFinding" } manifest: type: array description: > Every document in the report, including those with no findings — the coverage attestation. Present when format includes json. items: { $ref: "#/components/schemas/ManifestEntry" } manifest_digest: type: string description: > Lowercase hex SHA-256 over the loan's file set — the value a loan buyer recomputes to prove the manifest was not edited. Deterministic and independent of report generation time. manifest_digest_algorithm: type: string const: sha256-of-sorted-sha256-newline-joined description: > Exact construction, so it is independently recomputable: take each document's lowercase-hex sha256, sort the list lexicographically, join with "\n" (no trailing newline), UTF-8 encode, SHA-256, lowercase hex. Regenerating a report over the same file set with the same model versions reproduces the same digest. ManifestEntry: type: object properties: document_id: { type: string } name: { type: string } category: { type: string } verdict: { $ref: "#/components/schemas/Verdict" } risk_score: { type: integer } sha256: { type: string } ReportFinding: type: object description: One flagged document, carrying exactly the evidence the Document object carried — nothing is re-derived for the report. properties: document_id: { type: string } name: { type: string } verdict: { $ref: "#/components/schemas/Verdict" } risk_score: { type: integer } headline: { type: string } indicators: type: array items: { $ref: "#/components/schemas/Indicator" } math_checks: type: array items: { $ref: "#/components/schemas/MathCheck" } metadata_anomalies: type: array description: The metadata rows where anomalous is true. items: type: object properties: key: { type: string } value: { type: string } review_disposition: { type: string, enum: [in_review, cleared, escalated, confirmed_fraud] } VerifitTemplate: type: object properties: id: { type: string, description: "e.g. quality, routine, missing" } label: { type: string } body: { type: string, description: "Contains the {document} token." } MessageAudit: type: object description: Result of the server-side audit-safe validation of the borrower message. properties: safe: type: boolean description: False only when acknowledge_disclosure was used to override. matched_terms: type: array items: { type: string } description: Tip-off terms found in the body. Empty on a safe message. acknowledged_disclosure: { type: boolean } validated_at: { type: string, format: date-time } VerifitLink: type: object properties: id: { type: string, description: "vfl_…" } object: { const: verifit_link } status: type: string enum: [pending, consumed, expired, revoked] document_id: { type: string, description: The document being re-captured. } external_ref: { type: string, description: Inherited from the document. } url: type: string format: uri description: > Single-use claimant capture page — https://verifit.scam.ai/claim/. In production it is served by the live Verifit service; in the sandbox a deterministic fixture stands in for it. qr_png_url: type: string format: uri description: > GET /verifit/links/{id}/qr.png — a PNG of the QR encoding `url`. Authenticated like every other endpoint. delivery: { type: string, enum: [sms, email, link_only] } message: type: object properties: template_id: { type: string } body: { type: string } message_audit: { $ref: "#/components/schemas/MessageAudit" } capture_id: type: string description: Set once the borrower completes a capture. expires_at: { type: string, format: date-time } created_at: { type: string, format: date-time } VerifitSignal: type: object properties: label: { type: string } value: { type: string, description: Display copy — may be reworded without notice. } status: { type: string, enum: [pass, flag, unavailable] } VerifitSignalGroup: type: object properties: id: type: string enum: [provenance, device_integrity, forensics_rerun, reconciliation] description: Stable group ids. Signals within a group are additive. title: { type: string } blurb: { type: string } signals: type: array items: { $ref: "#/components/schemas/VerifitSignal" } C2PAManifest: type: object description: Content Credentials attached at capture time, before the bytes moved. properties: spec_version: { type: string, description: "e.g. 2.3" } validation_state: type: string enum: [Trusted, Valid, Invalid, Unverified] description: C2PA's own vocabulary, so an auditor can check it against the spec. signature_alg: { type: string, description: "e.g. ES256" } timestamp_authority: { type: string, description: "e.g. RFC3161" } edits_since_capture: { type: integer, description: Manifest actions after the shutter fired. 0 = untouched. } manifest_url: type: string format: uri description: > GET /verifit/captures/{id}/c2pa — this same object on its own. Authenticated like every other endpoint. VerifitCapture: type: object properties: id: { type: string, description: "vfc_…" } object: { const: verifit_capture } status: { type: string, enum: [processing, completed, failed] } link_id: { type: string } document_id: { type: string, description: The ORIGINAL submitted document. } recaptured_document_id: type: string description: > The new Document created from the capture, analyzed by the normal pipeline. Fetch it with GET /documents/{id} for verdict, indicators and regions — a capture is not a special case downstream. external_ref: { type: string } captured_at: { type: string, format: date-time } response_time: { type: string, description: ISO 8601 duration from link creation to capture. } device: { type: string, description: Display copy, e.g. "iPhone 15 Pro · rear camera". } photo_count: { type: integer } headline: { type: string, description: One-line human summary of the reconciliation outcome. } c2pa: { $ref: "#/components/schemas/C2PAManifest" } signal_groups: type: array items: { $ref: "#/components/schemas/VerifitSignalGroup" } contradicts_original: type: boolean description: > True when any signal group contains a flag — the capture disagrees with the submitted document. This is the field that changes an underwriting decision; branch on it, not on the display copy. created_at: { type: string, format: date-time } ErrorEnvelope: type: object description: > The wire shape of EVERY error response: one `error` member, nothing at the top level. Context keys are added INSIDE it — matched_terms on message_would_disclose_flag, for example — so read them from body.error, never from body. required: [error] properties: error: { $ref: "#/components/schemas/Error" } Error: type: object description: The contents of ErrorEnvelope.error. Never returned on its own. required: [type, code, message, doc_url] properties: type: type: string enum: [invalid_request, authentication, permission, not_found, conflict, rate_limited, file_error, processing_error, server_error] code: type: string description: > Stable machine key, e.g. file_encrypted, file_too_large (also raised for page-count overflow), unsupported_type, url_fetch_failed (the file_url could not be fetched — see POST /documents), unreadable_document (422, type processing_error — the bytes could not be read as a document; see POST /documents), file_not_retained, not_reviewable, missing_file, invalid_option, malformed_json, invalid_api_key, browser_origin_forbidden, not_found (404, type not_found — no object with that id in this environment), rate_limited, server_error. Registered in 1.1: message_would_disclose_flag (422), link_already_active (409), link_already_consumed (409), link_expired (410, type conflict), expected_total_below_submitted (422), attribution_currency_conflict (422, type invalid_request), idempotency_key_reuse (422, type invalid_request — an Idempotency-Key presented again with a different payload), sandbox_only (409, type conflict — a sandbox-only endpoint called against a live-backed deployment), capture_not_ready (409, type conflict: the capture exists but its evidence is still being produced). Registered in the 2026-09-15 error audit: request_too_large (413, type invalid_request — the JSON request body itself, not a file, is over 1 MB), method_not_allowed (405, type invalid_request — a known route called with a verb it does not answer to; the response carries an Allow header naming what would work), and analysis_in_progress (409, type conflict — POST /documents/{id}/reprocess called while the document is still queued or processing; wait for status completed or failed, then reprocess). Registered in 1.2: source_file_required (400, type invalid_request — a reprocess called without the original document). Open enum — branch on known codes, fall back to type. message: { type: string } doc_url: { type: string, format: uri, description: Always present. } additionalProperties: true WebhookEvent: type: object description: > Delivered as POST to your endpoint. Signed: X-ScamAI-Signature is "t=,v1=.` with your signing secret>". Reject if |now − t| > 300s. Retried on any non-2xx, a connection failure or no response within 5 s: 1m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 8h after each failure (ten attempts over ~24h), then abandoned. Each attempt re-signs the same body and carries X-ScamAI-Event-Id, X-ScamAI-Event-Type and X-ScamAI-Delivery-Attempt. Events may arrive out of order and at-least-once — treat handlers as idempotent (key on event id). Requests carry User-Agent ScamAI-CheckReality-Webhooks/1.1. Current build emits document.completed, document.failed, audit_report.completed, verifit.capture.completed and loan.coverage_complete; audit_report.failed and verifit.link.expired are defined for forward compatibility. Treat the webhook as a nudge and GET the object for its current state. properties: id: { type: string, description: "evt_…" } type: type: string enum: - document.completed - document.failed - audit_report.completed - audit_report.failed - verifit.capture.completed - verifit.capture.failed - verifit.link.expired - loan.coverage_complete created_at: { type: string, format: date-time } data: description: The full Document, AuditReport, VerifitCapture, VerifitLink or LoanSummary object. oneOf: - $ref: "#/components/schemas/Document" - $ref: "#/components/schemas/AuditReport" - $ref: "#/components/schemas/VerifitCapture" - $ref: "#/components/schemas/VerifitLink" - $ref: "#/components/schemas/LoanSummary" headers: IdempotencyReplayed: description: > Present and "true" when this response is the stored reply to an earlier request carrying the same Idempotency-Key — nothing new was created. schema: { type: string, const: "true" } responses: BadRequest: description: Malformed request. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } Unauthorized: description: Missing/invalid API key, or key used from a browser origin. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } NotFound: description: No such resource in this environment (sandbox and production ids are disjoint). content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } TooLarge: description: > File exceeds 200 MiB or 200 pages (code file_too_large, whether uploaded or fetched from file_url), or the JSON request body itself exceeds 1 MB (code request_too_large). The page count is read from a PDF at submit when it can be parsed, so an over-limit PDF answers this 413 synchronously, on the submitting request, before any analysis runs — not just as a possible later `document.failed`. When the count cannot be read (encrypted, malformed), submit cannot refuse it here; the document is accepted and the same limit is enforced after scoring instead, ending the document `failed` with file_too_large rather than answering this response. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } Unsupported: description: > Not a PDF/PNG/JPG (code unsupported_type). A password-protected PDF is NOT rejected here: encryption is only discoverable by opening the file, which happens during analysis, so it is reported on the document — status "failed", error code file_encrypted. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } RateLimited: description: > Over rate limit. Each key is a token bucket: 600 requests per minute sustained with a burst of 1,200 — a key that has been idle can send 1,200 requests at once, then 10 per second. X-RateLimit-Limit reports the BURST capacity, X-RateLimit-Remaining the requests left in the bucket, and X-RateLimit-Reset the seconds until it refills (until the next request is possible, on a 429). Honor Retry-After. Every response additionally carries X-CheckReality-Env and X-Response-Time-Ms. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } ServerError: description: > Something failed on our side (code server_error). Retry with backoff and the same Idempotency-Key. content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } ```