API reference

Every behaviour of the API, and why each one is the way it is. The quickstart is the one-screen version.

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

2. Making a request

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

Three media, one endpoint and one report shape. PDFs; Word, Excel and PowerPoint documents (.docx, .xlsx, .pptx and their macro-enabled twins); and JPEG, PNG, WebP, HEIC/HEIF and AVIF images. TIFF, GIF and BMP are recognised and reported but their containers are not walked — such a report says so in a signal, because an empty result must never be mistaken for a clean one.

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

multipart/form-data

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

raw application/pdf body

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

Node — raw body, no multipart encoder needed

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

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

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

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

Python

import os, requests

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

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

Verifying a file against the original: /compare

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

Compare a resubmitted file against the one on record

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

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

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.

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

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"]
}
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.

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.13.3",

  "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. …",
      "message": {                      // engine 1.8.0 — `detail` as a code plus its values
        "code": "producer-fingerprint.base",
        "params": { "tools": "iLovePDF" }
      },
      "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 eleven family ids are stable strings. Branch on these when you want per-signal weighting rather than a single threshold, which you will: if your flow legitimately involves customers signing documents, you want incremental-updates weighted down and signature-coverage weighted up.
  • signals[].evidence — store it. It is small, it contains no document content, and it is what a human reviewer needs. Treat the shape as per-family and additive; do not assume keys are present.
  • signals[].message (engine 1.8.0) — the same paragraph as detail, but as a stable code plus the params interpolated into it. Use it if you present findings in a language other than English, or in your own words: the params are values (offsets, counts, object numbers), so your sentence stays exactly as accurate as ours. detail is rendered from this and is unchanged, so nothing that reads detail today needs to move. A signal may also carry notes[] — qualifying sentences appended after the main one, in the same shape; detail is the concatenation.
  • 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.

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
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
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
429 {"error":"quota_exceeded","quota":25,"used":25} keyed caller over its monthly quota. The refused call is not billed
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.

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

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

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