{
  "openapi": "3.0.0",
  "info": {
    "title": "Tamperlens API",
    "description": "Document Trust API — deterministic PDF fraud-signal inspection. We report risk signals, never verdicts.",
    "version": "1.0.0"
  },
  "components": {
    "schemas": {}
  },
  "paths": {
    "/api/v1/inspect": {
      "post": {
        "summary": "Inspect a document or image for fraud/tamper signals",
        "tags": [
          "Inspect"
        ],
        "description": "Accepts multipart/form-data (field \"file\") or a raw body, up to the configured size cap. Returns an InspectionReport. Uploaded bytes are processed in memory and never written to disk.\n\n**Accepted formats.** PDF; Office Open XML documents (`.docx`, `.xlsx`, `.pptx` and their macro-enabled twins); plus JPEG, PNG, WebP, HEIC/HEIF and AVIF images. TIFF, GIF and BMP are recognised and reported but their containers are not walked. Such a report carries an `image-structure-anomalies` signal saying so, because an empty result must never be mistaken for a clean one. The format is determined by **sniffing the bytes**, not from the `Content-Type` header or a filename, so a mislabelled upload is still analysed correctly.\n\n**Image reports** set `mediaType: \"image\"` and add an `image` block (format, dimensions, camera make/model, metadata containers, JPEG quality, encoder fingerprint). `document` is still populated so existing integrations keep working: `producer` becomes the writing software and `creator` the capture device. The nine `image-`prefixed signal families read container structure and metadata, **no pixels are decoded**, so this is not error-level analysis or noise-residual forensics.\n\n**Office reports** set `mediaType: \"office\"` and add an `office` block (format, application and version, who saved it last, save count, editing minutes, part count, macro presence). `document` is still populated so existing integrations keep working: `producer` becomes the writing application, `creator` the recorded author, and `revisions` the `cp:revision` save counter. Eight `office-`prefixed families read the package's XML and structure. Unaccepted tracked changes (whose deleted text is recovered and returned, the Word analogue of text under a redaction box), hidden text, hidden and very-hidden sheets, hidden slides, embedded comments, macros and remote templates, author/last-editor disagreement, and timestamp anomalies. **The document is not rendered**, so text hidden by being coloured to match its background or covered by a shape is NOT detected; every report discloses that blind spot rather than leaving it to be inferred.\n\n**Prompt-injection markers, and their untrusted payload.** `document-injection-markers` (PDF) and `office-injection-markers` (OOXML) report text placed in a document to be read by a *language model* rather than by a person, in Info-dictionary values, XMP text nodes, annotation `/Contents`, embedded-file names and `/Desc`, `docProps/*`, Word comments and hidden (`w:vanish`) runs. Concealed PAGE text is NOT covered: an OCR layer is invisible text too, and that needs discriminators this engine does not ship yet.\n\nThe recovered text is **attacker-authored**. It appears only under `evidence.fields[].payload` and `evidence.fields[].name`, sanitised and truncated, on a signal carrying `evidence.payloadIsUntrusted: true` — never in `title`, `detail` or `message.params`, which name the *kind* of field from a fixed list. **Do not put it in a model prompt without neutralising it.** Add `?redact=payload` to receive the finding with those two keys replaced by `\"[redacted]\"` and `evidence.payloadRedacted: true` set, so \"nothing was found\" stays distinguishable from \"you were not given it\". The same flag and elision cover `evidence.recovered` and `evidence.authors` on `office-hidden-content` and `office-tracked-changes` since engine 1.30.0: comment text and deleted text are document-authored too. Everything else — the score, the band, the cue categories, every count, and a verdict policy, which is evaluated on the unredacted report — is unchanged. Any other `redact` value is ignored rather than rejected. The MCP server applies this elision unconditionally: an MCP tool result is a model's context.\n\n**What each revision of a PDF did (engine 1.32.0).** A PDF that was saved more than once keeps every earlier version's bytes, and the report describes the chain revision by revision rather than only in total. Two blocks carry it, both 1-based (`revision: 1` is the original save):\n\n- `incremental-updates.evidence.changedPerRevision[]` — for each appended revision: `xrefOffset`, the object numbers it wrote (`objectsWritten`), which of those replaced an object an earlier revision had written (`objectsOverwritten`, each with a `kind` of `page` | `content` | `image` | `annotation` | `other`), and `touched`.\n- `signature-permissions.evidence.revisionsAssessed[]` — present when the document carries a **certification** signature: for each revision appended after it, the same `touched` counters plus that revision's own `assessment` against `/DocMDP /P` (`content` | `structure` | `annotation` | `opaque` | `ltv` | `permitted`). This is what names **which** append broke the certification, where the signal's document-level `assessment` says only that one did.\n\n`touched` counts the objects that revision wrote, by kind. **Seven of the eight keys partition them** — `pageContent`, `images`, `pages`, `widgetAnnotations`, `otherAnnotations`, `signatures`, `other` — and sum to `objectsWrittenTotal`. The eighth, `pageImages`, is a **subset of `images`** and not a bucket beside it: it is the count a **page** actually names, so totalling all eight double-counts every page-drawn image. That narrowing is what keeps a permitted form fill-in from reading as a repainted page — a regenerated appearance stream and the graphic inside a signature widget are not page-reachable, so they arrive as `widgetAnnotations` and `images` with `pageContent: 0` and `pageImages: 0`. Both lists are capped like every array in a report; the companion `*Total`/`*Truncated` keys say when what you received is a sample.\n\n**This is not a diff, and must not be presented as one.** These are counts of PDF objects by kind. Tamperlens does not render two revisions and compare them: \"revision 3 rewrote one page content stream and added two form fields\" is what is known, and \"revision 3 changed the amount\" is not. A revision that only ADDS objects has an empty `objectsOverwritten` and a non-zero `touched` — the two answer different questions and neither one substitutes for the other.\n\n**Optional issuer baseline.** Name the institution the document claims to come from and it is additionally compared against a structural profile built from genuine documents of that issuer — which is what catches a file that was fabricated cleanly rather than edited. Two spellings:\n\n- multipart: an `issuer` field, sent **before** the file part;\n- raw body: an `X-Tamperlens-Issuer` header.\n\nThe slug must match `^[a-z0-9][a-z0-9-]{0,63}$`; anything else is 422 `invalid_issuer`. An **unknown** slug is not an error: the normal report is returned with `issuerBaseline: {requested, resolved: false, reason: \"no_profile\"}`, so a caller can distinguish \"matched\" from \"nothing to compare against\". When a profile resolves, `issuerBaseline` reports which tier answered (the account's own profile, else a built-in public specimen), its sample count and the divergence; a confident deviation also raises an `issuer-mismatch` signal. Omit `issuer` and the response is exactly what it has always been — the key is not present at all. Issuer baselines are a PDF feature; naming one alongside an image returns `issuerBaseline: {resolved: false, reason: \"not_applicable\"}`.\n\n**Optional verdict policy.** Tamperlens reports risk signals, never verdicts — the engine cannot know whether an appended revision is a forgery or your payroll provider, because that depends on your population and what a false accusation costs you. You can, so you can send the rule and get it back applied. Two spellings, same as `issuer`: a multipart `policy` field sent **before** the file part, or an `X-Tamperlens-Policy` header. Both carry a JSON object:\n\n```json\n{ \"review\": 30, \"reject\": 70,\n  \"rejectOn\": [\"redaction-exposure\"],\n  \"reviewOn\": [\"incremental-updates\"] }\n```\n\n`review` and `reject` are score cut-points (defaults 30 and 70, matching the engine's own bands). `rejectOn`/`reviewOn` name signal families that force a verdict **whatever the score says** — a rule beats a threshold, and `rejectOn` beats `reviewOn`, because a caller naming a family means it matters regardless of the arithmetic. Only signals above `info` severity can trigger a rule.\n\nThe response gains a `policy` block: `{verdict, riskScore, thresholds, reason, triggeredBy}` where `verdict` is `accept` | `review` | `reject` and `reason` is `score` or `signal`. **Nothing about the report changes** — not the score, not the band, not a single signal. The verdict is yours, computed from your numbers, and labelled as such.\n\nUnknown signal ids in `rejectOn`/`reviewOn` are accepted and simply never match, so a family we rename cannot take your pipeline down. A **malformed** policy is 422 `invalid_policy`, because silently falling back to our defaults would mean a typo leaves you believing a rule is enforced when it is not. Omit `policy` and the response is exactly what it has always been — the key is not present at all.\n\n**Measured I/O cost (engine 1.34.0).** Every report carries `summary.cost`: `{physicalBytes, expandedBytes, expansionRatio}`. `physicalBytes` is the file size; `expandedBytes` is the decompressed output the parse produced (content, object and cross-reference streams); `expansionRatio` is the second over the first, rounded to 3 decimals. Both counts are deterministic, so they ride in the byte-identical body. Wall-clock time is NOT deterministic and is not in the body by default; add `?timing=1` to receive a separate `timing: {elapsedMs}` block.\n\n**Cheap triage (`?mode=triage`, engine 1.34.0).** For an agent deciding whether a document is worth ingesting at all. A PDF is analysed for structure, metadata, signatures and presence flags only — the per-page content walk is skipped — for a fraction of the decompression cost (measured ~9% of a full parse on the phase-0 corpus). The response is an ordinary report plus a `triage` block `{contentAnalysed: false, suppressedFamilies, guidance}`. **A quiet triage is not a clean document**: the eight page-content families in `suppressedFamilies` (hidden text, redaction failure, altered arithmetic, glyph tampering, embedded-image anomalies, certification breaks) did not run. Use it to decide whether to run a full inspection, never as the full inspection. Office and image formats have no separate expensive walk, so they return their ordinary report; `issuer` is ignored in triage.\n\n**Usage caution.** Every report carries a constant `usageCaution` block: `{safeForAutomatedAdverseDecision: false, guidance}`. It is the machine-readable form of the disclaimer, for integrations that gate on a field instead of parsing prose. It is `false` on every report, including a clean one — absence of signals is not proof of authenticity, and no report is safe as the sole basis for an automated adverse decision.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {}
            },
            "application/pdf": {
              "schema": {}
            },
            "image/jpeg": {
              "schema": {}
            },
            "image/png": {
              "schema": {}
            },
            "image/webp": {
              "schema": {}
            },
            "image/gif": {
              "schema": {}
            },
            "image/tiff": {
              "schema": {}
            },
            "image/heic": {
              "schema": {}
            },
            "image/heif": {
              "schema": {}
            },
            "image/avif": {
              "schema": {}
            },
            "image/bmp": {
              "schema": {}
            },
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
              "schema": {}
            },
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
              "schema": {}
            },
            "application/vnd.openxmlformats-officedocument.presentationml.presentation": {
              "schema": {}
            },
            "application/vnd.ms-word.document.macroEnabled.12": {
              "schema": {}
            },
            "application/vnd.ms-excel.sheet.macroEnabled.12": {
              "schema": {}
            },
            "application/vnd.ms-powerpoint.presentation.macroEnabled.12": {
              "schema": {}
            },
            "application/zip": {
              "schema": {}
            },
            "application/octet-stream": {
              "schema": {}
            }
          }
        },
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "redact",
            "required": false,
            "description": "Set to `payload` to receive injection findings with the attacker-authored text elided (`evidence.payloadRedacted: true`). Any other value is ignored."
          },
          {
            "schema": {
              "type": "string",
              "enum": [
                "triage"
              ]
            },
            "in": "query",
            "name": "mode",
            "required": false,
            "description": "Set to `triage` for the cheap tier (engine 1.34.0): a PDF is analysed for structure, metadata, signatures and presence flags only — no per-page content walk — at a fraction of the decompression cost. The response carries a `triage` disclosure block. A quiet triage is NOT a clean document: hidden text, redaction failure, altered arithmetic, glyph tampering and certification breaks are not checked in this scope. Office and image formats have no expensive walk to skip, so they return their ordinary report. `issuer` is ignored in triage."
          },
          {
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "in": "query",
            "name": "timing",
            "required": false,
            "description": "Set to `1` to add a `timing: { elapsedMs }` block. Off by default because wall-clock is not deterministic and the report body is byte-identical; the deterministic cost counters live in `summary.cost` and are always present."
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/compare": {
      "post": {
        "summary": "Compare a candidate PDF against the original it claims to be",
        "tags": [
          "Inspect"
        ],
        "description": "Accepts multipart/form-data with two file fields: `original` (the reference: the file you generated or hold on record) and `candidate` (the file being verified). Returns a CompareReport: byte identity, revision ancestry and structural difference, as facts.\n\n**The three tiers, strongest first.**\n\n1. `relationship: \"identical\"`: the files hash equal. The only result that is a proof.\n2. `\"candidate-extends-original\"` / `\"candidate-truncates-original\"`: one file contains the other verbatim up to a revision boundary. PDF edits-in-place append rather than rewrite, so the appended revisions ARE the edit; the report classifies the objects they touch (`ancestry.contentTouched` is the headline fact: page/content/image objects were replaced after the shorter file was complete).\n3. `\"rewritten\"`: neither contains the other. The report degrades honestly to parsed structure: Info/XMP metadata field by field, the /ID pair (permanent half vs per-save half), fonts added/removed, page count and page geometry.\n\n**What it cannot do.** No pages are rendered, so no result asserts the rendered appearance is identical. A `\"rewritten\"` pair with clean structure is still only structurally clean. Findings are facts with severities, never verdicts.\n\n**Cost.** One comparison parses two documents and is metered as **2 documents** against the monthly quota. Requires an API key (or a dashboard session); there is no anonymous access to this endpoint.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/metadata": {
      "post": {
        "summary": "List every piece of metadata a document or image carries",
        "tags": [
          "Inspect"
        ],
        "description": "Accepts multipart/form-data with a `file` field, or a raw body. Returns a MetadataReport: every key the file carries, grouped by the container it came from, with the raw stored form beside the decoded one.\n\n**What you get, by medium.**\n\n- **PDF**: the complete Info dictionary (not just the five fields the signal families read: `Author`, `Subject`, `Keywords` and any custom keys a pipeline invented are all listed), every XMP property, and plain structural facts (pages, revisions, encryption, signatures, fonts).\n- **Images** (EXIF split by directory (IFD0, Exif, GPS, IFD1), the thumbnail's own metadata stays distinguishable from the photograph's), decoded GPS coordinates, PNG text chunks, XMP, and the containers present but not enumerated (IPTC, ICC, MakerNote, C2PA) named rather than silently omitted. JPEG, PNG, WebP, HEIC/HEIF and AVIF are all read the same way: a HEIC off an iPhone carries the same EXIF a JPEG does, in a different container.\n- **Office**: core properties, application properties, custom properties, and **the EXIF of every image embedded in the package**. A photograph pasted into a `.docx` keeps the coordinates the camera wrote; that is usually the most surprising thing in the report.\n\n**`personal`** collects the entries that identify a person, a device or a place, names, serial numbers, software, locations, as a copy, so the full grouped list stays complete. **`gps`** repeats a decoded coordinate pair as numbers, so a client need not parse its own report back into them.\n\n**Each entry carries up to three forms of one fact.** `value` is display-ready and always English; `raw` is what the file stores, present only when decoding changed it (an EXIF rational, a PDF `D:` date); and `code` appears when `value` is a decoded ENUMERATION rather than data. `exif.Flash.16` for a `Flash` of 16, which reads \"Did not fire, flash suppressed\". The code is a translation key, the same shape `signals[].message` uses: `value` stays byte-identical for every consumer that ignores it, and a client with a dictionary can render another language. An entry with no `code` is data out of the file and must not be translated.\n\n**This is not an inspection.** There are no signals, no risk score and no verdict here; use `POST /inspect` for those. And there is no way to get a modified file back: Tamperlens reads documents and never writes them.\n\n**Cost.** One document against the monthly quota. Works anonymously, like the free checker.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {}
            },
            "application/pdf": {
              "schema": {}
            },
            "image/jpeg": {
              "schema": {}
            },
            "image/png": {
              "schema": {}
            },
            "image/webp": {
              "schema": {}
            },
            "image/gif": {
              "schema": {}
            },
            "image/tiff": {
              "schema": {}
            },
            "image/heic": {
              "schema": {}
            },
            "image/heif": {
              "schema": {}
            },
            "image/avif": {
              "schema": {}
            },
            "image/bmp": {
              "schema": {}
            },
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
              "schema": {}
            },
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
              "schema": {}
            },
            "application/vnd.openxmlformats-officedocument.presentationml.presentation": {
              "schema": {}
            },
            "application/vnd.ms-word.document.macroEnabled.12": {
              "schema": {}
            },
            "application/vnd.ms-excel.sheet.macroEnabled.12": {
              "schema": {}
            },
            "application/vnd.ms-powerpoint.presentation.macroEnabled.12": {
              "schema": {}
            },
            "application/zip": {
              "schema": {}
            },
            "application/octet-stream": {
              "schema": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/signup": {
      "post": {
        "summary": "Create an account and receive a free API key",
        "tags": [
          "Account"
        ],
        "description": "Password must be at least 10 characters. Returns the plaintext API key exactly once. Only its sha256 hash is stored.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "409": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/login": {
      "post": {
        "summary": "Log in and receive a session cookie",
        "tags": [
          "Account"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/logout": {
      "post": {
        "summary": "Destroy the current session",
        "tags": [
          "Account"
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/verify": {
      "get": {
        "summary": "Land a verification link on the account page",
        "tags": [
          "Account"
        ],
        "description": "Side-effect free: redirects to /account?verify=<token>, where the page POSTs the token to POST /auth/verify. A GET that consumed the single-use token was being spent by mail-link scanners before the reader clicked. A missing token redirects to /account?verified=0.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "query",
            "name": "token",
            "required": false
          }
        ],
        "responses": {
          "302": {
            "description": "Default Response"
          }
        }
      },
      "post": {
        "summary": "Confirm an email address with a verification token",
        "tags": [
          "Account"
        ],
        "description": "Consumes a single-use token (7-day expiry) and marks the address verified. An unknown, expired or already-used token is 400 `invalid_token`.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/resend-verification": {
      "post": {
        "summary": "Send a fresh verification link to the logged-in account",
        "tags": [
          "Account"
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/forgot": {
      "post": {
        "summary": "Request a password-reset link",
        "tags": [
          "Account"
        ],
        "description": "ALWAYS answers 200 {ok:true}, whether or not the address has an account and whether or not this deployment can send mail. Anything else would turn the endpoint into an account oracle.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/auth/reset": {
      "post": {
        "summary": "Set a new password using a reset token",
        "tags": [
          "Account"
        ],
        "description": "Consumes a single-use token (1-hour expiry). Password must be at least 10 characters. Every session belonging to the account is destroyed, including the one that made this call.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "password"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "password": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/session": {
      "get": {
        "summary": "Whether the caller has a live session cookie",
        "tags": [
          "Account"
        ],
        "description": "Always 200. Returns `{ authenticated: boolean }` and nothing else, no email, no plan, no key material. Intended for public pages that need to know whether to render a signed-in nav, without a 401 in the console for every logged-out visitor.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/me": {
      "get": {
        "summary": "Account summary: plan, keys, month-to-date usage",
        "tags": [
          "Account"
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/keys/rotate": {
      "post": {
        "summary": "Revoke a key and issue a replacement on the same plan",
        "tags": [
          "Account"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "keyId"
                ],
                "properties": {
                  "keyId": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/usage": {
      "get": {
        "summary": "Per-day inspection counts and risk-band split for the current month",
        "tags": [
          "Account"
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/billing/checkout": {
      "post": {
        "summary": "Start a Stripe Checkout session for a paid plan",
        "tags": [
          "Billing"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "plan"
                ],
                "properties": {
                  "plan": {
                    "type": "string",
                    "enum": [
                      "solo",
                      "team",
                      "business"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "409": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "502": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/billing/credits": {
      "post": {
        "summary": "Start a Stripe Checkout session for a prepaid credit pack",
        "tags": [
          "Billing"
        ],
        "description": "One-time payment, not a subscription. Available ONLY to an account on the Free plan: a paid plan already includes a monthly quota, and credits are the alternative to subscribing rather than a top-up on it. Purchased documents never expire and are spent only after the monthly quota is exhausted.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "pack"
                ],
                "properties": {
                  "pack": {
                    "type": "string",
                    "enum": [
                      "small",
                      "large"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "409": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "502": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/billing/portal": {
      "post": {
        "summary": "Open the Stripe billing portal for the current account",
        "tags": [
          "Billing"
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "409": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "502": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/billing/webhook": {
      "post": {
        "summary": "Stripe webhook receiver (signature-verified)",
        "tags": [
          "Billing"
        ],
        "description": "Handles checkout.session.completed, checkout.session.async_payment_succeeded, checkout.session.async_payment_failed, customer.subscription.updated and customer.subscription.deleted. Fulfilment is gated on payment_status, so a delayed method (boleto) fulfils on async_payment_succeeded, not on the earlier completed. Idempotent: each plan handler assigns an absolute state, so replays converge; a credit-pack purchase is a delta deduplicated by Checkout Session id. A storage failure returns 500 so Stripe re-delivers (safe under idempotency).",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "500": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/baselines": {
      "get": {
        "summary": "List the issuer baselines available to this account",
        "tags": [
          "Baselines"
        ],
        "description": "Returns this account's own profiles plus the built-in public-specimen profiles, with `builtIn` distinguishing them. A built-in profile is omitted when the account has trained its own for the same issuer, because the account's own always wins at inspection time.\n\nNo document is ever returned, because none is ever stored: a profile is a structural fingerprint (producer strings, PDF versions, font sets, page geometry) derived from documents that were processed in memory and discarded.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Train an issuer baseline from known-good PDFs",
        "tags": [
          "Baselines"
        ],
        "description": "multipart/form-data. Fields: `issuer` (slug, `^[a-z0-9][a-z0-9-]{0,63}$`), `label` (optional human name), and up to 25 file parts, each within the deployment's upload cap.\n\nSend `issuer` and `label` BEFORE the file parts: an invalid slug is then rejected before a single document is parsed.\n\nEach file is parsed **in memory**, reduced to its structural profile, and discarded. Training documents are never written to disk. Files that are not PDFs, or that cannot be parsed, are skipped and reported in `skipped`; they do not fail the request unless none is usable.\n\nTraining an issuer that already has a profile MERGES into it, so a baseline can be grown in batches. Training does not consume inspection quota.\n\n**Team plan and up.** The built-in public baselines work on every tier; training private ones answers 403 `plan_required` below Team.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true,
                  "properties": {
                    "profile": {
                      "type": "object",
                      "properties": {
                        "issuer": {
                          "type": "string",
                          "description": "Stable slug, e.g. `chase`."
                        },
                        "label": {
                          "type": "string"
                        },
                        "sampleCount": {
                          "type": "integer",
                          "description": "Genuine documents this profile was built from."
                        },
                        "source": {
                          "type": "string",
                          "enum": [
                            "public-specimen",
                            "customer"
                          ]
                        },
                        "builtIn": {
                          "type": "boolean",
                          "description": "true = a profile Tamperlens ships; false = trained by this account."
                        },
                        "createdAt": {
                          "type": "string"
                        },
                        "updatedAt": {
                          "type": "string"
                        },
                        "matchingActive": {
                          "type": "boolean",
                          "description": "false while the profile has fewer than `minSamplesForMatching` samples. Such a profile is stored and reported but every dimension compares as `unknown`."
                        },
                        "minSamplesForMatching": {
                          "type": "integer"
                        },
                        "dimensions": {
                          "type": "array",
                          "description": "Per-dimension summary of the fingerprint. `consistency` is the share of samples carrying the most common value (0-1); a dimension below the engine's threshold can never produce a mismatch.",
                          "items": {
                            "type": "object",
                            "properties": {
                              "dimension": {
                                "type": "string"
                              },
                              "consistency": {
                                "type": "number"
                              },
                              "distinctValues": {
                                "type": "integer"
                              },
                              "top": {}
                            }
                          }
                        }
                      }
                    },
                    "merged": {
                      "type": "boolean"
                    },
                    "filesReceived": {
                      "type": "integer"
                    },
                    "filesUsed": {
                      "type": "integer"
                    },
                    "skipped": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "filename": {
                            "type": "string"
                          },
                          "reason": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "403": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "413": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "422": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/baselines/{issuer}": {
      "delete": {
        "summary": "Delete one of this account's own issuer baselines",
        "tags": [
          "Baselines"
        ],
        "description": "Deletes only a profile this account trained. A built-in `public-specimen` profile, an unknown slug and another account's slug are indistinguishable from the outside and all answer 404: built-in profiles are shared and can never be deleted through the API.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "issuer",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ingest/email": {
      "post": {
        "summary": "Email Worker ingest (shared secret)",
        "tags": [
          "AP monitor"
        ],
        "description": "Called by the Cloudflare Email Worker, never by browsers or customers. Authenticated by the `x-tamperlens-ingest-secret` header. The recipient alias identifies the tenant; the sender is recorded as evidence only. Attachments are inspected in memory and discarded: only derived facts reach the ledger. Advisory only: nothing is deleted, quarantined or blocked, ever.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "422": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ap/alias": {
      "post": {
        "summary": "Provision this account's forwarding alias",
        "tags": [
          "AP monitor"
        ],
        "description": "Returns the account's ingest alias, creating it on first call. Idempotent. Mail forwarded to `<alias>@in.tamperlens.com` (once the owner completes the Cloudflare Email Routing setup: see docs/AP-INGEST-SETUP.md) flows into this account's supplier ledger.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "500": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ap/ledger": {
      "get": {
        "summary": "This account's supplier ledger",
        "tags": [
          "AP monitor"
        ],
        "description": "Every supplier we hold history for, their current payment coordinates (bank code, branch, account last-4, beneficiary CNPJ, PIX key last-4), the full version chain, and the last 30 days of alerts. Derived facts only, no document, and no full account number, is stored anywhere.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ap/digest": {
      "get": {
        "summary": "The weekly digest, on demand",
        "tags": [
          "AP monitor"
        ],
        "description": "The same content the weekly email carries: every supplier bank detail we currently hold, plus the week's alerts. Useful when nothing is wrong. It is the table a finance team compares a suspicious invoice against.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/ap/seed": {
      "post": {
        "summary": "Seed the ledger from historical documents",
        "tags": [
          "AP monitor"
        ],
        "description": "The cold-start answer: batch-upload the last 12 months of supplier invoices and boletos at onboarding and the ledger is populated on day one. multipart/form-data, up to 25 PDFs per request; call repeatedly for more. **Upload oldest first**: the version chain records coordinate changes in the order documents arrive. Seeded documents record history and raise NO alerts: a legitimate bank change last year is history, not news. Documents are parsed in memory and never stored, exactly like /inspect.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "400": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "413": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "422": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/health": {
      "get": {
        "summary": "Liveness check",
        "tags": [
          "Health"
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string"
                    },
                    "engineVersion": {
                      "type": "string"
                    },
                    "uptime": {
                      "type": "number"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/stats": {
      "get": {
        "summary": "Server-side page views and inspection counts (owner-only)",
        "tags": [
          "Health"
        ],
        "description": "Requires `Authorization: Bearer $STATS_TOKEN`. 503 when STATS_TOKEN is unset. Cookieless: the underlying counters record a UTC day, a path, the link's own utm campaign tag and a count, no IP address, user agent, session or visitor id exists to report.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "401": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "429": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/stats/overview": {
      "get": {
        "summary": "Owner traffic overview, operator-filtered (session + owner allowlist)",
        "tags": [
          "Health"
        ],
        "description": "Session-authenticated and restricted to `OWNER_EMAILS`. 503 when that variable is unset; 404, never 403, for any other caller. Inspections are reported as two series, `external` and `operator`; page views cannot be filtered at all (`views.filterable: false`) because the counter records only a day, a path, the link's own utm campaign tag, and a count.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/samples": {
      "get": {
        "summary": "List the demo sample documents and their risk bands",
        "tags": [
          "Inspect"
        ],
        "description": "Three fictional statements: clean, edited after creation, and signed then modified, whose reports are precomputed and cached. Free: no API key, no rate limit, and calls do not count against any quota. Download the bytes from `downloadUrl` to check the findings yourself.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "string"
                      },
                      "label": {
                        "type": "string"
                      },
                      "description": {
                        "type": "string"
                      },
                      "riskBand": {
                        "type": "string"
                      },
                      "downloadUrl": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/samples/compare": {
      "get": {
        "summary": "The demo pair behind the /compare tool",
        "tags": [
          "Inspect"
        ],
        "description": "One pair of fictional statements: an original and a copy that gained an appended revision. Free, unmetered, and downloadable from the two URLs so the comparison can be reproduced.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/samples/compare/{id}/report": {
      "get": {
        "summary": "Precomputed comparison report for one demo pair",
        "tags": [
          "Inspect"
        ],
        "description": "The same ComparisonReport shape POST /compare returns, which is a KEYED, two-document endpoint, so this is the only way to see one without a key. Cached per process; free and unmetered.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/samples/metadata": {
      "get": {
        "summary": "The demo photograph behind the /metadata viewer",
        "tags": [
          "Inspect"
        ],
        "description": "One fictional snapshot carrying the identity, timestamp and location tags a phone writes without being asked. Free, unmetered, downloadable.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/samples/metadata/{id}/report": {
      "get": {
        "summary": "Precomputed metadata report for one demo file",
        "tags": [
          "Inspect"
        ],
        "description": "The same MetadataReport shape POST /metadata returns. Cached per process; free and unmetered, so trying the demo costs a visitor no quota.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/samples/{id}/report": {
      "get": {
        "summary": "Precomputed inspection report for one demo sample",
        "tags": [
          "Inspect"
        ],
        "description": "The same InspectionReport shape POST /inspect returns. Cached per process, so repeated calls are byte-identical (`id` included): it identifies the computation, not the request. Free and unmetered.",
        "parameters": [
          {
            "schema": {
              "type": "string"
            },
            "in": "path",
            "name": "id",
            "required": true
          }
        ],
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          },
          "404": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/pricing": {
      "get": {
        "summary": "The plan ladder, priced in the caller's currency",
        "tags": [
          "Account"
        ],
        "description": "Returns every plan with its quota and a display-ready price string. The currency is decided by the caller's country as reported by Cloudflare (`CF-IPCountry`): Brazil is quoted in BRL because Stripe can only charge Brazilian cards in reais, everyone else in USD. Unauthenticated; the pricing surfaces call it to keep the quote and the eventual Stripe charge in the same currency.",
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "additionalProperties": true
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/receipt/verify": {
      "post": {
        "summary": "Verify a report receipt",
        "tags": [
          "Receipts"
        ],
        "description": "Checks whether a `receipt` block was issued by Tamperlens and whether it covers the `report` you send with it.\n\nTamperlens never stores the documents it inspects, so it cannot reproduce a report to settle a dispute. The receipt is the alternative: you keep the report and its receipt, and this endpoint confirms the pair.\n\nSend the report **exactly as you received it**, including the `id`. Remove the `receipt` key itself, a receipt cannot cover itself, or leave it in place; both are accepted, and it is stripped before hashing either way.\n\n`valid: false` carries a `reason`: `bad_signature` means we did not issue this receipt at all, while `report_mismatch` means the receipt is genuine but the report you sent is not the one it was issued for. Those are different problems and the endpoint will not collapse them.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "report",
                  "receipt"
                ],
                "properties": {
                  "report": {
                    "type": "object",
                    "additionalProperties": true
                  },
                  "receipt": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "valid": {
                      "type": "boolean"
                    },
                    "reason": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Default Response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    },
                    "message": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/demo": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/ig": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/li": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/tn": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/dh": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/dd": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/wa": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/rd": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    },
    "/hn": {
      "get": {
        "responses": {
          "200": {
            "description": "Default Response"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Inspect",
      "description": "PDF fraud-signal inspection"
    },
    {
      "name": "Baselines",
      "description": "Issuer baselines — structural profiles of an institution's genuine documents. Only fingerprints are stored; the documents they are derived from are processed in memory and never written to disk."
    },
    {
      "name": "Account",
      "description": "Self-serve accounts, keys and usage"
    },
    {
      "name": "AP monitor",
      "description": "Accounts-payable supplier ledger — remembers which bank details each supplier has used and alerts when they change. Advisory only: read-only ingest via a forwarding alias, no mailbox access, no delete, no quarantine. Only derived facts are stored (hashed coordinates + last four digits), never documents."
    },
    {
      "name": "Billing",
      "description": "Stripe checkout, portal and webhook"
    },
    {
      "name": "Health",
      "description": "Liveness"
    }
  ]
}
