{
  "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.\n\n1.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.\n\nAbsent 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.\n\nChanges 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.\n",
    "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.\n",
        "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.\n"
          },
          {
            "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.\n"
          }
        ],
        "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.\n"
                  },
                  "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.\n"
                  },
                  "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.\n",
            "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.\n",
            "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.\n",
        "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.\n",
        "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.\n",
            "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.\n",
            "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.\n",
        "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.\n"
                  },
                  "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.\n",
        "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.\n",
        "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.\n"
                  },
                  "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.\n"
                  }
                }
              }
            }
          }
        },
        "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.\n",
        "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.\n",
                    "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.\n",
        "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.\n",
        "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.\n",
        "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.\n"
          }
        ],
        "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.\n"
                  }
                },
                "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=\"<report id>.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.\n",
        "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.\n"
          }
        ],
        "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.\n",
        "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.\nThe 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.\n",
        "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.\n"
          }
        ],
        "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.\n",
                    "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.\n"
                  },
                  "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.\n"
                  },
                  "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.\n"
                  }
                }
              }
            }
          }
        },
        "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.\n",
            "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.\n",
        "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.\n",
        "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.\").\n",
            "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.\n",
        "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.\n",
        "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.\n"
      },
      "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.\n"
      }
    },
    "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.\n"
          },
          "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.\n"
          }
        }
      },
      "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.\n"
      },
      "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.\n"
          },
          "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.\n"
          },
          "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.\n"
          },
          "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.\n"
          },
          "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.\n"
          },
          "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.\n"
          },
          "verifi_capture_id": {
            "type": "string",
            "deprecated": true,
            "description": "Pre-rename name of `verifit_capture_id`. Same value; emitted alongside it until 2026-12-31.\n"
          },
          "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.\n"
          },
          "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).\n"
          },
          "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.\n",
            "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.\n"
          },
          "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.\n",
            "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.\n"
          },
          "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).\n",
        "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.\n"
          },
          "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.\n"
          },
          "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.\n"
      },
      "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.\n",
        "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.\nsection 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.\n",
        "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.\n"
          }
        }
      },
      "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.\n",
        "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.\n"
          },
          "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.\n",
        "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.\n"
          },
          "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.\n",
            "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.\n"
          },
          "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.\n"
          }
        }
      },
      "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/<token>. In production it is served by the live Verifit service; in the sandbox a deterministic fixture stands in for it.\n"
          },
          "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.\n"
          },
          "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.\n"
          }
        }
      },
      "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.\n"
          },
          "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.\n"
          },
          "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.\n",
        "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.\n"
          },
          "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=<unix>,v1=<hex HMAC-SHA256 of `<t>.<raw body>` 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.\n",
        "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.\n",
        "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.\n",
        "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.\n",
        "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.\n",
        "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.\n",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            }
          }
        }
      }
    }
  }
}