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.contentTouchedis 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/IDpair, 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 samedocument.sha256. Benign cause: the same statement sent twice. -
same-base-file: different files share the permanent half of the trailer/ID(evidence.basisincludesid-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/IDfor every file it writes, a generator that numbers its tags instead of drawing them (Chrome writesBAAAAA,CAAAAAand 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,Keywordsand 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
.docxis 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/baselineslists your profiles and the built-in ones, withbuiltIntelling them apart and a per-dimensionconsistencyfigure for each. -
DELETE /api/v1/baselines/:issuerremoves one of yours. A built-in profile is shared and answers404. - 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,reviewOnandignore, 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. Apresetname other than the three above is malformed too. -
Omit it and nothing changes. The
policykey 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:highis 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[].idandseverity. 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 wantincremental-updatesweighted down andsignature-coverageweighted 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 asdetail, but as a stablecodeplus theparamsinterpolated 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.detailis rendered from this and is unchanged, so nothing that readsdetailtoday needs to move. A signal may also carrynotes[]: qualifying sentences appended after the main one, in the same shape;detailis the concatenation. -
evidence.payloadIsUntrusted: present andtrueondocument-injection-markersandoffice-injection-markers, and since engine 1.30.0 onoffice-hidden-contentandoffice-tracked-changes, and since engine 1.42.0 onincremental-updateswheneverevidence.textChangescarries a value (§5.4). It means the strings underevidence.fields[].payload,evidence.fields[].name,evidence.recovered,evidence.authorsandevidence.textChanges[].before/.afterwere 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 intitle,detailormessage.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 astructure-warningssignal carryingevidence.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, withregionsTotalandregionsTruncatedbeside it, on five families:redaction-exposure(each covered text run, and the/Rectof 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) andhybrid-page(the whole page, as its/MediaBox). On the two families that also carryfindings[], each region addsfindingIndex, 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/Rotateapplied, 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;nullfor an image, an Office file, an encrypted PDF and a triage report).issuerProfileHashis 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.baseFileholds the clues that two files were saved from the same file:idPermanent(the trailer's/ID[0], hex),creationDateandfontSubsetTags(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 writesAAAAAA,BAAAAA,CAAAAAand so on, mPDFMPDF... Only tags that look drawn at random are kept, so the list is empty for a Chrome-printed PDF. Together withidPermanentthey work as a pseudonym of the file: they identify a file, not a person, and carry no personal data. -
coverage:{"scope", "blindSpots"}.scopeis"triage"exactly when the report carries atriageblock,"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 (seeanalysisIncompletebelow), and"full"otherwise.blindSpotsis 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-anomaliesfired) andclean-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:SIGNED_BYTES_CHANGED:signature-integrityathigh.CONTENT_REPLACED_AFTER_CREATION:incremental-updatesathigh.REDACTION_EXPOSED:redaction-exposureathigh.APPENDED_AFTER_SIGNATURE:signature-coveragefound 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.SIGNATURE_ONLY_REVISIONS: at least one finding atmediumor above, and every finding aboveinfo,lowincluded, issignature-coverage,incremental-updatesorid-inconsistencymarked 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 atelevated. Route on this code without parsing evidence. It fails closed: any other finding aboveinfo, even alowone, sends the report to the next rules.SEVERAL_FAMILIES: two or more families atmediumor above.ONE_FAMILY: exactly one family atmediumor above.LOW_SEVERITY_ONLY: findings, all of themlow.NO_FINDINGS_PARTIAL_SCOPE: nothing aboveinfo, and the report did not read everything:coverage.scopeis not"full", ortext-extraction-untrustedoroffice-not-renderedis a blind spot, or the PDF is a scan (every page one image with no visible text).NO_FINDINGS: nothing aboveinfo.
NO_FINDINGSis 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.limitnames 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,000Tj,TJ,'and"per document),forms-per-page(400),form-content-bytes(1 MiB a form),form-invocations(2,000 a page),form-depth(8) orgraphics-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 withouttime-budgetwas not touched by the clock; one with it can differ on a retry.{"reason": "parse-error"}: the structural parse failed part-way.
coverage.scopeis"partial", a report with no findings carriesNO_FINDINGS_PARTIAL_SCOPE, andstructure-warningslists 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 readshighinstead ofelevated. 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.
tokenKindis alwaysmoney: an amount with a decimal separator and exactly two decimals (4.100,00,1,234.56,279,55), optionally afterR$,US$or$, with thousands groups of exactly three digits and at most twelve integer digits. Eachbeforeandafteris 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 inDoc.RGorR.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 treatbeforeandafteras document content;?redact=payloadelides them. -
Still document content. The values were written by whoever
wrote the file, so the signal carries
payloadIsUntrusted: truewhen the list is not empty, and?redact=payloadand the MCP server replacebeforeandafterwith"[redacted]", keepingkind,tokenKindandregion. -
kind.replaced: a different amount in the same place, or the one amount left on the same printed line.removed(after: null) andadded(before: null): everything else. A value that only moved, for example below an inserted line, is not a change. -
regionfollows the region contract: where the after-value is painted, or forremovedwhere the before-value was. Null when the geometry was unusable. -
At most 50 entries, top of the page first;
textChangesTotalandtextChangesTruncatedsay when the list is a sample. -
textChangesSkippedmeans the comparison was declined andtextChangesis empty:linearized(a linearized or hybrid-reference file, where the earlier revision is not a clean cut),too-many-revisions(more than 16) orbudget(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) ortime-budget(the clock ran out first; the report then also carriesanalysisIncomplete). 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 busyand aRetry-Afterheader 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
/inspectand/casedocument 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
idand 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.fileappears when the account keeps files:{stored: true, expiresAt}, or{stored: false, reason}withreasonbusy(no memory to spare right now),full(the storage set aside for kept files is used up) orerror. 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.basisissame-file,id-permanent,font-subset-tagsorcreation-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/caseonly, 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 ownretention.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.
| Route | Who | What it does |
|---|---|---|
GET /api/v1/retention | session or key | mode (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/retention | session only | JSON {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&limit | session or key | Newest 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/:id | session or key | One record with its labels and its tenantMatches |
GET /api/v1/history/:id/file | session or key | The 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/reinspect | session or key | Inspects 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/:id | session or key | Deletes one record with its labels and its file |
DELETE /api/v1/history?sha256= | session or key | Deletes every record of that file; the server computes the account's HMAC |
DELETE /api/v1/history?confirm=all | session only | Deletes everything the account kept and discards its salt |
POST /api/v1/feedback | session or key | JSON {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
- OpenAPI explorer Machine-readable spec and a try-it-out console for every endpoint.
- Fraud signals All twenty signal families, evidence keys, benign causes, severity logic.
- Bank statement screening How to wire signals into a lending or onboarding decision.
- PDF metadata forensics What the engine is actually reading, structure by structure.