# 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: </v1/verifit>; rel="successor-version"` — until 2026-12-31. Claimant links now open on `https://verifit.scam.ai/claim/<token>`; 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/<token>`.
- `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=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" 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="<report id>.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.
