DEV Community

Cover image for How to detect an edited bank statement PDF via API (Python, PHP, Node)
Borys Haiduchyk
Borys Haiduchyk

Posted on AI-assisted

How to detect an edited bank statement PDF via API (Python, PHP, Node)

Disclosure: I'm the founder of Atomara, the API in this post.

Here is the situation I kept running into before I built this.

A rental platform. A small lender. A marketplace onboarding sellers. Someone uploads a bank statement, the PDF opens fine, the numbers look fine, and the person approving it has maybe forty seconds per document. They have no way of knowing that one amount was changed in a PDF editor an hour before upload.

OCR doesn't help. The text is exactly the thing the editor changed.

What the editor rarely cleans up is the file itself. How it was written. What changed between its saved revisions. Whether the fonts on one line match the rest of the page. Whether the running balances still add up. That is the check this post is about: how to run it from your own backend with one HTTP call, what comes back, and what it will not tell you. The last part matters as much as the first two.

The whole integration, in one call

curl -X POST https://atomara.io/api/v1/documents \
  -H "Authorization: Bearer $ATOMARA_API_KEY" \
  -H "Idempotency-Key: applicant-1042-$(shasum -a 256 statement.pdf | cut -c1-64)" \
  -F "file=@statement.pdf" \
  -F "external_id=applicant-1042"
Enter fullscreen mode Exit fullscreen mode
{ "id": "doc_4a79…", "status": "completed", "verdict": "tampered", "score": 0,
  "signals": [ { "id": "editor_producer_desktop", "severity": "high", "evidence": "/Producer='Adobe Acrobat Pro DC 24.2.20857' /Creator='Northbridge Core Banking 11.4'" },
               { "id": "font_anomaly", "severity": "medium", "page": 1, "evidence": "font 'Times-Roman' appears only within critical-field zone(s), never elsewhere in the document" } ] }
Enter fullscreen mode Exit fullscreen mode

One multipart POST, one JSON answer, usually within a few seconds. Every example in this post is this one file: a fictional statement (Northbridge Bank does not exist) with one amount changed in a desktop editor and saved on top of the original. The rest of the post is what the fields mean, how to act on them, and the same call in Python, PHP and Node.

What the check reads, and what it does not

A statement that came out of a bank's generator has a predictable shape. One producer, one revision, consistent fonts, arithmetic that holds. An edit usually leaves at least one of those out of place:

  • Saved revisions. A PDF can carry its own history as incremental updates. A statement with a second revision written by a desktop editor is a finding on its own.
  • Producer and editor traces. The software that last wrote the file leaves identifiable traces in the metadata and in how the objects are laid out.
  • Fonts. Text typed into an existing PDF rarely lands in the exact font subset the generator embedded.
  • Arithmetic. Opening balance plus transactions should equal the closing balance. On an edited statement it often doesn't, because people edit one number and forget the other three.

Now the honest part. The check does not contact the bank. For most statements it has nothing of the issuer's to compare the file against, so it cannot say "this statement is genuine". It can say "this file shows no signs of editing", or "this file was edited, and here is where". There are two narrow exceptions, and the response names them: a digital signature that still covers the file, or, for the few issuers we hold genuine reference documents of, a structure that matches theirs in every detail.

And not every edit leaves a trace. A file re-rendered through a clean generator, with every balance fixed by hand, can come back with no findings, which is why the absence of findings is never called "genuine". For the same reason I am not quoting a detection rate: there is no public labelled corpus of edited bank statements, and a number measured on our own test files would say more about those files than about yours. Keep that distinction in your decision logic. The whole response is built around it.

The response

One request, one JSON document (abridged: file details, the raw engine numbers and engine are left out):

{
  "id": "doc_4a790f2ea6991acc2b18db2d5ed94cad",
  "status": "completed",
  "verdict": "tampered",
  "basis": "negative",
  "reference_match": null,
  "score": 0,
  "summary": "High risk; basis: the gap between /ModDate and /CreationDate is larger than a single save would explain (…); the document was generated by a desktop PDF editor, not a bank system (…); a font anomaly in a critical field (…).",
  "signals": [
    { "id": "moddate_gap", "kind": "tamper", "severity": "medium", "scored": true,
      "evidence": "CreationDate D:20261001091200+01'00' -> ModDate D:20261004173318+02'00', UTC-normalized delta 3 days, 7:21:18" },
    { "id": "timezone_inconsistent", "kind": "tamper", "severity": "low", "scored": true,
      "evidence": "CreationDate offset 1:00:00 != ModDate offset 2:00:00" },
    { "id": "editor_producer_desktop", "kind": "tamper", "severity": "high", "scored": true,
      "evidence": "/Producer='Adobe Acrobat Pro DC 24.2.20857' /Creator='Northbridge Core Banking 11.4'" },
    { "id": "font_anomaly", "kind": "tamper", "severity": "medium", "scored": true, "page": 1,
      "evidence": "font 'Times-Roman' appears only within critical-field zone(s), never elsewhere in the document (page(s) [1])" },
    { "id": "text_changed_between_revisions", "kind": "tamper", "severity": "high", "scored": false,
      "page": 1, "bbox": [56, 390.47, 538.25, 400.06],
      "evidence": "text changed on page 1 between revision 0 and revision 1" }
  ],
  "file": { "name": "statement-edited.pdf", "pages": 1, "document_class": "born_digital" },
  "report_url": "https://atomara.io/account/uploads/4a790f2ea6991acc2b18db2d5ed94cad"
}
Enter fullscreen mode Exit fullscreen mode

basis says what the verdict rests on: negative (scored findings of editing), signature (an intact digital signature), reference_match (a structural match with a reference template of the issuer), integrity_only (structure and provenance checked out, nothing more), image_container (an image of a document, checked as an image) or inconclusive.

Three layers. Use the one that fits your flow:

  • verdict is one of tampered, suspicious, inconclusive, authentic, unsupported. authentic has exactly two bases, signature and reference_match. Only with the second is the reference_match object filled in; otherwise it is null, as above. When its attack_tested is false, a copy with changed amounts, dates or details would have the same structure, so the values are not verified.
  • score runs from 0 (edited) to 100 (intact). The verdict picks the band (tampered 0–30, suspicious 31–55, inconclusive 56–80, authentic 81–100); the evidence moves the score inside it.
  • signals is the evidence. Each has a stable id you can branch on, plus the page and the box where it was found. scored: false marks supporting evidence, shown but not counted: above, the recovered earlier revision shows where the text changed, while the four scored signals decide the verdict. kind is tamper (content was changed; moves tamper_score) or integrity (structure and provenance; moves integrity_score, no severity). Show the signals to the person who reviews the application; report_url opens the same result in a dashboard for them.

The same answer as the JSON, opened through report_url. The statement is a fictional test file (Northbridge Bank does not exist); the edit is one amount changed in Acrobat and saved on top of the original.

One thing to get right before anything else: an ordinary unsigned PDF of an issuer with no template, with nothing wrong, comes back inconclusive with no scored signals. That is the best result an unsigned document can get. It is not a warning, and I've watched more than one team treat it as one.

An image of a document (a scan, a photo, a screenshot, a page rendered to a picture) is checked as an image and comes back with no score (basis: "image_container"). The report says what it could rule out (no re-save, nothing laid over the page, pages alike) and asks for the PDF the issuer sent. Route those to a person, or request the original file.

Get a key

Create an account at atomara.io, open Developers, create a key. On a work email the first key adds 100 trial checks, each the full analysis. The key goes in the Authorization header and stays on your server.

Python

import hashlib, os, time, requests  # pip install requests

API = "https://atomara.io/api/v1"
KEY = os.environ["ATOMARA_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}
POLL_DEADLINE = 120  # seconds; bound the poll in production, never loop forever

def call(method: str, url: str, **kwargs) -> dict:
    headers = {**HEADERS, **kwargs.pop("headers", {})}
    r = requests.request(method, url, headers=headers, timeout=60, **kwargs)  # the API holds the request open for up to 20 s
    doc = r.json()
    if r.status_code >= 400:
        e = doc["error"]
        raise RuntimeError(f"{e['code']}: {e['message']} ({e['request_id']})")
    return doc

def verify(path: str, external_id: str) -> dict:
    with open(path, "rb") as f:
        sha256 = hashlib.sha256(f.read()).hexdigest()
        f.seek(0)
        doc = call("POST", f"{API}/documents",
                   headers={"Idempotency-Key": f"{external_id}-{sha256}"},
                   files={"file": (os.path.basename(path), f, "application/pdf")},
                   data={"external_id": external_id})
    deadline = time.monotonic() + POLL_DEADLINE
    while doc["status"] == "processing":   # the rare 202: poll every few seconds
        if time.monotonic() > deadline:
            raise TimeoutError(f"{doc['id']} still processing after {POLL_DEADLINE} s")
        time.sleep(4)
        doc = call("GET", f"{API}/documents/{doc['id']}")
    return doc

doc = verify("statement.pdf", "applicant-1042")
print(doc["verdict"], doc["score"], [s["id"] for s in doc["signals"] if s["scored"]])
Enter fullscreen mode Exit fullscreen mode
tampered 0 ['moddate_gap', 'timezone_inconsistent', 'editor_producer_desktop', 'font_anomaly']
Enter fullscreen mode Exit fullscreen mode

Python 3.8 or newer; requests is the one dependency.

PHP

<?php
$api = 'https://atomara.io/api/v1';
$key = getenv('ATOMARA_API_KEY');

function atomara(string $method, string $url, string $key, array $post = [], array $headers = []): array
{
    $ch = curl_init($url);
    $options = [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 60,                      // the API holds the request open for up to 20 s
        CURLOPT_HTTPHEADER => array_merge(["Authorization: Bearer $key"], $headers),
    ];
    if ($post !== []) {
        $options[CURLOPT_POSTFIELDS] = $post;       // an array => multipart; curl writes the boundary
    }
    curl_setopt_array($ch, $options);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $doc = json_decode((string) $body, true);
    if ($status >= 400) {
        throw new RuntimeException("{$doc['error']['code']}: {$doc['error']['message']} ({$doc['error']['request_id']})");
    }
    return $doc;
}

$path = 'statement.pdf';
$doc = atomara('POST', "$api/documents", $key, [
    'file' => new CURLFile($path, 'application/pdf', basename($path)),
    'external_id' => 'applicant-1042',
], ['Idempotency-Key: applicant-1042-'.hash_file('sha256', $path)]);

$deadline = time() + 120;                       // bound the poll in production, never loop forever
while ($doc['status'] === 'processing') {       // the rare 202: poll every few seconds
    if (time() > $deadline) {
        throw new RuntimeException("{$doc['id']} still processing after 120 s");
    }
    sleep(4);
    $doc = atomara('GET', "$api/documents/{$doc['id']}", $key);
}

$scored = array_column(array_filter($doc['signals'], fn ($s) => $s['scored']), 'id');
echo "{$doc['verdict']} {$doc['score']} ".implode(',', $scored).PHP_EOL;
Enter fullscreen mode Exit fullscreen mode

PHP 7.4 or newer with the curl extension, no packages.

Node

import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";

const API = "https://atomara.io/api/v1";
const KEY = process.env.ATOMARA_API_KEY;
const POLL_DEADLINE_MS = 120_000; // bound the poll in production, never loop forever

async function call(method, url, { headers = {}, body } = {}) {
  const res = await fetch(url, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, ...headers },
    body,
    signal: AbortSignal.timeout(60_000), // the API holds the request open for up to 20 s
  });
  const doc = await res.json();
  if (!res.ok) throw new Error(`${doc.error.code}: ${doc.error.message} (${doc.error.request_id})`);
  return doc;
}

async function verify(path, externalId) {
  const bytes = await readFile(path);
  const form = new FormData();
  form.append("file", new Blob([bytes], { type: "application/pdf" }), path.split("/").pop());
  form.append("external_id", externalId);
  // No Content-Type header by hand: fetch writes it with the multipart boundary.
  let doc = await call("POST", `${API}/documents`, {
    headers: { "Idempotency-Key": `${externalId}-${createHash("sha256").update(bytes).digest("hex")}` },
    body: form,
  });
  const deadline = Date.now() + POLL_DEADLINE_MS;
  while (doc.status === "processing") {   // the rare 202: poll every few seconds
    if (Date.now() > deadline) throw new Error(`${doc.id} still processing after ${POLL_DEADLINE_MS / 1000} s`);
    await new Promise((r) => setTimeout(r, 4000));
    doc = await call("GET", `${API}/documents/${doc.id}`);
  }
  return doc;
}

const doc = await verify("statement.pdf", "applicant-1042");
console.log(doc.verdict, doc.score, doc.signals.filter((s) => s.scored).map((s) => s.id));
Enter fullscreen mode Exit fullscreen mode

Node 20 or newer. fetch, FormData and Blob are built in, no packages.

Three details that save you a support ticket

Most answers are synchronous. POST /documents holds the request open for up to 20 seconds and answers 200 with status: "completed" for the typical document. If the analysis needs longer you get 202 with status: "processing". That is not an error: poll GET /documents/{id} until it isn't, as the loops above do.

Retries are free. If the connection drops mid-upload, send the same request again with the same Idempotency-Key: you get the document that was already created, not a second charge (same key, same file, within 24 hours). The same key with a different file is refused with 409 idempotency_key_reused. The snippets build the key from your own reference plus the file's SHA-256, so two files from one applicant never share a key, and one file sent twice does.

Errors have one shape, { "error": { "code", "message", "request_id" } }, so branch on code: 401 unauthorized, 402 quota_exceeded, 400 not_a_pdf, 429 rate_limited (honour Retry-After). Every response carries an X-Request-Id header. Log it next to your own request id and support conversations get very short.

A document can also be accepted and still fail later (password-protected, corrupt): that is 200 with status: "failed", the reason in its own error.code, never charged. The full list is in the errors guide.

A decision policy to start from

Before an API, the usual fix for this problem is a костиль (kostyl: Ukrainian dev slang for a crutch, the hack you swear you'll remove next sprint). A checklist for the reviewer, a rule that rejects every PDF whose producer says "Acrobat", a Slack channel where someone asks "does this look right to you?". Yes, the response above has a producer signal too. The difference is that it is one weighted signal, not the verdict. A desktop editor in the producer raises editor_producer_desktop and sends the engine looking for the edit itself: a recoverable earlier revision, Acrobat's own text touch-up marker. editor_producer_blacklist is reserved for online editors, Canva and Photoshop, which no bank issues anything with. The server-side generators banks actually use (iText, Apache FOP, ReportLab, PDFlib) count the other way.

The table below is what I'd replace the crutch with on day one. Tune it once you've seen your own distribution of verdicts.

Response What it means A sensible default
inconclusive, no scored tamper signal No signs of editing. The issuer is not confirmed, because nothing was. Accept under your own rules.
inconclusive, with scored signals Minor findings that stayed below suspicious; the score sits lower in its band. Accept or review under your own rules; show the signals.
suspicious Findings that need a look. Human review; show the signals.
tampered Scored findings of editing after issue. Rejection candidate a person confirms.
authentic, basis: "signature" Digitally signed and the signature still covers the file. This confirms integrity, not who signed. Accept if the signer is one you trust and its certificate checks out under your own rules.
authentic, basis: "reference_match" Matches a reference template of its issuer, no sign of editing. Accept if reference_match.attack_tested is true; if it is false, the amounts are not verified, so check them under your own rules.
status: "failed" Could not be analysed; error.code says why (password_required, not_a_pdf, …). Not charged. Ask for an intact, unlocked PDF.
unsupported No text layer and no image to analyse. Not charged. Ask for an intact PDF.
basis: "image_container" An image of a document, checked as an image, no score. Ask for the original PDF; a person decides.

The API flags; your team decides. Keep a human on suspicious. The score tells that person where to look, not whether to look.

If an AI agent does the checking

The same call wrapped as an Agent Skill, a SKILL.md plus a bash and a Python helper, lets Claude Code, Codex CLI or OpenCode verify a PDF from a prompt and branch on the exit code. Details on atomara.io/agents.

Top comments (0)