---
title: "Inspect a document or image for fraud/tamper signals"
method: POST
path: "/api/v1/inspect"
tags: ["Inspect"]
---

# Inspect a document or image for fraud/tamper signals

`POST /api/v1/inspect`

## Request

- Base URL: `https://tamperlens.com` (the document declares no server; this is the origin it was published from)
- URL: `https://tamperlens.com/api/v1/inspect`
- Auth: none declared

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.

**Retention.** A call with no account (the anonymous checker, a key with no account) keeps nothing. A call made for an account whose history is on (see `GET /api/v1/retention`; on by default for accounts created since retention launched, off for older ones until they turn it on) also keeps a short record of the result: a closed list of derived fields, never the file, its name or any text from it. The file itself is kept, encrypted, only when the account opted in to file retention. A key set to `retention: none`, or the header `X-Tamperlens-Retention: none` on one call, keeps nothing; the header can only turn keeping off, never on. When a record was kept the response carries `retention: {stored, id, expiresAt, file?}`, and `tenantMatches` lists up to 20 earlier records of the same account (of the same key, for a key) that share an origin with this file. Both sit outside the engine's report and inside the body the receipt signs.

**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.

**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.

**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.

**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.

The 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.

**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):

- `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`.
- `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.

`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.

**These counts are not a diff, and must not be presented as one.** They 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 the counts say. 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.

**Which amounts of money the last revision changed (engine 1.42.0, opt-in).** Off by default: only a request with `?changes=values` (here or on `/case`) gets these fields, and a report without it carries none of them; the free checker and the MCP server never ask. With it, `incremental-updates.evidence.textChanges[]` compares the LAST revision with the one immediately before it, reading the text layer of both (no rendering), and lists the money amounts that changed: `{revision, page, kind, tokenKind, before, after, region}`, with `kind` one of `added`, `removed`, `replaced` and `tokenKind` always `money`: an amount with a decimal separator and exactly two decimals, optionally after R$, US$ or $, thousands groups of three, at most twelve integer digits, at most 40 characters. Nothing else is ever returned: no words, labels or names, no dates, no numbers that are not money, and no identifier, not even masked. A printed line is not read when its own text holds a run of nine or more digits or up to eleven digit groups that pass a CPF or CNPJ check, when such a run or group in its neighbourhood (widened until 13 digits are gathered on each side, at most 12 lines each way, joined without separators) takes digits from it, when it carries an identifier label (CPF, RG, conta, account, phone...), or when it sits up to two lines below one that its own line does not answer with a complete, checkable value (a CEP of eight digits, a CPF or CNPJ that passes its check digits), on either revision. Residual risk: an identifier written in money format with no recognizable label nearby (an account printed as `12.345,67`), or split into pieces more than 12 lines apart, cannot be told from an amount and can be reported as one; a caller that stores or forwards reports should not enable it. The values are still document content, so the evidence carries `payloadIsUntrusted: true` when it holds one, and `?redact=payload` and the MCP server replace `before` and `after` with `"[redacted]"`. `region` locates the value on the page (the after-value; for `removed`, where the before-value was). At most 50 entries; `textChangesTotal` and `textChangesTruncated` say when the list is a sample. `textChangesSkipped` (`linearized`, `too-many-revisions`, `budget` for a work limit, `time-budget` when the clock ran out, which also sets `analysisIncomplete`) means the comparison was declined and `textChanges` is empty; the fields are absent when no comparison was attempted (an encrypted file, triage scope, a revision chain that could not be cut cleanly). Nothing here moves a severity or the score.

**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:

- multipart: an `issuer` field, sent **before** the file part;
- raw body: an `X-Tamperlens-Issuer` header.

The 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"}`.

**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:

```json
{ "review": 30, "reject": 70,
  "rejectOn": ["redaction-exposure"],
  "reviewOn": ["incremental-updates"] }
```

`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.

`ignore` names signal families your channel makes noisy (default `[]`). An ignored family cannot trigger a rule and does not count toward the score the thresholds read: the policy rescores the remaining signals with the engine's own scoring function and returns that number as `effectiveRiskScore`, beside the unchanged `riskScore`. `ignore` beats `rejectOn`/`reviewOn` for the same id. The signal stays in the report.

`preset` (`strict` | `balanced` | `lenient`, default none) is a named starting point that expands **before** your own keys, so anything you send wins; a list you send replaces the preset's. `balanced` is the engine's bands (30 and 70). `strict` also sends any low-severity finding to review (review from 10, reject from 70). `lenient` sends only the high band to review (review from 70) and rejects by score only at 100. No preset rejects below 70. The published false-positive and recall figures behind them are at /evidence; they are a starting point, not a guarantee about your own documents.

The response gains a `policy` block: `{verdict, riskScore, thresholds, reason, triggeredBy, policyVersion}` where `verdict` is `accept` | `review` | `reject` and `reason` is `score` or `signal`. With a non-empty `ignore` it also carries `effectiveRiskScore` and `ignored` (the ignored families that actually fired). `policyVersion` is `pv1-` plus 12 hex characters of a sha256 over the normalized policy (preset expanded, lists sorted and de-duplicated), so the same rule sent with keys or lists in another order gets the same value; store it beside the decision. **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.

Unknown signal ids in `rejectOn`/`reviewOn`/`ignore` 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.

**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.

**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.

**Regions, file hash, fingerprint, coverage and reason code (engine 1.39.0).** Additive facts; none is a signal and none moves the score or the band. `redaction-exposure`, `glyph-anomalies`, `running-balance-break`, `br-identifier-checksum` and `hybrid-page` carry `evidence.regions` (at most 50, with `regionsTotal` and `regionsTruncated`): `{page, x0, y0, x1, y1}`, page 1-based, in the PDF's default user space with no `/Rotate` applied and the MediaBox origin kept, so a viewer converts them with the page's own viewport. On `glyph-anomalies` and `br-identifier-checksum` each region also carries `findingIndex`, the index into `findings`. Numbers only, never text. `document.sha256` is the SHA-256 of the bytes received, on every media type. `fingerprint` (PDF only; `null` for images, Office files, encrypted PDFs and triage) has `issuerProfileHash`, the SHA-256 of the issuer-baseline profile, which groups genuine documents of one issuer and is never evidence on its own, and `baseFile` (`idPermanent` = trailer `/ID[0]`, `creationDate`, sorted `fontSubsetTags`, at most 20, only tags that look random: generators that number them, like Chrome's AAAAAA/BAAAAA and mPDF's MPDF.., are excluded) for files saved from the same base file. The tags are a weak hint that depends on the generator; with `idPermanent` they are a pseudonym of the file, not personal data. `coverage` is `{scope, blindSpots}`: `scope` is `triage` exactly when the `triage` block is present, `metadata-only` for images, encrypted PDFs and unwalkable containers, `partial` for a PDF whose analysis a work limit or the clock cut short (engine 1.43.0, see `analysisIncomplete` below), `full` otherwise; `blindSpots` is a closed list in fixed order (`page-content-not-walked`, `pixels-not-decoded`, `office-not-rendered`, `encrypted-content`, `text-extraction-untrusted`, `clean-regeneration`, the last always present). `summary.reasonCode` names the first matching rule: `SIGNED_BYTES_CHANGED`, `CONTENT_REPLACED_AFTER_CREATION`, `REDACTION_EXPOSED`, `APPENDED_AFTER_SIGNATURE` (bytes past a signature that are not later signatures; a countersigned contract does not get it), `SIGNATURE_ONLY_REVISIONS` (a countersigned contract and nothing else: at least one medium finding, and every finding above info, low included, is signature-coverage, incremental-updates or id-inconsistency carrying `exemptionReason: signature-only-revision-capped-elevated`; fails closed), `SEVERAL_FAMILIES` (2+ families at medium or above), `ONE_FAMILY` (1 at medium or above), `LOW_SEVERITY_ONLY` (only low findings), `NO_FINDINGS_PARTIAL_SCOPE` (nothing above info and scope not full, or extraction untrusted, Office not rendered, or a scanned PDF), `NO_FINDINGS`. The codes describe what was observed, never a verdict.

**When the analysis was cut short (engine 1.43.0).** A PDF report carries `analysisIncomplete` only when part of the analysis did not run; a complete report has no such key. `{reason: "work-limit", limit}` means a deterministic limit on work stopped it, and `limit` names the first one hit, in parse order: `input-bytes`, `inflated-bytes` (64 MiB decompressed per document), `stream-inflated-bytes` (32 MiB per stream), `xref-entries`, `xref-free-entries`, `xref-chain`, `objects`, `decoded-scan-bytes`, `xmp-packet-bytes`, `analysed-pages` (200), `text-runs` (100 000), `page-content-bytes` (8 MiB a page), `page-content-operators` (2 000 000 a page), `content-bytes` (24 MiB of page content per document, Form XObject content counted at every draw), `content-operators` (8 000 000 per document), `text-operators` (250 000 `Tj`, `TJ`, `'` and `"` per document), `forms-per-page` (400), `form-content-bytes` (1 MiB a form), `form-invocations` (2 000 a page), `form-depth` (8) or `graphics-state-depth` (50). A work limit stops the analysis at the same point for the same bytes. `{reason: "time-budget"}` means the 10-second clock ran out first: a safety net the work limits are sized to stay well inside on the production host, which adversarial input or a slower, busier machine can still reach. A report without `time-budget` was not touched by the clock; one with it can differ on a retry. `{reason: "parse-error"}` means the structural parse failed part-way. In every case `coverage.scope` is `partial` and a report with no findings carries `NO_FINDINGS_PARTIAL_SCOPE`, so an unfinished analysis never reads as a clean one, and `structure-warnings` lists the same cut among its warning codes. A cut can also change a real document's score: since a cut parse cannot vouch for what a later signature revision touched, a countersigned contract that hits one is not exempted and reads `high` instead of `elevated`.

**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.

## Query parameters

- `redact` string
- `changes` string
- `mode` 'triage'
- `timing` '1'

## Response `200`

Default Response

- object

## Other responses

- `400`: `no_file`: a multipart request with no `file` part. `malformed_multipart`: the multipart body could not be parsed (a broken or missing boundary, a body cut off mid-part). Message: "The multipart body could not be parsed. Check the boundary and send the document as one file part."
- `413`: `file_too_large` (with `maxMb`): the body or the multipart file is over the upload cap. `too_many_parts`: the multipart body has more fields, files or parts than an upload needs. Message: "The multipart body has more fields, files or parts than an upload needs. Send the document as one file part with at most a few fields."

---

[API](https://skmtc.dev/tamperlens/apis/tamperlens-api.md) · [All operations](https://skmtc.dev/tamperlens/apis/tamperlens-api/llms.txt) · [OpenAPI document](https://skmtc.dev/tamperlens/apis/tamperlens-api/revisions/e1b7ae64347b?raw)
