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"
{ "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" } ] }
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"
}
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:
-
verdictis one oftampered,suspicious,inconclusive,authentic,unsupported.authentichas exactly two bases,signatureandreference_match. Only with the second is thereference_matchobject filled in; otherwise it isnull, as above. When itsattack_testedisfalse, a copy with changed amounts, dates or details would have the same structure, so the values are not verified. -
scoreruns from 0 (edited) to 100 (intact). The verdict picks the band (tampered0–30,suspicious31–55,inconclusive56–80,authentic81–100); the evidence moves the score inside it. -
signalsis the evidence. Each has a stableidyou can branch on, plus the page and the box where it was found.scored: falsemarks supporting evidence, shown but not counted: above, the recovered earlier revision shows where the text changed, while the four scored signals decide the verdict.kindistamper(content was changed; movestamper_score) orintegrity(structure and provenance; movesintegrity_score, no severity). Show the signals to the person who reviews the application;report_urlopens the same result in a dashboard for them.
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"]])
tampered 0 ['moddate_gap', 'timezone_inconsistent', 'editor_producer_desktop', 'font_anomaly']
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;
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));
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)