Bank statement fraud detection, as an API

For the engineer who has an upload endpoint, a growing pile of statement PDFs, and no procurement budget.

If your product accepts a bank statement as proof of income, address or balance, you have a document-fraud problem whether or not you have a document-fraud programme. This page is about the shape of that problem, and about a small, self-serve piece of it: reading what a PDF records about its own editing history and handing that back to your decision logic as JSON.

Why statements get forged

A bank statement is the most widely accepted, least verifiable document in consumer finance. It is asked for by lenders and BNPL underwriters as income and affordability evidence, by letting agents and proptech platforms as proof of rent affordability, by gig and marketplace platforms during payout onboarding, by neobanks as proof of address during KYC, and by brokers at refresh time. In most of those flows it arrives as a PDF the applicant uploaded, and in a lot of them nothing downstream ever compares it against the issuing bank.

That combination: high decision weight, self-supplied artefact, no authoritative counterparty. Is what makes it attractive. Open banking removes the problem where it is available and the applicant consents; the PDF upload path is the fallback that stays open, and the fallback is where the pressure goes.

Open banking closes this where it is available and the applicant consents. The PDF-upload path is the fallback that stays open, and the fallback is where the pressure goes.
FLOWS THAT ASK FOR A STATEMENT lending and BNPL affordability letting agents and proptech gig and marketplace payouts neobank proof of address broker refresh open banking: a connection to the issuing bank closes the problem where it exists and the applicant consents a PDF the applicant uploaded nothing downstream ever compares it against the issuing bank WHAT MAKES THE LOWER ROUTE ATTRACTIVE High decision weight · a self-supplied artefact · no authoritative counterparty: all three at once.

The incentive is not always criminal-syndicate scale. A great deal of it is a single applicant, ten minutes before a deadline, who is £400 short of a threshold and has just discovered that a free online PDF editor will let them retype a number. That population is large, unsophisticated, and, for exactly that reason, detectable.

What naive statement fraud looks like

Sophisticated document fraud exists, and nothing on this page claims to stop it. But the everyday volume falls into a small number of recognisable patterns:

  • Edit the real thing. Download the genuine statement from online banking, open it in a browser-based PDF editor, white out a figure, type a new one, download the result. Total elapsed time: two minutes. The editor writes its own name into the file's metadata and, depending on the tool, either appends an incremental update or rewrites the whole document.
  • Print, alter, rescan. Or its digital equivalent: render the statement to an image, edit the image, wrap it back into a PDF. This defeats text-level checks and produces a file whose pages are full-page rasters. Very often the forger then wants the numbers to be selectable and adds a text layer, or the edit tool drops vector text on top of the image.
  • Template generation. Buy or build a statement template, populate it with invented transactions, export. These files can look immaculate on screen and carry entirely coherent metadata, because they were genuinely generated once by a single tool. Structural forensics is weak here; what betrays them is usually layout and typography, a different class of check, or the one arithmetic this engine does run: a balance column that stops adding up (running-balance-break).
  • Reuse someone else's. The same PDF, resubmitted with a name changed, or an unaltered statement belonging to a different person. Nothing structural is wrong with the file at all.
  • Sign, then modify. Where statements are digitally signed, common in parts of the EU and LATAM. Appending an incremental update after signing leaves the signature intact over the original byte range and the new content outside it. Some viewers show this clearly; users click through it.
File structure reaches three of the five common patterns. The other two leave nothing structural wrong with the file at all.
THE PATTERN WHAT FILE STRUCTURE SEES Edit the real thing in a browser-based PDF editor an editor in the chain, and a second revision Print, alter, rescan full-page rasters, and visible glyphs drawn over them Template generation almost nothing: it really was written once, by one tool Reuse somebody else's statement nothing at all: the file is genuine and untouched Sign, then modify content sitting outside the signature's byte range

Sophisticated document fraud exists and nothing on this page claims to stop it. The two dashed rows are where an honest tool says so rather than implying coverage.

The traces it leaves

A statement is a machine-generated document. That is the whole basis of the method: the file is supposed to have been written exactly once, by a server-side library, in one pass, with one set of metadata and one font subset per typeface. Every deviation from "written once by one tool" is a question you can ask the applicant.

Every trace below is the same finding in a different place: this file was not written once, in one pass, by one tool.
WHERE EACH TRACE LIVES body objects xref trailer /Info · XMP signature dictionary two subsets of one typeface visible vector text drawn over a full-page scan more than one revision strongest when a later replaces page content a consumer editor in the production chain Info and XMP disagreeing bytes outside the /ByteRange WHY THE BASELINE IS SO STRICT A statement is machine-generated: written exactly once, by a server-side library, with one metadata set and one font subset per typeface. Every deviation is a question to put to the applicant, never an answer. Each one has a benign explanation documented beside it in the field guide.
  • More than one revision. Bytes appended after the first %%EOF. Strongest when a later revision overwrites an object that already existed and that carries page, content-stream or image data: the rendered appearance changed after generation.
  • A consumer editor in the toolchain. Online converters, desktop PDF editors, image editors, office re-save pipelines. A core banking system does not emit statements through Photoshop.
  • Two metadata stores that disagree. Info dictionary versus XMP packet. Editors frequently update one and forget the other, and sometimes only one of them names the editor at all.
  • Two subsets of one typeface. Glyphs of the same font embedded on two separate occasions: the signature of text added to an existing document by a second tool.
  • Visible vector text over a full-page scan. Legitimate scanned documents carry either no text layer or an invisible OCR layer. A handful of visible glyph runs over a raster page is how values on a scan get altered.
  • Bytes outside a signature's /ByteRange. Content appended after signing, provable by comparing two integers, independent of whether the certificate validates.

Each of these is documented in full, including the benign explanations, which matter more than the malicious ones for anyone building a review queue, in the field guide. The manual version of the same checks is in how to detect if a PDF was edited.

Why enterprise tooling is out of reach

Document-fraud detection is a solved commercial problem at the top of the market. The established platforms are genuinely good and genuinely enterprise-shaped: a demo request form instead of a signup page, a sales cycle, an MSA, an annual minimum, and a per-document price that assumes you are processing at institutional volume. Several of them also involve a human-in-the-loop review step, which means latency measured in minutes and your applicants' documents sitting in someone else's review queue.

If you are three engineers at a seed-stage lender processing a few hundred statements a month, none of that is buyable. The usual result is that the upload endpoint ships with no document checks at all and the problem is rediscovered after the first loss.

Tamperlens is deliberately the small end of that market: sign up, get a key, make an HTTP call. No sales conversation, no minimum, no review queue. The trade-off is honest. It is a structural-forensics engine, not a full fraud platform, and it returns evidence rather than decisions.

The uploaded bytes exist for the length of one request and are gone when the response is written.
ONE REQUEST, START TO FINISH your service POSTs parsed in memory, CPU only report JSON written bytes discarded over HTTPS WHAT DOES NOT HAPPEN ANYWHERE ON THAT LINE Written to disk · logged · sent to a third party · queued for a human reviewer For a compliance team, “we never hold the file” is a materially shorter conversation than one about a review queue.

Stateless by design. Uploaded bytes are parsed in memory and discarded when the response is written. No document is stored, no document is logged, nothing is sent to a third party, and there is no human review step. For a compliance team, "we never hold the file" is a materially easier conversation than a data-processing addendum about a review queue.

The API, in one call

One endpoint: POST /api/v1/inspect. It takes either multipart/form-data with a file field, or a raw application/pdf body, up to 10 MB. Authentication is a bearer key. Free tier: 50 documents a month, no card.

multipart

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

raw body, no multipart encoding, useful from a worker

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

A response for a statement that was run through a free online editor looks like this. detail strings are abridged here for width; in the real response they are full paragraphs, and evidence carries the offsets and object numbers behind each claim.

200 OK

{
  "id": "insp_2f1e9c04-7b3a-4d51-9f0e-6c2a18d4bb71",
  "engineVersion": "1.37.0",
  "summary": {
    "riskScore": 100,
    "riskBand": "high",
    "signalCount": 3,
    "revisions": 2
  },
  "signals": [
    {
      "id": "incremental-updates",
      "severity": "high",
      "title": "Page content was replaced after the document was generated",
      "detail": "The file contains 2 revisions. A later revision re-writes 2 object(s) that already existed in an earlier revision and that carry page, content-stream or image data (objects 12, 14). …",
      "evidence": {
        "revisions": 2,
        "updatesAfterCreation": 1,
        "eofOffsets": [118842, 184102],
        "startxrefValues": [118201, 183640],
        "revisionChainBroken": false,
        "contentObjectsOverwritten": [
          { "obj": 12, "kind": "content" },
          { "obj": 14, "kind": "page" }
        ]
      }
    },
    {
      "id": "producer-fingerprint",
      "severity": "medium",
      "title": "PDF editing tool in the production chain (iLovePDF)",
      "detail": "The document's metadata names iLovePDF in its production chain. Unlike a word processor or a design tool, these take an existing PDF as input and write a new one … The file also carries 2 revisions, so it was written more than once.",
      "evidence": {
        "tools": ["iLovePDF"],
        "editorTools": ["iLovePDF"],
        "origin": "pdf-editor",
        "matches": [
          { "field": "Info:Producer", "value": "iLovePDF", "tool": "iLovePDF", "category": "online-editor", "origin": "pdf-editor" }
        ],
        "producer": "iLovePDF",
        "creator": "iText 7.2.5",
        "revisions": 2,
        "fullPageImagePages": []
      }
    },
    {
      "id": "id-inconsistency",
      "severity": "medium",
      "title": "Trailer /ID marks the file as changed since creation",
      "detail": "The trailer /ID array holds two different identifiers. The first element is set once when a document is created and must never change …",
      "evidence": {
        "idOriginal": "8f2c...a91b",
        "idCurrent": "41de...77c0",
        "trailerCount": 2
      }
    }
  ],
  "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": 2,
    "sizeBytes": 184320
  },
  "disclaimer": "Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic."
}
The high band is reserved. A pile-up of weak findings is clamped at 69 no matter how many of them there are.
riskScore · 0 TO 100 weak findings accumulate here riskBand “high” 0 69 70 100 However many medium and low findings a document collects, the score is clamped at 69: a crowd is not a conclusion. Any single high-severity signal forces the score to at least 70. So “high” means one finding that on its own establishes a change.

The weights are versioned by engineVersion and the same bytes always produce the same score, which is what makes the number defensible in a file note. It is a weighted aggregation, not a probability.

riskScore is a weighted aggregation, not a probability. The contract is deliberately narrow: any high-severity signal forces the score to at least 70, and the high band is reserved for high-severity signals, a pile-up of weak findings is clamped at 69. So riskBand === "high" means "at least one finding that on its own establishes a change", which is a statement you can defend in a file note. The weights are versioned by engineVersion; the same bytes always produce the same score.

Full field reference, error codes and quotas: the API reference and the OpenAPI spec at /docs.

Wiring signals into a decision

The whole design assumes you own the decision. Some patterns that work:

Every band routes to a queue. None of them routes to a decline: auto-declining on a structural signal declines real customers.
riskBand WHERE IT GOES low continues automatically elevated manual review high manual review, evidence attached, ask for a freshly downloaded original Genuine applicants can re-download a statement; the second submission is often more informative than the first.

Store the signal ids, severities and evidence objects against the application rather than the file: it is small, it is your audit trail, and it contains no document content.

  1. Route, do not reject. Map riskBand to a queue: low continues automatically, elevated goes to manual review, high goes to manual review with the evidence attached and a prompt for a fresh document. Auto-declining on a structural signal will decline real customers who re-saved a file in Preview.
  2. Store the evidence, not the file. Persist the signal ids, severities and evidence objects against the application. That is your audit trail, it is small, and it contains no document content.
  3. Ask for a specific replacement. The single most effective response to a structural signal is "please re-download the original PDF from your bank and upload it without opening it in any other application". Genuine applicants can do this; the second submission is often more informative than the first.
  4. Tune per signal, not per score. If your flow legitimately involves customers signing statements, incremental-updates will fire constantly and you should weight it down for that flow while keeping signature-coverage and font-anomalies at full weight.
  5. Baseline your own traffic first. Run the checker or the free tier over a sample of statements you already believe are genuine, per issuing bank, before you set any threshold. Producer strings and revision habits vary enormously between banks, and knowing what "normal" looks like for your actual applicant population is worth more than any default we could ship.

What this does not do

Stated plainly, because a fraud tool that oversells itself is worse than none:

The right-hand column is not a gap waiting to close. It is a different class of check, and this tool does not perform it.
WHAT IT READS WHAT IT DOES NOT the revision chain, and what each save replaced the production chain named in the metadata the two metadata stores, against each other font subsets, and how the page was composed which bytes a signature covers all of it deterministic: same bytes, same report the statement's meaning: no OCR, no name matching, no lookup against a bank identity, or who owns the account a template forgery generated once, cleanly certificate and trust-chain validation a verdict: there is no "fraud": true field and there will not be one
  • It does not read your statement for meaning. There is no OCR and no name matching. The one arithmetic it does is the running balance: since engine 1.25.0, running-balance-break reports a balance column that stops adding up, once a column has proved it is a balance, and says nothing about which number is wrong or why. It will not tell you that a closing balance disagrees with a total printed elsewhere, or that a transaction never happened.
  • It does not verify identity or account ownership. A perfectly genuine, untouched statement belonging to somebody else scores low, correctly.
  • It does not detect a well-made template forgery. A file generated once, cleanly, from a fake template has nothing structurally wrong with it.
  • It does not validate signature cryptography. It reports what a signature covers. Certificate and trust-chain validation is a different job.
  • It does not survive a determined adversary forever. A forger who knows about revision history can flatten it. That is a real limit, and also why the signal set is versioned and grows: each new evasion technique leaves its own trace.
  • It does not issue a verdict. There is no "fraud": true field and there will not be one.

Try it on a real file first

Open the “Clean statement” and “Edited after creation” samples side by side, the same report the API returns, precomputed on a genuine-shaped statement and on the same statement after an editor changed a printed figure. That is the first row of the pattern figure above, with the evidence attached and a control to read it against. No account, no quota. Your own file goes through the same path: sent over HTTPS, parsed in memory, discarded when the response is written. Then get a key: 50 documents a month free, $29/mo for 1,000, $199/mo for 10,000, $749/mo for 50,000. Full terms on pricing.

Tamperlens reports risk signals, not authenticity verdicts. Signals can have benign causes; combine them with your own decision logic.

Related reading