Most bank statement parsers are a black box.
You send a PDF. You get back JSON. Something happened in between — but what? If the output is wrong, you have no clear way to know where the process broke down. Was it the layout parse? A sign assignment? A corrupted balance figure from a bad scan? You're debugging a result with no trace of how it was produced.
Stmtly is built to be the opposite of that. Every stage of processing has a defined input, a defined output, and a defined failure mode. Errors don't disappear into the pipeline — they surface, with enough context to know exactly what went wrong and why.
This post walks through all four stages, from the moment a PDF is received to the moment audit-ready data comes out the other side.
What the pipeline is actually trying to do
Before the walkthrough, the design goal needs to be clear — because it explains every decision that follows.
The pipeline isn't trying to "read" a bank statement the way a human reads it. It's not inferring meaning from context or using a model's prior training to fill in ambiguity. It's trying to reconstruct a mathematical proof.
Every legitimate bank statement is a proof: a sequence of signed transactions that, summed from a known opening balance, arrives at the printed closing balance. That's not a design convention — it's how the document works. The bank's own printed totals are an audit trail embedded in the PDF.
The pipeline's job is to extract the inputs to that proof, verify it holds, and flag the output when it doesn't. Every stage either advances the reconstruction or surfaces an error that prevents bad data from passing through.
That's the standard. Not "probably correct." Verifiable.
Stage 1 — Document ingestion and pre-flight checks
Before any parsing begins, the system qualifies the document.
The PDF is received and each page is rendered to a high-resolution raster. Page count is assessed and multi-page statements are queued for sequential processing. The engine then attempts layout fingerprinting — checking header and footer patterns against known bank signatures for Chase, Bank of America, Wells Fargo, and Capital One. If the layout is unrecognized, the system doesn't fail hard. It proceeds with generalized spatial rules and notes the unrecognized layout in the output metadata.
Three things can stop the pipeline at this stage. Password-protected PDFs can't be rendered and return an immediate error. Scans below a minimum usable DPI trigger a scan quality warning that travels with the output through every subsequent stage. Documents that aren't bank statements — a tax form submitted by mistake, for example — usually pass ingestion but will fail the validation audit in Stage 4 when the math doesn't close.
This is also where the system first attempts to locate the Account Summary section: the block at the top of most statements that contains the Opening Balance and Closing Balance. These two numbers are the anchor for everything that follows. If they can't be found, the system can still extract transactions — but the final validation audit won't be possible, and the output will be flagged accordingly.
Stage 1 output
A document metadata object — detected bank, page count, estimated statement period, scan quality score, and the extracted Opening and Closing Balance if the summary section was found.
Stage 2 — Spatial grid mapping
This is the most distinctive stage of the pipeline, and the one that most directly addresses why standard OCR fails on bank statements.
Rather than reading the document as a stream of characters — left to right, top to bottom — the engine extracts every text token directly from the PDF's vector layer, along with its precise X-Y coordinate. These tokens are then plotted onto a coordinate grid.
Column inference runs against that grid. The engine clusters tokens by their X-axis range to identify which horizontal bands correspond to which data fields: Date, Description, Debit, Credit, Balance. This clustering is derived dynamically from the document itself — not hardcoded per bank. A Chase statement and a Credit Union statement with completely different layouts are both geometry problems. The approach is the same; the coordinates are different.
Once columns are established, orphaned tokens are resolved. A minus sign that appears two pixels to the left of its number. A date split across a line break. A transaction description that wraps into the row below. The engine reassembles these into single coherent data points rather than treating them as separate tokens.
The practical impact of this approach is easiest to see through two concrete layout problems that defeat stream-based parsers.
Bank of America groups all deposits in one block, all withdrawals in another — the statement isn't chronological. A parser that reads top-to-bottom assumes chronological order, reassembles the ledger in the wrong sequence, and produces running balances that don't compute. The spatial parser doesn't assume chronological order — it reads column assignments first, then reconstructs sequence from the Date column.
Chase and Wells Fargo use multi-column layouts where debits and credits sit in separate columns separated only by whitespace. There's no per-row label. The column position is the signal. Stream-based parsing loses that signal — it sees a number with no column context. Spatial parsing preserves it.
Two failure modes are possible at this stage. Heavily degraded scans can produce token coordinates that don't cluster cleanly, yielding low-confidence column inference scores that are flagged in the output. Some credit union statements use unusual table structures with merged cells; the system flags these rather than guessing at column assignments.
Stage 2 output
A structured array of candidate transaction rows. Each row contains a raw date string, description string, raw amount, inferred column assignment (debit, credit, or ambiguous), page number, and a confidence score. Signs are not yet confirmed — that's Stage 3.
Stage 3 — Combinatorial sign verification
This is where Stmtly diverges most sharply from every other bank statement parser, AI-based or otherwise.
The core insight is simple: whether a transaction is a debit or a credit doesn't need to be inferred. It can be proven.
The transaction array from Stage 2 is grouped into chunks — sequences of transactions bounded by consecutive known running balances. Running balances are the per-row balance figures printed in the rightmost column of most statements. Two consecutive balance figures define a chunk: every transaction between them must, when signed correctly and summed from the first balance, produce the second balance exactly.
For each chunk, the engine enumerates every possible combination of positive and negative signs for the transaction amounts in that chunk. It tests each combination against the constraint. The combination that satisfies it is the proof. Those signs are locked. This isn't the most likely answer — it's the only answer that works.
// Chunk example
Opening balance: $12,400.00
Transactions: [$1,200.00], [$340.00], [$85.00]
Closing balance: $10,775.00
Test: (+1200, +340, +85) → $14,025.00 ✗
Test: (-1200, +340, +85) → $11,625.00 ✗
Test: (+1200, -340, -85) → $13,175.00 ✗
Test: (-1200, -340, -85) → $10,775.00 ✓ → Signs locked
When no combination satisfies the equation, the chunk is flagged as unresolved. This typically happens when an OCR error has corrupted a running balance figure — a comma read as a period, producing a balance that can't be reached by any sign combination. The system can't guess past this. It flags the chunk and includes the unresolved transactions in the output with sign: null.
Not all bank formats include per-row running balances. Some institutions only print the opening and closing balance in the summary section. In these cases, the combinatorial approach can still be applied at the statement level — but if there are many transactions and no intermediate anchors, the search space becomes computationally impractical. The engine falls back to heuristic sign assignment using curated regex dictionaries: known credit patterns (Zelle Received, Direct Deposit, Refund, ACH Credit), known debit patterns (Payment, Fee, Purchase, Withdrawal), and neutral descriptions that require additional context. Heuristic assignments are marked in the output with a lower confidence score than mathematically proven assignments.
Stage 3 output
The transaction array from Stage 2, now with confirmed signs, a proof method (combinatorial or heuristic) for each transaction, and a confidence score. Unresolved transactions are included with sign: null and flagged for review.
Stage 4 — Two-point validation audit
The final stage is conceptually the simplest — and the one that makes the entire pipeline trustworthy.
The engine takes the Opening Balance extracted in Stage 1. It sums every transaction in the Stage 3 output using their assigned signs. It compares the result against the Closing Balance.
If they match to the penny: valid: true. If they don't: valid: false, with a delta field showing the exact discrepancy.
The "to the penny" standard is worth addressing directly, because it's sometimes questioned. A $0.01 delta could be a rounding artifact. It could also be the residue of a $10,000 transaction read as $9,999.99. At this level of precision, the only defensible policy is to flag every discrepancy and let the user determine the cause. The delta is always surfaced in the output — not just the pass/fail status — so the user knows the magnitude of the mismatch, not just that one exists.
// Clean output
"validation": {
"opening_balance": 12400.00,
"closing_balance": 9873.41,
"computed_balance": 9873.41,
"delta": 0.00,
"valid": true
}
// Flagged output
"validation": {
"opening_balance": 12400.00,
"closing_balance": 9873.41,
"computed_balance": 9831.00,
"delta": -42.41,
"valid": false,
"error": "math_drift_detected",
"unresolved_transaction_ids": ["txn_047", "txn_048"]
}
Stage 4 output
The validation object: opening balance, closing balance, computed balance, delta, valid status, and a list of unresolved transaction IDs if applicable.
The complete output structure
The final response object has four top-level fields.
metadata — Bank name, last four of account number, statement period start and end, page count, scan quality score, detected layout type.
transactions — The full transaction array. Each entry contains: date, description, amount, sign, running balance (when available from the statement), proof method (combinatorial or heuristic), confidence score, and page number.
validation — The complete audit object from Stage 4: opening balance, closing balance, computed balance, delta, and valid status.
flags — An array of any issues surfaced during processing: unresolved chunks, low-confidence column assignments, scan quality warnings, missing account summary, unrecognized layout. Each flag includes a code, a description, and a reference to the affected page or transaction IDs.
The design logic of the output is deliberate: valid: true documents with no flags can go directly into downstream systems without manual review. valid: false documents and any transactions with sign: null or low confidence scores are the review queue. The system identifies exactly what needs human attention — you don't have to sample the full output hoping to find problems.
What this means for your integration
If you're building on the API, the pipeline gives you three actionable buckets on every document.
Clean. valid: true, no flags, all transactions with combinatorial proof method. Route directly to your accounting system, underwriting model, or audit report. No review needed.
Review. valid: false, or any transactions flagged with sign: null or low confidence. Route to a human reviewer with the delta and flag list attached. The reviewer knows exactly what to look at — not the full statement, just the flagged items.
Rejected. Password-protected, below minimum scan quality, or a document that failed to produce any usable transaction data. Return to sender with the specific error.
This is the 95/5 workflow in practice. The 95% that pass go downstream automatically. The 5% that don't are handled efficiently because the flags tell you precisely where the issue is and what caused it.
The pipeline isn't a black box with a binary result. It's a documented process with a traceable failure mode at every stage. When something goes wrong, you know where it went wrong, why, and what the impact is — which means you can handle it correctly rather than distrust everything.
Audit-ready doesn't mean formatted nicely. It means every number in the output has been verified against the document's own printed totals, and every discrepancy has been surfaced rather than smoothed over.
That's what all four stages are working toward. Not a plausible-looking ledger. A provable one.
