API quickstart

One endpoint, two request forms, one JSON report. No SDK required and none provided.

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

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.

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

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 policy key 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: 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 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 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.
  • document.encrypted — when true, five 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.

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

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