API reference

Every behaviour of the API, and why each one is the way it is. The quickstart is the one-screen version, and the machine-readable spec is at /openapi.json: the same document the Swagger playground renders.

The whole contract: authentication, both request forms, the sibling endpoints, the report schema field by field, every error code and the limits around them. If you have not made a call yet, the quickstart is one screen and gets you there faster. The machine-readable spec, with a try-it-out console, is at /docs.

1. Authentication

A bearer token in the Authorization header. Keys are prefixed tl_.

Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Three behaviours worth knowing, because they are deliberate:

  • No header → anonymous. Allowed, IP rate-limited.
  • A header that does not resolve → hard 401. A presented-but-invalid key never silently degrades to anonymous, because a silent downgrade is how a rotated key in production turns into a mystery outage instead of an error.
  • A valid key → metered against the monthly quota of the account that owns it (every key on the account shares one ceiling; rotating a key does not reset it), and exempt from the anonymous IP rate limit entirely.

The inspect endpoint authenticates by header only and never by cookie, so there is nothing to configure for CORS credentials and nothing CSRF-shaped to worry about. Account and billing endpoints are the opposite (session cookie, JSON body required), and are documented at /docs.

2. Making a request

POST /api/v1/inspect accepts either multipart/form-data with a field named file, or a raw request body. Both are equivalent; pick whichever your HTTP client makes cheap. The cap is 10 MB.

Three media, one endpoint and one report shape. PDFs; Word, Excel and PowerPoint documents (.docx, .xlsx, .pptx and their macro-enabled twins); and 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 says so in a signal, because an empty result must never be mistaken for a clean one.

The format is decided by sniffing the bytes, never from the Content-Type header or a filename. A phone that renamed a JPEG to .pdf is still analysed as the JPEG it is, and a hostile caller cannot pick their own parser by setting a header. Reports carry a mediaType discriminator ("pdf", "office" or "image"), plus an additive office or image block; document stays populated for all three, so an existing integration keeps working.

multipart/form-data

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F [email protected]

raw application/pdf body

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/pdf" \
  --data-binary @statement.pdf

Node: raw body, no multipart encoder needed

const pdf = await readFile("statement.pdf");

const res = await fetch("https://tamperlens.com/api/v1/inspect", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TAMPERLENS_KEY}`,
    "Content-Type": "application/pdf",
  },
  body: pdf,
});

if (!res.ok) throw new Error(`inspect failed: ${res.status}`);
const report = await res.json();

if (report.summary.riskBand !== "low") {
  await queueForManualReview(applicationId, report.signals);
}

Python

import os, requests

with open("statement.pdf", "rb") as fh:
    res = requests.post(
        "https://tamperlens.com/api/v1/inspect",
        headers={
            "Authorization": f"Bearer {os.environ['TAMPERLENS_KEY']}",
            "Content-Type": "application/pdf",
        },
        data=fh.read(),
        timeout=30,
    )

res.raise_for_status()
report = res.json()
print(report["summary"]["riskBand"], [s["id"] for s in report["signals"]])

Verifying a file against the original: /compare

When you hold the original: you generated the document, or you kept the copy the issuer sent. POST /api/v1/compare answers a stronger question than inspection can: is this file that original, unchanged? Send two files and get the facts back in three tiers, strongest first.

Compare a resubmitted file against the one on record

curl -s https://tamperlens.com/api/v1/compare \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F [email protected] \
  -F [email protected]
  • relationship: "identical": the files hash equal. The only answer that is a proof rather than a signal.
  • "candidate-extends-original": the candidate contains the original verbatim plus appended incremental updates. The report classifies what the appended revisions touch; ancestry.contentTouched is the headline fact. Page content was replaced after the original was complete. "candidate-truncates-original" is the reverse: the candidate is an earlier revision of the original.
  • "rewritten": neither contains the other, and the comparison degrades honestly to parsed structure: metadata field by field, the /ID pair, fonts, page count and geometry. No pages are rendered, so no result claims the rendered appearance is identical.

/compare requires a key (no anonymous access) and parses two documents, so it is metered as 2 documents against the monthly quota. PDF pairs only. The full schema is at /docs, and the guide covers what each relationship does and does not prove.

Several documents of one case: /case

A lender rarely receives one document. It receives three months of statements, or a payslip and the statement it was paid into. Three "months" made by editing one real statement each pass a single-file check; side by side, they still carry that one file's identifiers. POST /api/v1/case takes 2 to 10 files of one case, inspects each one exactly as /inspect would, and reports what the files have in common that independent documents should not. Retention works per document as on /inspect (§8).

Three statements, one call

curl -s https://tamperlens.com/api/v1/case \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F [email protected] \
  -F [email protected] \
  -F [email protected]

The answer is { reports, crossDocument }. reports[i] is, in upload order, the report /inspect returns for file i, with its own receipt when receipts are on. A file that cannot be analysed gets the error /inspect would have given for it, plus status, the HTTP status that error has on its own (not_a_pdf 422, analysis_timeout 422, busy 503, case_budget_exhausted 503, internal_error 500); the other files still come back. crossDocument.findings lists { code, documents, evidence }, where documents are indices into reports and evidence holds hashes, identifiers, counts and timestamps, never page text.

  • same-file: two uploads have the same document.sha256. Benign cause: the same statement sent twice.
  • same-base-file: different files share the permanent half of the trailer /ID (evidence.basis includes id-permanent), or the same whole set of two or more non-trivial font subset tags (font-subset-tags). Editing one file into another keeps both. Benign causes: a generator that reuses one /ID for every file it writes, a generator that numbers its tags instead of drawing them (Chrome writes BAAAAA, CAAAAA and so on, so those are dropped before comparing), or a document legitimately re-saved by its issuer. TeX-family generators derive their tags from the glyphs used, so their tags are not compared. PDF only.
  • shared-creation-instant: different PDFs say they were created in the same second (in UTC). Benign cause: a bank that exports several months in one batch. Midnight is never compared, because a date written without a time of day reads as midnight.

These are inconsistencies to review, not verdicts, and none of them changes any report's score. What no rule uses on its own is fingerprint.issuerProfileHash: genuine statements of one bank share it every month, so three real statements of the same issuer raise nothing. A case with no findings does not prove the documents are independent.

/case requires a key. Each analysed document is metered as one document, so a case of N costs N, and a file that failed costs nothing. The account needs N documents available once the files are read, or the call is 429 quota_exceeded. Each file obeys the upload cap; together they may not pass three times it (413 case_too_large). More than 10 files is 400 too_many_files, fewer than 2 is 400 not_enough_files. Files run one at a time, each under the per-document deadline, inside a 90-second budget for the whole case: a file starts only while a full deadline plus a 5-second margin is left, and a file still waiting for a worker when the budget runs out is dropped. Either way its slot says case_budget_exhausted and it costs nothing. At most two cases run at once on a server, and one of the two is kept for paid plans: a free account runs one case at a time on a server, and none while a paid case holds the other. A case takes its place only once all its files have arrived, so the 503 busy for capacity can come after the upload; it carries Retry-After, the same answer a shed /inspect gets. While files arrive, the bytes all cases hold are capped too (two cases' combined cap, one of them kept for paid plans), and past that the call is also 503 busy. One account, and one network, runs one case at a time: a second one gets 429 busy with Retry-After. The files have 30 seconds to arrive, counted from when the case is admitted; past that the answer is 408 body_timeout and the connection is closed. None of these is billed. ?redact=payload and ?changes=values work per report as on /inspect.

Reading a file's metadata: /metadata

POST /api/v1/metadata answers a different question from inspection: not was this edited? but what does this file say about itself? Every key, grouped by the container it came from, with the stored form beside the decoded one.

List everything a document carries

curl -s https://tamperlens.com/api/v1/metadata \
  -F [email protected]
  • PDF: the complete Info dictionary, not just the five fields the signal families read. Author, Subject, Keywords and any custom key a pipeline invented are all listed, plus every XMP property and the plain structural facts (pages, revisions, encryption, signatures, fonts).
  • Images: EXIF split by directory, so a thumbnail's facts stay distinguishable from the photograph's; decoded GPS coordinates; PNG text chunks; XMP. Containers present but not enumerated (IPTC, ICC, MakerNote, C2PA) are named rather than silently omitted.
  • Office: core, application and custom properties, and the EXIF of every image embedded in the package. A .docx is a ZIP: a photograph pasted into one keeps the coordinates the camera wrote, and that is usually the most surprising thing in the report.

personal collects the entries that identify a person, a device or a place (names, serial numbers, software, locations), as a copy, so the grouped list stays complete. There are no signals, no risk score and no verdict; that is /inspect's job.

Read-only, by construction. The response is JSON and only JSON. Tamperlens has no endpoint that returns a modified file (no cleaned copy, no metadata-stripped download), because producing one would mean writing a document, and every promise on the security page depends on us never doing that. If you need a stripped file, strip it locally; we will tell you exactly what to remove.

One file, one parse, metered as 1 document. Works anonymously like the free checker, so a key is optional. The browser version is at /metadata.

Health check, unauthenticated: GET /api/v1/health.

For agents: screen the document before your agent reads it

An agent that opens an attachment has already read whatever the attachment says. A PDF or a .docx is a convenient place to put a sentence addressed to the model rather than to the person, and an extraction pipeline surfaces exactly the fields a human reader never looks at. Tamperlens classifies those carriers on the way in: Info-dictionary values including the custom keys pipelines invent, the XMP packet, annotation contents, embedded-file names and descriptions, and on the Office side docProps, custom properties, comment text and runs marked hidden. The same parse reports the executable surface beside it (embedded JavaScript, /Launch actions and attached files), none of which Tamperlens ever runs, opens or follows.

So the useful order is inspect, then read: one call before the bytes reach your model's context, branching on document-injection-markers and office-injection-markers exactly as you would on any other family. The payload itself never has to travel with the finding, ?redact=payload returns the same report with every attacker-authored string elided.

What this does not read, stated so you do not assume it. The classifier runs on the carriers above, not on a PDF's page content: text concealed inside the content stream is not screened for instructions, because separating a hidden message from the invisible OCR layer under a scan is work that is specified and not built. A clean report here means those fields were clean, not that the document is safe.

If the caller is an LLM agent rather than your own code, there is an MCP server that exposes the same endpoints as five tools: inspect_document, triage_document, check_redaction, compare_documents and inspect_case. It is a thin wrapper over the HTTP API on this page; there is no second engine behind it.

{
  "mcpServers": {
    "tamperlens": {
      "command": "node",
      "args": ["/absolute/path/to/tamperlens/dist/mcp/index.js"],
      "env": { "TAMPERLENS_API_KEY": "tl_..." }
    }
  }
}

The tools take an absolute file path, never inline base64: the bytes need to reach Tamperlens, not the model, and passing a document through an agent's context would cost megabytes for nothing. Without a key the anonymous allowance applies, which is enough to try it and not enough to work with.

Not on npm yet, but the package now exists. The configuration above is the one that works today: clone the repo, npm run build, and point your MCP host at dist/mcp/index.js.

The reason it was long unpublishable is that this repository carries the API server, so installing it would compile a native database driver for a client that only speaks HTTP. That is solved, and it is published: packages/mcp builds tamperlens-mcp with two runtime dependencies, the MCP SDK and zod, and installs into a clean project with no native build.

npx tamperlens-mcp

Its version is the engine version, deliberately, so [email protected] tells you which engine it wraps rather than counting its own releases. It is an HTTP client: it holds no signal logic, and an engine release reaches you when this API is deployed, not when you upgrade the package.

3. Issuer baselines

The twenty signal families above answer one question: was this file edited after it was made? They are silent on a document that was fabricated cleanly: generated once from a template and never touched again. Such a file is structurally pristine, so nothing fires.

An issuer baseline answers the other question: does this file look like it came from the institution it claims to come from? Every institution generates its PDFs with a fixed toolchain, and that toolchain leaves a consistent structural fingerprint: the same producer string, the same PDF version, the same embedded font set, the same page geometry, the same cross-reference style, a single revision. A forged statement produced by wkhtmltopdf is internally consistent but wrong for the bank whose name is on it, and that is visible without reading a single number on the page.

What a baseline stores

A profile is derived structure only: it records that documents from an issuer carry producer X and PDF version 1.4. It never contains the documents, their text, account numbers, names or balances, and it is a few hundred bytes. Training documents are parsed in memory and discarded exactly like an inspection, so training never keeps a document, whatever the account's retention setting.

Naming an issuer on an inspection

Optional, and additive. Send the issuer slug either as a multipart issuer field (before the file part) or as an X-Tamperlens-Issuer header on a raw body. Slugs match ^[a-z0-9][a-z0-9-]{0,63}$.

multipart

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -F issuer=chase \
  -F [email protected]

raw body

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer tl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/pdf" \
  -H "X-Tamperlens-Issuer: chase" \
  --data-binary @statement.pdf

The response is the ordinary report plus one top-level block. A confident deviation also raises an issuer-mismatch signal in signals.

{
  "issuerBaseline": {
    "requested": "chase",
    "resolved": true,
    "source": "customer",
    "sampleCount": 18,
    "matchingActive": true,
    "divergence": 0.42,
    "mismatchedDimensions": ["producers", "fontSets"]
  }
}

An unknown slug is not an error. You get the normal report and {"requested": "chase", "resolved": false, "reason": "no_profile"}, so “matched” and “we had nothing to compare against” are never confused with each other: a distinction the absence of a signal cannot express. A malformed slug is 422 invalid_issuer, because that can only be a bug in the caller. Omit issuer entirely and the response is byte-for-byte what it was before this feature existed: the key is not present.

Training your own

Baselines you train from your own known-good documents are private to your account and always win over the built-in ones. A small set of public bank profiles ships built in as a fallback. Profiles are never merged across accounts.

curl -s https://tamperlens.com/api/v1/baselines \
  -H "Cookie: tl_session=$SESSION" \
  -F issuer=chase \
  -F label="JPMorgan Chase" \
  -F [email protected] -F [email protected] -F [email protected] \
  -F [email protected] -F [email protected] -F [email protected]
  • GET /api/v1/baselines lists your profiles and the built-in ones, with builtIn telling them apart and a per-dimension consistency figure for each.
  • DELETE /api/v1/baselines/:issuer removes one of yours. A built-in profile is shared and answers 404.
  • Five genuine documents is the floor before a profile can call anything a mismatch, and a dimension your documents disagree on simply produces no expectation. Training the same issuer again merges into the existing profile, so a baseline can be grown in batches.
  • Training does not consume inspection quota. It is bounded instead: 25 documents per request, 25 profiles per account, 5 training requests per hour.

These three endpoints authenticate with a dashboard session, not a bearer key, and are easiest to drive from the dashboard.

4. Your verdict policy

Tamperlens does not decide whether a document is fraudulent, and section 9 explains why that is a position rather than a hedge. But you may have decided, and before this existed you had to re-implement that decision on your side: every integrator writing the same if (riskScore >= 70) slightly differently, none of it visible to us when you asked why a document scored what it scored.

So send the rule with the request. Same two spellings as an issuer: a multipart policy field before the file part, or an X-Tamperlens-Policy header. Both carry a JSON object.

curl -X POST https://tamperlens.com/api/v1/inspect \
  -H "Authorization: Bearer $TL_KEY" \
  -H 'X-Tamperlens-Policy: {"review":30,"reject":70,"rejectOn":["redaction-exposure"]}' \
  -H "Content-Type: application/pdf" \
  --data-binary @statement.pdf

The response gains one block, and nothing else changes:

"policy": {
  "verdict": "reject",          // accept | review | reject
  "riskScore": 45,              // the report's own score, never recomputed
  "thresholds": { "review": 30, "reject": 70 },
  "reason": "signal",           // "score" when a cut-point was crossed
  "triggeredBy": ["redaction-exposure"],
  "policyVersion": "pv1-ba2f7738e0ae" // names the rule that decided, see below
}
Field Default What it does
review 30 Score at or above which the verdict is review
reject 70 Score at or above which the verdict is reject
rejectOn [] Signal families that force reject, whatever the score
reviewOn [] Signal families that force at least review
ignore [] Signal families left out of the decision. They stay in the report
preset none strict, balanced or lenient: a starting point your own keys override

The default cut-points are the engine's own band boundaries, so an unconfigured policy agrees with summary.riskBand instead of quietly disagreeing with it. A named family beats a threshold, and rejectOn beats reviewOn. A caller naming a family is saying it matters regardless of what the arithmetic came to, and a threshold that could veto that would make the rule advisory. Only signals above info severity can trigger a rule.

Ignoring a family your channel makes noisy

Some families fire on genuine documents for reasons that belong to your channel. An e-signature platform appends a revision to every contract it signs, so incremental-updates tells you little about those files. Name such families in ignore and they leave the decision: they cannot trigger a rule, and they do not count toward the score the thresholds read. That score comes back as effectiveRiskScore, computed by the engine's own scoring function over the remaining signals, next to the unchanged riskScore. ignored lists the ignored families that actually fired.

X-Tamperlens-Policy: {"ignore":["incremental-updates"]}

"policy": {
  "verdict": "accept",
  "riskScore": 80,              // the engine's score, unchanged
  "effectiveRiskScore": 10,     // the score without incremental-updates
  "ignored": ["incremental-updates"],
  "thresholds": { "review": 30, "reject": 70 },
  "policyVersion": "pv1-2cb4ef611a9c"
}

ignore beats rejectOn and reviewOn for the same id. The report itself does not change: the ignored signal is still in signals[], with its score and evidence, for whoever reviews the file.

Presets

preset expands into thresholds before your own keys are read, so any key you send wins over the preset's, and a list you send replaces the preset's list. The values come from the figures on the measured accuracy page. Only two cut-points have published numbers, 30 and 70, because those are where the scorer jumps: a medium-severity signal puts a report at 30 or more, a high-severity one at 70 or more. So the presets differ in which action each band gets, and none of them rejects below 70.

Preset review reject What the published figures say
strict 10 70 Rejects what balanced rejects and also sends any low-severity finding to a person. The false-positive rate below 30 is not measured. The six forgeries we flattened with pdftoppm scored 10.
balanced 30 70 The engine's bands, the same as sending no thresholds. Scored 30 or more on 18.6% of genuine US .gov documents and 30.0% of genuine Brazilian web documents; 70 or more on 7.4% and 10.0%. On the forgeries we built, 30 caught 100% of the incremental-update ones, 71.1% of those rewritten by qpdf and 65.5% of those re-distilled by Ghostscript.
lenient 70 100 Only the high band reaches a person, which on genuine documents is the 7.4% and 10.0% above. Laundered forgeries that score between 30 and 69 are accepted. How often a genuine document scores 100 is not published.

These are starting points, not a guarantee about your documents. The figures were taken on two populations and on forgeries we built (engine versions and dates are on the measured accuracy page), and the rate on your traffic depends on the software that wrote your files. Measure on your own documents before you rely on a preset.

Which rule decided

Every policy block carries policyVersion: pv1- followed by the first 12 hex characters of a sha256 over the normalized policy. Normalized means the preset is expanded, lists are sorted and de-duplicated, and keys are written in a fixed order. The same rule sent with its keys or lists in another order gets the same value, and {"preset":"balanced"} gets the same value as {} because the two decide identically. Store it next to each decision to know which rule produced it.

  • Unknown signal ids are accepted in rejectOn, reviewOn and ignore, and simply never match. A family we rename must not be able to take your pipeline down.
  • A malformed policy is 422 invalid_policy, never a silent fallback to our defaults. A typo that leaves you believing a rule is enforced when it is not is the one failure mode this must not have. A preset name other than the three above is malformed too.
  • Omit it and nothing changes. The policy key is absent from the response, not null, and the report is byte-identical to what it was before this feature existed.

5. The report, annotated

A 200 response is an InspectionReport. The shape is a stable contract; new signal families are additive.

{
  // Per-call identifier. The ONLY non-deterministic field in the response.
  "id": "insp_2f1e9c04-7b3a-4d51-9f0e-6c2a18d4bb71",

  // Bump this and the scoring weights may have changed. Pin it in your tests.
  "engineVersion": "1.43.0",

  "summary": {
    "riskScore": 10,          // 0-100, weighted aggregation — not a probability
    "riskBand": "low",        // "low" <30 · "elevated" 30-69 · "high" >=70
    "signalCount": 1,         // signals above severity "info"
    "revisions": 1,           // 1 = never incrementally updated
    "reasonCode": "LOW_SEVERITY_ONLY" // engine 1.39.0: which rule produced the headline (§5.3)
  },

  // Most severe first, then alphabetically by id. At most one entry per family.
  "signals": [
    {
      "id": "producer-fingerprint",     // one of the stable family ids
      "severity": "low",                // "info" | "low" | "medium" | "high"
      "title": "PDF editing tool in the production chain (iLovePDF)",
      "detail": "The document's metadata names iLovePDF in its production chain. …",
      "message": {                      // engine 1.8.0 — `detail` as a code plus its values
        "code": "producer-fingerprint.rewritten",
        "params": { "tools": "iLovePDF" }
      },
      "evidence": {                     // shape varies per family; always machine-readable
        "tools": ["iLovePDF"],
        "editorTools": ["iLovePDF"],
        "origin": "pdf-editor",         // an editor rewrites a PDF; "authoring" tools create one
        "producer": "iLovePDF",
        "creator": "iText 7.2.5",
        "revisions": 1,                 // single revision, so "low" — "medium" needs a re-save
        "fullPageImagePages": []
      }
    },
    {
      "id": "id-inconsistency",
      "severity": "info",               // divergent /ID on a single-revision file scores zero
      "title": "Trailer /ID elements differ on a file written once",
      "detail": "The trailer /ID array holds two different identifiers. …",
      "evidence": {
        "idOriginal": "8f2c...a91b",
        "idCurrent": "41de...77c0",
        "trailerCount": 1,
        "revisions": 1
      }
    }
  ],

  // Facts, not findings. Any string field may be null.
  "document": {
    "pages": 2,
    "producer": "iLovePDF",
    "creator": "iText 7.2.5",
    "creationDate": "2026-01-04T10:02:00.000Z",   // ISO-8601, or null
    "modDate": "2026-01-06T18:41:00.000Z",
    "encrypted": false,
    "signed": false,
    "revisions": 1,     // written once; the divergent /ID alone proves nothing
    "sizeBytes": 184320,
    "sha256": "9c1e…40ab"  // engine 1.39.0: SHA-256 of the bytes you sent
  },

  // Engine 1.39.0. Grouping hints, PDF only; null for images, Office files,
  // encrypted PDFs and triage. Never a finding on its own (§5.3).
  "fingerprint": {
    "issuerProfileHash": "3f0a…c2d1",
    "baseFile": { "idPermanent": "8f2c...a91b", "creationDate": "2026-01-04T10:02:00.000Z",
                  "fontSubsetTags": ["KPLZWD", "QXRTMB"] }
  },

  // Engine 1.39.0. What this report read, and what it could not see (§5.3).
  "coverage": { "scope": "full", "blindSpots": ["clean-regeneration"] },

  "disclaimer": "Tamperlens reports risk signals, not authenticity verdicts. …"
}

Fields you should actually branch on

  • summary.riskBand. The coarse routing decision. The band contract is narrow on purpose: high is reserved for reports containing at least one high-severity signal, so a pile-up of weak findings is clamped at 69 and can never reach it.
  • signals[].id and severity. The twenty family ids are stable strings. Branch on these when you want per-signal weighting rather than a single threshold, which you will: if your flow legitimately involves customers signing documents, you want incremental-updates weighted down and signature-coverage weighted up.
  • signals[].evidence. Store it. It is small, it contains no document content, and it is what a human reviewer needs. Treat the shape as per-family and additive; do not assume keys are present.
  • signals[].message (engine 1.8.0): the same paragraph as detail, but as a stable code plus the params interpolated into it. Use it if you present findings in a language other than English, or in your own words: the params are values (offsets, counts, object numbers), so your sentence stays exactly as accurate as ours. detail is rendered from this and is unchanged, so nothing that reads detail today needs to move. A signal may also carry notes[]: qualifying sentences appended after the main one, in the same shape; detail is the concatenation.
  • evidence.payloadIsUntrusted: present and true on document-injection-markers and office-injection-markers, and since engine 1.30.0 on office-hidden-content and office-tracked-changes, and since engine 1.42.0 on incremental-updates whenever evidence.textChanges carries a value (§5.4). It means the strings under evidence.fields[].payload, evidence.fields[].name, evidence.recovered, evidence.authors and evidence.textChanges[].before/.after were written by whoever wrote the document, and on the injection families in order to be read by a language model. Everything else in that signal (the cue categories, the counts, which kind of field carried it, which obfuscation techniques were used) is ours and is safe. Do not place the payload in a model prompt without neutralising it. Nothing attacker-authored ever appears in title, detail or message.params: prose names the kind of field from a fixed list, because a custom Info key is a string the author chose too. See §5.1.
  • document.encrypted, when true, six content-dependent signal families did not run at all and the report says so via a structure-warnings signal carrying evidence.code: "encrypted-content-not-analysed". Absence of a content-level signal in that report means nothing. Details.

Every family, its evidence keys, its benign causes and its exact severity rules: the field guide.

5.1 ?redact=payload

POST /api/v1/inspect?redact=payload returns the same report with every attacker-authored string elided: payload, name, recovered, authors, and since engine 1.42.0 before and after, become "[redacted]" and the signal gains evidence.payloadRedacted: true, so you can tell “nothing was found” from “you were not given it”. Nothing else changes: the score, the band, the severity, the cue categories and every count are identical, and a verdict policy is evaluated on the unredacted report, so your verdict cannot move with this flag.

It exists for one integration shape, and it is a common one: you are feeding documents to a model, and you want the finding without the sentence that was written to attack it. Any other value of redact is ignored rather than rejected. There is exactly one thing to redact, and nothing a typo here could silently switch off.

The MCP server does this unconditionally, and that is the one place where our safe default differs from this API's. An MCP tool result is a model's context, so returning the payload there would deliver the injection through our own tool with a trusted label on it. The MCP summary reports cue categories and counts instead; fetch the report over this API when a human needs to read the words.

curl -X POST 'https://tamperlens.com/api/v1/inspect?redact=payload' \
  -H 'Authorization: Bearer tl_...' \
  --data-binary @resume.pdf

5.2 receipt: proving later what we returned

We do not keep the report (an account's history keeps derived fields, not the report), so we cannot reproduce one to settle a dispute. Every /api/v1/inspect response therefore ends in a receipt: an HMAC signature over the exact body we returned, with the engine version and the time we issued it. It covers everything above it in the response, issuerBaseline, policy, retention and tenantMatches included, and after any ?redact=payload; it never covers itself. Keep the report and the receipt together. The receipt covers the report, not the file: it proves what we said, never what the document was.

"receipt": {
  "version": "v1",
  "issuedAt": "2026-08-15T19:32:25.147Z",
  "engineVersion": "1.43.0",
  "reportHash": "7543fa3f…76ef",
  "redacted": false,
  "signature": "…"
}

To check a pair later, POST /api/v1/receipt/verify with the report exactly as you received it and the receipt beside it. The call is keyed (a receipt is evidence about one customer's report, so the oracle stays off the anonymous internet), rate limited, and never counted against your quota: it parses no document.

curl -X POST https://tamperlens.com/api/v1/receipt/verify \
  -H 'Authorization: Bearer tl_...' \
  -H 'Content-Type: application/json' \
  -d '{"report": {…the report, receipt key optional…}, "receipt": {…}}'

# -> {"valid": true}
# -> {"valid": false, "reason": "bad_signature"}    we never issued this receipt
# -> {"valid": false, "reason": "report_mismatch"}  genuine receipt, different report

The signature is an HMAC, so only we can verify it; a published-key signature that anyone could check offline is the intended v2, and version is inside the signed bytes so a stored v1 receipt keeps verifying when it lands. The longer argument is on /security.

5.3 Regions, sha256, fingerprint, coverage and reasonCode

Engine 1.39.0 added five facts to the report. None of them is a signal and none moves riskScore, riskBand or signalCount: every test fixture scores exactly what it scored on 1.38.0. All of them are inside the receipt, and ?redact=payload leaves them alone because none of them carries text from the document.

  • evidence.regions, with regionsTotal and regionsTruncated beside it, on five families: redaction-exposure (each covered text run, and the /Rect of each redaction mark never applied), glyph-anomalies (the value off its line), running-balance-break (the printed row at each broken link), br-identifier-checksum (the printed line carrying each failing identifier) and hybrid-page (the whole page, as its /MediaBox). On the two families that also carry findings[], each region adds findingIndex, the index of the finding it locates. Each region is {"page", "x0", "y0", "x1", "y1"}: page 1-based, coordinates in points rounded to 0.01, at most 50 per signal. The space is the PDF's default user space with no /Rotate applied, and a MediaBox that does not start at the origin keeps its offset. That is the space the page is drawn in. To draw a box on screen, convert it with the page's own viewport (in pdf.js, page.getViewport() applies the rotation and the CropBox origin); do not rotate it yourself first. A region is four numbers and a page number, never text. The viewer in 5.4 does the drawing for you.
  • document.sha256: the SHA-256 of the bytes received, lowercase hex, on every media type. Use it to match a stored report to a stored file.
  • fingerprint (PDF only; null for an image, an Office file, an encrypted PDF and a triage report). issuerProfileHash is the SHA-256 of the structural profile an issuer baseline is built from: producer, creator, PDF version, cross-reference style, font set, page size, revision count and two flags. Genuine statements from one bank in different months share it, which is what it is for, so an equal hash is never evidence of copying. baseFile holds the clues that two files were saved from the same file: idPermanent (the trailer's /ID[0], hex), creationDate and fontSubsetTags (the six-letter prefixes of subset fonts, sorted, at most 20). The tags are a weak hint that depends on the generator: many draw them at random on each save and pdfTeX derives them from the glyphs used. Generators that number them are excluded: Chrome writes AAAAAA, BAAAAA, CAAAAA and so on, mPDF MPDF... Only tags that look drawn at random are kept, so the list is empty for a Chrome-printed PDF. Together with idPermanent they work as a pseudonym of the file: they identify a file, not a person, and carry no personal data.
  • coverage: {"scope", "blindSpots"}. scope is "triage" exactly when the report carries a triage block, "metadata-only" for an image, an encrypted PDF or a container that could not be walked, "partial" for a PDF whose analysis was cut short (see analysisIncomplete below), and "full" otherwise. blindSpots is a closed list, always in this order: page-content-not-walked (triage), pixels-not-decoded (an image, or a PDF that draws images), office-not-rendered (an Office file), encrypted-content, text-extraction-untrusted (extraction-anomalies fired) and clean-regeneration. The last one is on every report: a document generated whole by a forger, with coherent metadata and arithmetic, leaves nothing for a structural engine to find (the null result on /evidence).
  • summary.reasonCode: which rule produced the headline. The first rule that matches wins, in this order:
    1. SIGNED_BYTES_CHANGED: signature-integrity at high.
    2. CONTENT_REPLACED_AFTER_CREATION: incremental-updates at high.
    3. REDACTION_EXPOSED: redaction-exposure at high.
    4. APPENDED_AFTER_SIGNATURE: signature-coverage found bytes outside a signed range that are not later signatures (evidence.uncoveredExplained: false). A contract signed by a second party does not get this code.
    5. SIGNATURE_ONLY_REVISIONS: at least one finding at medium or above, and every finding above info, low included, is signature-coverage, incremental-updates or id-inconsistency marked as coming from later revisions that only add a signature or signature-validation data (evidence.exemptionReason: "signature-only-revision-capped-elevated"). This is a contract signed by a second party and nothing else, reported at elevated. Route on this code without parsing evidence. It fails closed: any other finding above info, even a low one, sends the report to the next rules.
    6. SEVERAL_FAMILIES: two or more families at medium or above.
    7. ONE_FAMILY: exactly one family at medium or above.
    8. LOW_SEVERITY_ONLY: findings, all of them low.
    9. NO_FINDINGS_PARTIAL_SCOPE: nothing above info, and the report did not read everything: coverage.scope is not "full", or text-extraction-untrusted or office-not-rendered is a blind spot, or the PDF is a scan (every page one image with no visible text).
    10. NO_FINDINGS: nothing above info.
    The codes describe what was observed, not what anyone did. NO_FINDINGS is not a statement that the document is authentic.
  • analysisIncomplete (engine 1.43.0, PDF only): present only when part of the analysis did not run. A complete report has no such key.
    • {"reason": "work-limit", "limit": "..."}: a limit on work, not on time, stopped it. 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, with Form XObject content counted every time it is drawn), 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"}: the 10-second clock ran out first. It is a safety net: the work limits are sized to stop a document well before it on our production server, but adversarial input, or a slower or busier machine, can still reach it. A report without time-budget was not touched by the clock; one with it can differ on a retry.
    • {"reason": "parse-error"}: the structural parse failed part-way.
    In every case coverage.scope is "partial", a report with no findings carries NO_FINDINGS_PARTIAL_SCOPE, and structure-warnings lists the same cut among its warning codes. A cut can also change the score of a real document: a cut parse cannot vouch for what a later signature revision touched, so a countersigned contract that hits one is not exempted and reads high instead of elevated. Until 1.42.0 the clock cut a dense document in silence, so the report could change with the load on the server.

5.4 Embed the viewer

<tamperlens-viewer> draws a report's evidence.regions on the PDF they came from: it renders the pages with pdf.js, maps each region through the page's own viewport (so /Rotate and the CropBox are handled), and outlines it in the colour and border style of the signal's severity, with the signal's title on hover and focus and a list of the findings under each page. The free checker on the home page uses it.

The file never leaves the browser. The viewer takes the PDF from memory and needs only the report JSON you already have. It takes no API key and makes no request carrying the document or the report.

<input type="file" id="pdf" accept="application/pdf">
<tamperlens-viewer id="viewer"></tamperlens-viewer>
<script type="module">
  import "/viewer/tamperlens-viewer.js";
  // report: the JSON your server got back from POST /api/v1/inspect
  // for this same file.
  document.getElementById("pdf").addEventListener("change", async (e) => {
    await document.getElementById("viewer").load(e.target.files[0], report);
  });
</script>

load() takes a File, Blob, ArrayBuffer or Uint8Array. Serve the viewer from your own origin, because browsers only start a pdf.js worker from the page's origin: it is the three files at /viewer/ (tamperlens-viewer.js, geometry.js, tamperlens-viewer.css) next to pdf.js 6.3.289 from the pdfjs-dist package (Apache-2.0) under vendor/pdfjs/. Under a strict CSP it needs script-src 'self' and connect-src 'self', and no 'unsafe-inline', blob: or 'wasm-unsafe-eval'. Set viewer.labels to translate its text and viewer.signalTitle to name signals your own way.

5.4 textChanges: which amounts of money the last revision changed

Engine 1.42.0, opt-in. Add ?changes=values to /inspect or /case and, when a PDF's last revision was appended to an earlier one, incremental-updates evidence compares the text layer of that last revision with the revision immediately before it and lists the amounts of money that changed. Without it, which is the default, the report carries none of the fields below; the free checker and the MCP server never ask for them. Any other value of changes is ignored, and mode=triage never compares. Nothing is rendered, only the last revision is compared, and it moves no severity and no score.

"textChanges": [
  {
    "revision": 2, "page": 1, "kind": "replaced", "tokenKind": "money",
    "before": "4.683,15", "after": "6.983,15",
    "region": { "page": 1, "x0": 410.62, "y0": 464.14, "x1": 453.43, "y1": 477.89 }
  }
],
"textChangesTotal": 6,
"textChangesTruncated": false,
"payloadIsUntrusted": true
  • What is reported: changed money amounts, and nothing else. tokenKind is always money: an amount with a decimal separator and exactly two decimals (4.100,00, 1,234.56, 279,55), optionally after R$, US$ or $, with thousands groups of exactly three digits and at most twelve integer digits. Each before and after is at most 40 characters and matches that pattern from end to end.
  • What is never reported. Words, labels and names; the amount written out in words; dates (a changed payment date or date of birth); numbers that are not money (quantities, percentages, codes); and any identifier, not even masked: CPF, CNPJ, RG, PIS, CEP, bank agency and account, card digits, phone numbers, boleto lines, registration numbers.
  • An amount that may be a piece of an identifier is silenced. Text is read after Unicode NFKC normalization, with spaces, dots, dashes, slashes and invisible characters taken out. A printed line whose own text holds a run of nine or more digits, or up to eleven digit groups that together pass a CPF's or a CNPJ's check digits, is not read at all. Around every other line the check reads at least two lines above and below, and further until it has 13 digits on each side (lines without a digit, and lines holding only complete amounts, do not count; at most 12 lines each way), joined without separators, and silences the line when such a run or group takes digits from the line itself. That catches a number typed with spaces, split across runs, columns or lines, padded with filler lines or rows of amounts, or printed in fullwidth or other scripts' digits. An amount that merely sits near a complete identifier is reported: it reveals nothing of it. An amount on the same line as an identifier label (CPF, CNPJ, RG, CNH, PIS, título, matrícula, conta, agência, cartão, telefone, CEP, nascimento, account, routing, SSN, phone, cuenta, DNI, NIE, NIF, RUT, CURP, IBAN, passport and similar, also when written with dots such as C.P.F.; a dot between two words or before digits, as in Doc.RG or R.G.1.234,56, still ends the label) is dropped, and so is one on the two lines below a label, unless the label's own line answers it with a complete value that can be checked: a CEP of eight digits, a CPF or a CNPJ that passes its check digits. It runs on both revisions, and a line silenced in either one is silent in both. The price is that some legitimate amounts are not reported: on 255 US court filings, 89.6% of the amounts are kept.
  • Residual risk, and why it is opt-in. An identifier written in money format with no recognizable label nearby (an account number printed as 12.345,67, or an RG split into two amount-shaped halves) cannot be told from an amount, and can be reported as one. So can the pieces of an identifier split more than 12 lines apart. A caller that stores reports or forwards them to anyone else should not send ?changes=values. One that does should treat before and after as document content; ?redact=payload elides them.
  • Still document content. The values were written by whoever wrote the file, so the signal carries payloadIsUntrusted: true when the list is not empty, and ?redact=payload and the MCP server replace before and after with "[redacted]", keeping kind, tokenKind and region.
  • kind. replaced: a different amount in the same place, or the one amount left on the same printed line. removed (after: null) and added (before: null): everything else. A value that only moved, for example below an inserted line, is not a change.
  • region follows the region contract: where the after-value is painted, or for removed where the before-value was. Null when the geometry was unusable.
  • At most 50 entries, top of the page first; textChangesTotal and textChangesTruncated say when the list is a sample.
  • textChangesSkipped means the comparison was declined and textChanges is empty: linearized (a linearized or hybrid-reference file, where the earlier revision is not a clean cut), too-many-revisions (more than 16) or budget (the second read would not fit what is left of the inspection's work budgets, or a page is too dense to read whole; the same for the same bytes) or time-budget (the clock ran out first; the report then also carries analysisIncomplete). When the fields are absent no comparison was attempted: an encrypted file, scope=triage, or a revision chain that could not be cut at a clean boundary.
  • An empty list is common and normal. A revision that only signs the document or only rewrites metadata paints the same text, so it reports no change. A figure changed only in the pixels of an image is not in the text layer and is not seen here.

6. Errors

All errors are JSON with an error string. Nothing about the document leaks into an error body.

Status Body When
400 {"error":"no_file"} multipart request with no file part
400 {"error":"malformed_multipart","message":"The multipart body could not be parsed. Check the boundary and send the document as one file part."} the multipart body could not be read: a broken or missing boundary, or a body cut off mid-part. Fix the request; retrying the same bytes gets the same answer
400 {"error":"too_many_files","limit":10} /case with more than 10 files. Nothing was analysed or billed
400 {"error":"not_enough_files","min":2} /case with fewer than 2 files. For one document, use /inspect
401 {"error":"invalid_api_key"} a key was presented and did not resolve
403 {"error":"origin_not_allowed"} a browser called from an origin that is not ours. Only a browser sends Origin, so a server-side caller can never see this
413 {"error":"file_too_large","maxMb":10} body or multipart file over the cap
413 {"error":"body_too_large","maxBytes":65536} the OTHER 413: the small body cap that guards every non-upload route. You sent something that was never a file: send the document as a file upload instead. maxBytes is the refusing route's own limit
413 {"error":"too_many_parts","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."} 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
408 {"error":"body_timeout","timeoutSeconds":30} the files of one /case did not finish arriving within 30 seconds; the connection is closed and nothing is billed
413 {"error":"case_too_large","maxTotalMb":30} the files of one /case add up to more than three times the upload cap
422 {"error":"not_a_pdf"} the bytes are not a format Tamperlens reads. The code predates Office and image support and is kept for the integrations that switch on it
422 {"error":"analysis_timeout","timeoutMs":15000} document exhausted the CPU budget; its worker was killed. Retrying is pointless: the input is the problem
422 {"error":"invalid_issuer"} the issuer you sent is not a slug (lowercase letters, digits, hyphens). An unknown slug is not an error: you get the normal report with no baseline comparison
422 {"error":"invalid_policy","message":"…"} the verdict policy could not be read, so nothing was decided with it. Refused rather than silently falling back to our defaults
422 {"error":"profile_rejected"} baseline training only. The profile the engine built failed our own privacy checks and was discarded rather than stored. Not something you can fix by retrying; the reason is logged on our side
422 {"error":"no_usable_documents","reports":[…]} /case where no file could be analysed; each entry of reports carries that file's own error. Nothing was billed
429 {"error":"quota_exceeded","quota":25,"used":25} keyed caller past its monthly quota and past the free buffer that follows it on paid plans. The refused call is not billed. Watch x-quota-state to see this coming: it reads within, then soft-ceiling once you are into the buffer, then exhausted
429 {"error":"rate_limited","retryAfterSeconds":60} called too fast: the anonymous per-network hourly limit, or any other limiter in the product. Every one of them answers with this code. Distinct from quota_exceeded on purpose: a quota is the plan you bought, a rate limit is the pace you called at
503 {"error":"busy","retryAfterSeconds":5} all workers busy and the queue full; Retry-After: 5 is set. Honest back-pressure: retry it
500 {"error":"internal_error"} our fault. Generic by design; internals never leak

Retry 503 with backoff. Do not retry 422. On 429 quota_exceeded, queue rather than drop: the counter resets monthly.

Quota headers. Every keyed response carries x-quota-limit, x-quota-used, x-quota-remaining, x-quota-ceiling and x-quota-state, on every request, not only when something is wrong, so there is no conditional to write. x-quota-ceiling is the number of documents served free past the quota before the API starts refusing; it is 0 on Free, where prepaid credits do that job instead. These are in the CORS allowlist, so browser code can read them cross-origin.

Note what is not an error: a file the parser cannot make sense of. Unparseable and hostile input degrades into a 200 carrying a structure-warnings signal rather than throwing. A garbage PDF is a finding, not a failure.

7. Rate limits and quotas

  • Anonymous: 10 documents per hour per client IP. No quota, no key, full report. Intended for the web checker and for trying the API out.
  • Keyed: no hourly rate limit; a monthly document quota per account, set by the plan and shared by every key on it. Usage is counted per successful inspection; a call refused for quota is not counted.
  • Concurrency: inspections run in a worker-thread pool with a bounded queue and a hard 15-second per-document deadline. When the queue is full the API sheds load with 503 busy and a Retry-After header rather than growing an unbounded latency tail.
  • Size: 10 MB per document.

Dashboard-session endpoints. GET /api/v1/me and GET /api/v1/usage authenticate with the dashboard session cookie, not a bearer key. A request carrying only Authorization: Bearer … gets 401 not_authenticated. They back the dashboard; the keyed API surface is /inspect, /compare and /baselines.

Month-to-date usage: GET /api/v1/me. Per-day breakdown for the current month: GET /api/v1/usage. Both are also rendered on the dashboard.

Plans, quotas, credit packs and what counts as one document: the pricing page. Every plan runs the identical engine: you pay for volume, never for better detection.

8. Privacy, retention and determinism

  • Without an account, no document and no history is kept. Uploaded bytes are parsed in memory and discarded when the response is written. No document content is logged, and there is no human review step. What persists is a usage-ledger row: which key, when, how many bytes, which risk band.
  • An account keeps a short derived record of each /inspect and /case document while its history is on (below), and the file itself only if it opted in.
  • No third parties in the analysis path. CPU-only, no ML models, no external data vendors, no outbound calls while inspecting.
  • Deterministic. Identical bytes produce an identical report apart from id and the blocks this section describes, which sit outside the engine's report. Pin fixtures, snapshot the reports, and regression-test us. That is a supported use of the free tier, and if a version bump changes a score we would rather you caught it than us.
  • Usage ledger. Rows for anonymous calls (which carry an IP address) are deleted after 90 days; keyed rows after 400 days.

Retention: what an account keeps

A call made for an account (a key that belongs to one, or the dashboard session) whose history is on keeps one record per analysed document: a closed list of derived fields (HMACs of the file's identifiers under a per-account salt, the issuer-profile hash, score, band, reason code, signal ids and severities, policy verdict and version, media type, a size band, engine version, dates). Never the file, its name, its text, a payload or an author. The privacy policy lists every column. History is on by default for accounts created since retention launched and off for older ones until they turn it on; records live 30, 90 or 365 days (90 by default).

Keeping nothing for one call or one key. Send X-Tamperlens-Retention: none, or set the key to retention: none with PATCH /api/v1/retention (keys: [{id, retention: "none"}]) from a signed-in dashboard session; the dashboard has no per-key switch. The header only ever turns keeping off: any value other than inherit counts as none, so a typo keeps nothing. The anonymous checker, a key with no account, /metadata, /compare and baseline training never keep anything.

The file itself is kept only when the account turned on file retention (7, 30 or 90 days, confirmed email required): encrypted with AES-256-GCM under a key of its own, so it can be downloaded or inspected again with a newer engine.

What a kept call returns

These blocks sit next to policy, outside the engine's report and inside the body the receipt signs, so a response pasted back into /api/v1/receipt/verify still verifies. Each is present only when it applies.

"retention": {
  "stored": true,
  "id": "insp_…",                 // the record id: the report's own id
  "expiresAt": "2027-01-11T10:00:00.000Z",
  "file": { "stored": true, "expiresAt": "2026-11-12T10:00:00.000Z" }
},
"tenantMatches": [
  { "inspectionId": "insp_…", "createdAt": "2026-09-30T14:02:11.000Z",
    "basis": ["id-permanent", "font-subset-tags"] }
]
  • retention: present only when a record was kept. file appears when the account keeps files: {stored: true, expiresAt}, or {stored: false, reason} with reason busy (no memory to spare right now), full (the storage set aside for kept files is used up) or error. The derived record is kept either way.
  • tenantMatches: up to 20 earlier records of the same account (of the same key, for a key caller) that share an origin with this file, other files first, newest first. basis is same-file, id-permanent, font-subset-tags or creation-instant, with the same specificity rules as /case, applied pair by pair. Never across accounts. A clue to review, never a verdict. Absent when nothing matches, on the account's first record, and whenever nothing was kept. Records kept by an older engine keep the identifiers in the form that engine computed. Because the receipt signs it, sharing a whole response shares those ids and dates.
  • fileRetention: on /case only, at the top level, as {stored: false, reason: "busy"} when the files of the case could not be kept; each report also says it in its own retention.file.

History, deletion and labels

A dashboard session sees and manages the whole account. An API key sees, deletes and labels only the records it made itself (a rotated key keeps its predecessor's), and never changes the account's settings. Another account's id, another key's id, an expired id and an unknown id all answer the same 404.

RouteWhoWhat it does
GET /api/v1/retentionsession or keymode (off or derived), days, bytesDays (7, 30, 90 or null), bytesAvailable, and the live keys with their own setting (a key sees only itself)
PATCH /api/v1/retentionsession onlyJSON {mode?, days?, bytesDays?, keys?: [{id, retention}]}, all or nothing. Shortening a period applies to what is already kept; bytesDays: null deletes every kept file
GET /api/v1/history?cursor&limitsession or keyNewest first, limit up to 100; pass nextCursor back as cursor. Each item is the derived record plus file, tenantMatchCount and feedback
GET /api/v1/history/:idsession or keyOne record with its labels and its tenantMatches
GET /api/v1/history/:id/filesession or keyThe kept file, decrypted and authenticated in full before the first byte, named <id>.<ext> (the original name was never kept)
POST /api/v1/history/:id/reinspectsession or keyInspects the kept file again with the current engine; costs one document, keeps nothing new, and adds reinspection: {of, storedEngineVersion} inside the receipt
DELETE /api/v1/history/:idsession or keyDeletes one record with its labels and its file
DELETE /api/v1/history?sha256=session or keyDeletes every record of that file; the server computes the account's HMAC
DELETE /api/v1/history?confirm=allsession onlyDeletes everything the account kept and discards its salt
POST /api/v1/feedbacksession or keyJSON {inspectionId, signalId?, label}, label one of confirmed-fraud, genuine, false-positive, unsure. No free text; a new label for the same target replaces the old one

What deleting does. The record is gone from the database at once; leftover bytes in the database's files on disk go with the next daily sweep at the latest, and database backups age out within 60 days. A kept file is unreadable at once, because its key goes with the record, except in the hosting provider's whole-disk images until they rotate out (seven days).

Errors on these routes use the same {error, message} shape: account_required and session_required (403), email_unverified (403, turning file retention on), inspection_not_found and file_not_retained (404), file_expired (410), retention_bytes_unavailable (409 on PATCH, 503 on a download: this server cannot keep files), no_live_key (409, a session with no key to bill a re-inspection to), file_unreadable (500, the file failed authentication), busy (503 with Retry-After, when too many kept files are being served or inspected at once), invalid_api_key and not_authenticated (401), key_not_found (404, an unknown key id in PATCH), and 400 for a malformed body or query (invalid_*, unknown_field, unknown_signal, confirm_required, ambiguous_delete). A re-inspection also answers every error /inspect can.

The long version. How uploads are handled, what is retained, the subprocessor list. Is on the security page, and the commitment itself is in the privacy policy.

9. Disclaimer posture

Every report carries this string, and it is the product's actual position rather than legal garnish:

Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.

Concretely, what that commits us to:

  • No verdict of ours. There is no "fraud": true, no "authentic" boolean, and there will not be one. The report describes what is true of the bytes and stops there.
    The verdict policy is not an exception to this, and it is worth being exact about why. That block appears only when you send a rule, it is computed from thresholds you chose, and it changes no score, no band and no signal. It is your judgment echoed back with its inputs shown, which is the opposite of a vendor deciding for you, and the reason the code that evaluates it is deliberately not part of the engine.
  • Benign causes are documented per signal, in the field guide, at the same level of detail as the malicious ones. A legitimately re-saved PDF will fire signals. That is not a bug; it is what "this file was modified after creation" means.
  • Route, do not auto-reject. Map bands to review queues. Any design that declines a person on a single structural signal will decline real customers who opened a statement in Preview.
  • Stated limits. No OCR, no transaction parsing, no balance arithmetic, no identity or account-ownership check, no signature-cryptography validation, and no ability to detect a clean forgery generated once from a fake template. A determined adversary who knows about revision history can flatten it.

Next