DEV Community

Haruo
Haruo

Posted on Originally published at tamperlens.com

Find out whether a PDF was edited, with one curl and no signup

The thing that took me longest to internalise about this file format: a PDF is not a picture of a document. It is an append-only container with a linked list of revisions in it, and it keeps receipts about its own history whether or not anyone wanted it to.

Which means "was this edited after it was made?" is often answerable from the bytes, deterministically, with no model involved. Here is how to ask, in one command, without an account.

The call

curl -s https://tamperlens.com/api/v1/inspect \
  -F file=@statement.pdf
Enter fullscreen mode Exit fullscreen mode

No Authorization header. Anonymous callers get the full report, rate limited to 10 documents per hour per IP. The cap is 10 MB per document, the bytes are parsed in memory and discarded when the response is written, and there is no human review step.

A raw body works too, if your HTTP client makes multipart expensive:

curl -s https://tamperlens.com/api/v1/inspect \
  -H "Content-Type: application/pdf" \
  --data-binary @statement.pdf
Enter fullscreen mode Exit fullscreen mode

The format is decided by sniffing the bytes, never from Content-Type or the filename. A phone that renamed a JPEG to .pdf is analysed as the JPEG it is. Word, Excel and PowerPoint files and JPEG, PNG, WebP, HEIC and AVIF images go to the same endpoint and come back in the same report shape, with a mediaType discriminator.

What comes back

{
  "id": "insp_2f1e9c04-...",
  "engineVersion": "1.37.0",
  "summary": {
    "riskScore": 10,
    "riskBand": "low",
    "signalCount": 1,
    "revisions": 1
  },
  "signals": [
    {
      "id": "producer-fingerprint",
      "severity": "low",
      "title": "PDF editing tool in the production chain (iLovePDF)",
      "detail": "The document's metadata names iLovePDF in its production chain. ...",
      "evidence": {
        "tools": ["iLovePDF"],
        "origin": "pdf-editor",
        "producer": "iLovePDF",
        "creator": "iText 7.2.5",
        "revisions": 1
      }
    }
  ],
  "document": {
    "pages": 2,
    "producer": "iLovePDF",
    "creator": "iText 7.2.5",
    "creationDate": "2026-01-04T10:02:00.000Z",
    "modDate": "2026-01-06T18:41:00.000Z",
    "encrypted": false,
    "signed": false,
    "revisions": 1,
    "sizeBytes": 184320
  },
  "disclaimer": "Tamperlens reports risk signals, not authenticity verdicts. ..."
}
Enter fullscreen mode Exit fullscreen mode

Three things to know before you write any code against it.

riskBand is low under 30, elevated 30 to 69, high at 70 and above, and the high band is narrow on purpose: it 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. That is a contract, not an accident of the arithmetic.

signals[].id is a stable string. Branch on ids rather than on the score when you know your own domain, and you will: if your flow legitimately involves customers signing documents, you want incremental-updates weighted down (a signature is an incremental update) and signature-coverage weighted up.

signals[].evidence is the part a human reviewer needs. It is small, it contains no document content, and it is per-family and additive, so read keys defensively. Store it next to the decision it produced.

Build a positive before you trust a negative

This is the part I would actually do first, and it takes about four minutes.

Take a PDF whose history you personally know: something you generated, or a statement you downloaded straight from a bank. Inspect it. Then run the same file through any free browser-based PDF editor, change one number, download it, and inspect that.

You can watch the mechanism before the API even answers:

$ grep -c '%%EOF' statement.pdf
2
Enter fullscreen mode Exit fullscreen mode

A PDF ends with %%EOF. Two of them is the format's own record that something was appended after the original save. Every appended cross-reference section's trailer carries /Prev, the byte offset of the previous one, which is a backwards linked list you can walk to the original document.

The count alone is weak evidence, and you should treat it that way: signing a document is an incremental update, so is filling in a form field, and a linearised "fast web view" file legitimately has two markers from a single save. The strong version is what the appended revision replaced. A later revision that replaces an object which already existed, and that carries page or content-stream data, has no reading compatible with "the issuer generated this and nobody touched it."

Watching which signals light up on a file whose history you know is worth more than any vendor's page, including mine.

Send your decision rule with the request

You will otherwise reimplement if (riskScore >= 70) on your side, slightly differently from everyone else, and invisibly to whoever you ask about it later.

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

The response gains one block and nothing else changes:

"policy": {
  "verdict": "reject",
  "riskScore": 45,
  "thresholds": { "review": 30, "reject": 70 },
  "reason": "signal",
  "triggeredBy": ["redaction-exposure"]
}
Enter fullscreen mode Exit fullscreen mode

Four behaviours worth knowing, because each is a decision:

  • The defaults are the engine's own band boundaries, so an unconfigured policy agrees with riskBand instead of quietly disagreeing with it.
  • A named family beats a threshold, and rejectOn beats reviewOn. Naming a family means it matters regardless of what the arithmetic came to, and a threshold that could veto that would make the rule advisory.
  • Unknown signal ids are accepted and never match. A family we rename must not be able to take your pipeline down.
  • A malformed policy is a 422, 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.

Errors worth writing code for

Status Body Do
413 {"error":"file_too_large","maxMb":10} check size client-side first
422 {"error":"analysis_timeout","timeoutMs":15000} do not retry, the input is the problem
429 {"error":"rate_limited","retryAfterSeconds":60} you called too fast
429 {"error":"quota_exceeded","quota":25,"used":25} queue, do not drop, the counter resets monthly
503 {"error":"busy","retryAfterSeconds":5} retry with backoff, Retry-After is set

rate_limited and quota_exceeded are deliberately distinct: a quota is the plan you bought, a rate limit is the pace you called at.

And the one that catches people out: a file the parser cannot make sense of is not an error. Unparseable and hostile input degrades into a 200 carrying a structure-warnings signal. A garbage PDF is a finding, not a failure, and treating it as a 5xx would mean throwing away the most interesting document of the day.

Two properties you can build tests on

It is deterministic. Identical bytes produce an identical report apart from id. Pin your fixtures, snapshot the reports, and regression-test the vendor: that is an explicitly supported use of the free tier. Pin engineVersion while you are there, because a bump means the scoring weights may have moved.

Nothing is stored. No document is written to disk, no document content is logged, no third party is in the analysis path, and no outbound call happens while inspecting. What persists is a usage row that holds no document.

If the caller is an agent

An agent that opens an attachment has already read whatever the attachment says, and a PDF is a convenient place to put a sentence addressed to the model rather than to the person. So the useful order is inspect, then read: one call before the bytes reach your model's context, branching on the injection families exactly as you would on any other signal.

The payload does not have to travel with the finding:

curl -s -X POST 'https://tamperlens.com/api/v1/inspect?redact=payload' \
  --data-binary @resume.pdf
Enter fullscreen mode Exit fullscreen mode

Every attacker-authored string is elided and the signal gains evidence.payloadRedacted: true, so "nothing was found" stays distinguishable from "you were not given it". The score, the band and every count are identical.

There is an MCP server over the same endpoints, npx tamperlens-mcp, which redacts unconditionally, because an MCP tool result is a model's context.

What a clean report does not mean

The honest limit, and I would rather write it than have you find it.

The engine reports what is true of the bytes. A structural signal can be defeated: flattening a page to a raster image throws away almost everything there is to read, printing the file from a browser rewrites its whole account of itself, and stripping the metadata and then rewriting the file removes both the metadata and the history in one go. I published the whole matrix of what defeats each of my own signals, with the cells I got wrong, because the alternative is a reader who sees a clean report and hears "this document is genuine".

Those are different claims. A clean report means the bytes are clean.

So the report is a set of signals and never a verdict. "This file contains two revisions, a later revision replaced a page content stream, and the Info dictionary names an online PDF editor that the XMP packet does not" is a statement about bytes that a human can act on. "This document is fraudulent" is a statement about a person, and a parser does not get to make it.


The one-screen quickstart is at tamperlens.com/api, the full schema and every error code at /api-reference, and the machine-readable spec with a try-it console at /docs. If you would rather drag a file into a page than write a request, the free checker returns the same report, with the raw evidence under every finding and no account involved.

If a signal fires on something you know to be completely innocent, that is the comment I want most. Those are the interesting ones.

Top comments (0)