Tamperlens has a deliberately small surface: POST /api/v1/inspect
takes a document or an image and returns structural fraud-risk signals as JSON.
Everything else on
this page is auth, limits and error handling. The machine-readable spec, with a
try-it-out console, is at /docs.
1. Get a key
Create an account — email and password, no card. Signup issues a Free key immediately, with a quota of 25 documents a month.
or from the shell
curl -s -c /tmp/tl.jar https://tamperlens.com/api/v1/auth/signup \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"a-long-enough-password"}'
# -> {"user":{…},"apiKey":{"plaintext":"tl_…","plan":"free","quota":25}}
The key is shown exactly once. Only a SHA-256 hash is stored,
so it cannot be re-displayed — the dashboard shows a non-reversible preview
that reveals nothing about the secret. If you lose it, rotate it from
the dashboard or
POST /api/v1/keys/rotate; rotation revokes the old key and issues
a new one.
You can also call the endpoint with no key at all. Anonymous calls work and return the full report — they are rate-limited by client IP rather than metered against a quota. That is the mode the free web checker uses. It is fine for trying things out and wrong for production.
2. 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 that key's monthly quota, 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.
3. 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 and WebP images.
HEIC, 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.
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.
From an agent (MCP)
If the caller is an LLM agent rather than your own code, there is an
MCP server that exposes the same
two endpoints as three tools — inspect_document,
check_redaction and compare_documents. 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. The server runs from a local build — clone
the repo, npm run build, and point your MCP host at
dist/mcp/index.js as above. A one-command install needs it
extracted into its own package, because this one carries the API server and
would make an install compile a native database driver for a client that only
speaks HTTP. Ask if you want it and it moves up the list.
4. Issuer baselines
The eleven 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 “uploaded documents are never stored” stays literally true with this feature enabled.
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.
5. Your verdict policy
Tamperlens does not decide whether a document is fraudulent, and
section 11 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"]
}
| 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 |
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.
- Unknown signal ids are accepted 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. -
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.
6. 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.2.0",
"summary": {
"riskScore": 95, // 0-100, weighted aggregation — not a probability
"riskBand": "high", // "low" <30 · "elevated" 30-69 · "high" >=70
"signalCount": 2, // signals above severity "info"
"revisions": 1 // 1 = never incrementally updated
},
// Most severe first, then alphabetically by id. At most one entry per family.
"signals": [
{
"id": "producer-fingerprint", // one of the stable family ids
"severity": "high", // "info" | "low" | "medium" | "high"
"title": "Consumer editing tool in the production chain (iLovePDF)",
"detail": "The document's metadata names iLovePDF in its production chain. …",
"evidence": { // shape varies per family; always machine-readable
"tools": ["iLovePDF"],
"producer": "iLovePDF",
"creator": "iText 7.2.5",
"revisions": 1,
"fullPageImagePages": [1] // corroboration: this is why it is "high"
}
},
{
"id": "id-inconsistency",
"severity": "medium",
"title": "Trailer /ID marks the file as changed since creation",
"detail": "The trailer /ID array holds two different identifiers. …",
"evidence": {
"idOriginal": "8f2c...a91b",
"idCurrent": "41de...77c0",
"trailerCount": 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, // one revision, but a divergent /ID: rewritten whole
"sizeBytes": 184320
},
"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 ten 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. -
document.encrypted— when true, five 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.
7. 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 |
| 401 | {"error":"invalid_api_key"} |
a key was presented and did not resolve |
| 413 | {"error":"file_too_large","maxMb":10} |
body or multipart file over the 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 |
| 429 | {"error":"quota_exceeded","quota":25,"used":25} |
keyed caller over its monthly quota. The refused call is not billed |
| 429 | rate-limit body | anonymous caller over the per-IP hourly limit |
| 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.
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.
8. 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 key, set by the plan. 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.
9. Pricing
| Plan | Price | Documents / month | Notes |
|---|---|---|---|
| Free | $0 | 25 | Full report, no watermark, no card. Plus the web checker. |
| Solo | $15/mo | 200 | Full API access. Card, self-serve. |
| Starter | $49/mo | 1,000 | Adds private issuer baselines. No call, no contract. |
| Growth | $199/mo | 10,000 | Adds priority email support. |
Every plan gets the same engine, the same signals and the same latency. There is no enterprise tier that unlocks better detection, because there is no better detection to unlock. The one feature split is private issuer baselines (Starter and up) — that gates a data store you train, not the engine; the built-in public baselines work on every plan.
Upgrades and downgrades are self-serve from the dashboard (Stripe Checkout and the Stripe billing portal). Cancelling returns the key to the Free plan and its 25-document quota rather than disabling it. Past 10,000 documents a month, email — the honest answer is that we would rather talk about your volume than guess at a price for it.
25 documents a month, free, right now
Create an account and the key is on screen in about fifteen seconds. Or try it with no account at all: the free checker runs the same engine on a drag-and-dropped file.
10. Privacy and determinism
- Nothing is stored. Uploaded bytes are parsed in memory and discarded when the response is written. No document is written to disk, 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.
- 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. 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. - Retention. Usage-log rows for anonymous calls (which carry an IP address) are deleted after 90 days; keyed rows after 400 days.
11. 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 reference Machine-readable spec and a try-it-out console for every endpoint.
- Fraud signals All eleven 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.