What this API is for
Lending runs on transaction data, and most of it now arrives through a feed. This page is about the rest — the applications where a PDF is what you have, and where an underwriter would otherwise spend an hour keying three months of transactions into a spreadsheet before any assessment can begin.
The API takes a statement PDF or scan and returns the account, the period, the opening and closing balances, and every transaction with an ISO date, a normalised signed amount, the description as printed and the running balance where the statement shows one. That is the input your affordability model, your cash-flow analysis or your manual review actually needs.
What it does not return is a decision, a score, or a derived metric. That boundary runs through the whole page, and it is deliberate: extraction is a document problem with a right answer, and credit is a judgement your business owns.
Open banking first — and the four gaps it leaves
If an applicant will connect an account, connect it. A feed beats reading a PDF on every axis it covers: the data comes from the institution rather than from a rendering of it, it is categorised, it is current, and there is no document to misread. We say the same thing on the Plaid comparison and we are not going to argue otherwise here.
Four gaps remain, and they are the ones that reach an underwriter's desk. History beyond the window an aggregator returns. Institutions with no coverage — regional banks, foreign accounts, newer fintechs. Closed accounts, where the borrower has statements and nothing to connect. And applicants who decline, which in some segments is a large share and is not a technical problem you can solve.
So the sensible architecture is both: connect where you can, extract where you cannot, and normalise the two into one internal shape so the model downstream does not care which route a transaction arrived by. Most lenders who build this end up treating the PDF path as the fallback that has to exist rather than a competing strategy.
Where the PDFs actually come from
Broker packets are the biggest source in commercial and mortgage lending: a zip or an email with six to twelve documents, several accounts, sometimes overlapping periods, occasionally a page scanned twice. Nobody in that chain is thinking about your data model.
Self-employed and small-business applicants are the second. Their trading account may be with an institution nobody aggregates, they may bank in a different country from where they are borrowing, and the statement they have is the one the branch printed.
And there is the historical requirement. A file that needs twelve or twenty-four months, where the feed returns twelve, means the earlier period comes from documents no matter what your integration looks like. That is why the PDF path is a permanent part of the architecture rather than a migration you are trying to finish.
What the response contains
One call returns one document, structured. The statement level carries the bank name, the account holder, a masked account number, the statement period, the currency and the opening and closing balances. The transaction level carries a date, the description as printed, a signed amount, and the running balance where the document prints one.
Normalisation is most of the value. Debit and credit columns collapse into one signed amount with money out negative; European and Anglo decimal conventions both parse correctly; day-first and month-first dates are resolved per document rather than guessed per row; wrapped descriptions are joined back into one field. Those are the four things that consume a week when a team writes its own parser.
Categories are returned where the engine can infer them, and they are a convenience, not a credit signal. Any classification that affects a lending decision should run on your side, against your rules, where you can explain it — which is the subject of the boundary section.
| Level | Fields | Note |
|---|---|---|
| Statement | bank_name, account_holder, account_number_masked | Account numbers are masked as the document masks them |
| Statement | statement_period, currency | Period as printed; currency per statement, never mixed |
| Statement | opening_balance, closing_balance | The two numbers the completeness proof depends on |
| Transaction | date (ISO), description | Description is verbatim — no cleaning, no rewriting |
| Transaction | amount (signed) | Money out is negative, whatever the document's convention |
| Transaction | balance | Running balance where the statement prints one |
| Transaction | category | Best-effort convenience. Not a credit signal. |
Prove completeness before you compute anything
The failure that should worry a lender is not a misread character — it is a missing transaction. A dropped row leaves output that looks perfect: sensible descriptions, clean dates, plausible totals, no error anywhere. Every metric computed on it is then wrong in a direction nobody can see, and the file goes to committee looking fine.
Statements carry their own audit, so this is provable rather than probable. Opening balance plus the sum of transactions must equal the closing balance, per account and per currency. We run that check on every statement and flag the ones that fail; you should run it again on your side, because a check you execute is a check you can evidence in a file review.
There is a second check across documents that only you can run: month two's opening balance must equal month one's closing balance. That catches the missing month in a packet — the most common real-world gap, and one no single-document check can see. Both checks are in the code below.
Handling a multi-account, multi-month packet
Send documents individually rather than merged. A concatenated packet is slower, risks the size and time limits, and destroys attribution — when one page fails you want to know which document it was, not that something in a twelve-document bundle went wrong.
Group by account before you check anything. One applicant with a current account and a savings account has two arithmetic chains, and checking them together produces a reconciliation that passes while both accounts are individually wrong. The masked account number in the response is the natural key; where a bank masks inconsistently, fall back to sort code plus the last digits.
Watch for overlapping periods. Brokers routinely include the same month twice, in different renderings, and a naive pipeline double-counts every transaction in it. Deduplicate on account, date, amount and description before anything aggregates, or use the merge endpoint, which does the consolidation and the overlap handling server-side.
# A broker packet: 3 months x 2 accounts, six PDFs, one applicant.
import base64, os, requests
KEY = os.environ["FLOWPARSE_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}
def extract(path):
with open(path, "rb") as fh:
b64 = base64.b64encode(fh.read()).decode()
r = requests.post("https://flowparse.io/api/v1/extract",
headers=H, json={"file": b64, "filename": os.path.basename(path)},
timeout=180)
r.raise_for_status()
return r.json()["data"]["data"]
statements = [extract(p) for p in sorted(packet_paths)]
# Group by ACCOUNT before you check anything — one applicant, several arithmetic chains.
by_account = {}
for st in statements:
by_account.setdefault(st.get("account_number_masked", "unknown"), []).append(st)
for account, months in by_account.items():
months.sort(key=lambda s: s["transactions"][0]["date"])
for prev, nxt in zip(months, months[1:]):
# The other completeness check: last month's closing must equal next month's opening.
if abs(prev["closing_balance"] - nxt["opening_balance"]) > 0.01:
print(f"{account}: gap or missing month between periods")The metrics are yours to compute
We do not compute affordability, revenue quality, debt service coverage, days negative, unpaid-item counts or anything else a credit policy would rely on. The API returns rows; your model turns rows into signals. That is not a limitation we are apologising for — a lender's metric definitions are policy, they differ between products, and they have to be explainable in a way a vendor's black box is not.
The practical shape is simple. Load the transactions, assert the completeness check, then compute exactly what your policy defines. Average daily balance means resampling the balance series by day, not averaging the printed balances — a distinction that changes the number materially on an account with clustered activity.
Classification is the same story. Whether a transaction is income, a transfer between the applicant's own accounts, a loan repayment or a returned-item fee depends on rules you own and can defend. The description arrives verbatim precisely so your rules can work on what the bank actually printed.
# What WE return, and what YOU compute. The boundary in code.
import pandas as pd
df = pd.DataFrame(statement["transactions"]) # date, description, amount, balance
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values("date")
# 1. Completeness first — never compute a metric on an unproven extraction.
drift = statement["opening_balance"] + df["amount"].sum() - statement["closing_balance"]
assert abs(drift) < 0.01, f"statement does not reconcile: off by {drift:.2f}"
# 2. Average DAILY balance — not the average of the printed balances.
daily = (df.set_index("date")["balance"]
.resample("D").last().ffill())
avg_daily_balance = daily.mean()
days_negative = int((daily < 0).sum())
# 3. Inflow / outflow by month.
monthly = df.groupby(df["date"].dt.to_period("M"))["amount"].agg(
inflow=lambda s: s[s > 0].sum(),
outflow=lambda s: s[s < 0].sum(),
net="sum",
)
# 4. Your own classification rules live HERE, in your model, not in ours.
FEE_TERMS = ("unpaid", "returned", "insufficient", "nsf", "reversal")
suspect_fees = df[df["description"].str.lower().str.contains("|".join(FEE_TERMS))]The boundary, stated plainly
FlowParse does not make or support credit decisions. There is no score, no risk grade, no affordability calculation, no recommendation and no model tuned on lending outcomes. We are not a consumer reporting agency, we produce nothing intended as a consumer report, and we generate no adverse-action content. If your process needs those things, they come from you or from a credit bureau.
We also do not enrich for credit. No NSF-day counts, no revenue verification, no true-revenue estimates, no cash-flow scoring, no lender-specific categorisation model. Where a page like bank statement analysis for loans describes those signals, it describes what *you* can compute from clean data — we supply the clean data.
And we do not detect forged documents. This is the distinction most worth internalising: our balance check proves that our extraction of the document is complete, not that the document is genuine. A skilfully altered statement can reconcile perfectly, because a forger who changes an amount usually changes the balances too. Document authenticity is a different discipline and a different vendor.
What the arithmetic can and cannot tell you about fraud
It is worth being precise, because the two questions get conflated in procurement conversations. *Did we read every row?* is answerable by arithmetic and we answer it. *Is this document what the bank issued?* is not answerable from the numbers alone, and anyone claiming otherwise is overselling.
What a failed reconciliation does tell you is that something is wrong — and in a lending context that is a reason to look, whether the cause is a scan that lost a page, a document assembled from two sources, or an edit. Treat a failure as an exception requiring a human, not as an accusation.
For genuine authenticity work — metadata analysis, font and rendering forensics, cross-checks against the issuing institution — use a specialist. Several exist, they do it properly, and combining their verdict with our completeness proof answers both questions instead of pretending one covers the other.
How this page differs from our other lending pages
Bank statement analysis for loans is the analyst's view: what underwriters look for in a statement and how spreading works. Income verification from bank statements is about the income signals themselves and how to separate genuine income from noise. Bank statement converter for mortgage covers the broker and mortgage-file workflow, and rental application screening the landlord version.
This page is the integration: endpoints, response shape, limits, packet handling, throughput and error semantics for an engineering team wiring extraction into a loan origination system. Same engine, different question — the others answer *what should I look at*, this one answers *how do I get it into my stack*.
If you want the general developer documentation rather than the lending framing, the bank statement API page and the API docs are the neutral versions.
Limits worth designing around
Three numbers shape the integration. Documents are capped at 20 MB. Extraction has an internal ceiling of 110 seconds, after which the call returns an error rather than hanging. And the API is synchronous — there is no callback, no webhook and no job queue on our side, so your system owns the queueing.
That last point matters most in lending, where a packet arriving at 9am should not block a web request. The pattern that works is a job per document in your own queue, with your worker calling the API, so a slow scan never occupies a user-facing thread. The webhook automation guide covers that design in detail.
Set client timeouts above ours, not below. A 30-second timeout on a document that legitimately takes 80 seconds gives you a failure and a charge — the extraction completed, you just stopped listening.
| Constraint | Value | Design implication |
|---|---|---|
| Maximum file size | 20 MB | Split large scanned packets before sending |
| Extraction ceiling | 110 seconds | Client timeout above it; split very long statements |
| Delivery model | Synchronous, no callback | Your queue, your retries, your idempotency key |
| Billing unit | Per PDF page | A retry is a new extraction — retry only on 5xx |
| Unreadable document | 422, not billed | Route to manual review rather than retrying |
| Budget exhausted | 429 | Alert on it; retrying cannot create pages |
Throughput, retries and idempotency
Keep concurrency modest and bounded — a small worker pool per tenant rather than a fan-out per packet. Lending traffic is bursty by nature, with every broker submitting on Monday morning, and a queue with a steady drain rate produces far more predictable behaviour than a burst that trips rate limits and then retries into the same wall.
Retry only on 5xx, and never on 422 or 429. Every retry that reaches the API is a full billed extraction, so a naive retry policy converts one transient failure into several charges for a document you were never going to use. Record the outcome per document so a resumed job skips what already succeeded.
Deduplicate on your side. We do not: sending the same statement twice extracts and bills twice. A hash of the file bytes, stored against the application, is enough to make an entire packet safely re-runnable — which matters when a broker resends a corrected bundle containing five documents you already processed.
Scanned and photographed statements
A meaningful share of statements in lending arrive as scans, because a branch printed them or an applicant photographed them. Those documents have no text layer, so extraction runs vision over each page, which is slower and where the time limit is most likely to bite on a long file.
Quality dominates outcome. A flat, well-lit scan at a sensible resolution extracts cleanly; a photograph taken at an angle in poor light produces digit-level errors. The completeness check catches errors that change the totals, which is the class that matters most, but it cannot catch a misread character in a description.
Practically: if the file is long and scanned, split it by month before sending. Twelve monthly documents each extract comfortably where one annual bundle may not, and per-document attribution makes the exception queue meaningful.
The exception queue is part of the design
No extraction pipeline is fully automatic, and a lending pipeline should not pretend to be. Three cases need a person: documents that could not be read at all, statements that fail their balance check, and packets with a gap between months. Each is a specific, explainable reason rather than a vague low-confidence flag.
Give the reviewer the original alongside the data. Since we delete originals immediately after extraction, your storage holds the only copy — and a file review that cannot produce the source document is a file review with a hole in it.
Measure the exception rate by source. A broker whose packets fail three times more often than average is a process conversation worth having, and it is a number you only get if exceptions are categorised rather than lumped into one bucket.
Data handling, and what your policy will ask
Documents travel over TLS, are processed in the EU, and the original is deleted immediately after extraction. Extracted data is encrypted at rest. No customer document is used to train models. Those are the four answers most lending security reviews open with, and the security page has the detail including sub-processors.
Two consequences follow for lenders specifically. First, we are not an archive — your obligation to retain the applicant's documents for the life of the file, plus whatever your regulator requires, is met by your storage, not ours. Second, there is no on-premise or air-gapped deployment; if your policy forbids applicant documents leaving your infrastructure, this is not the right tool and building in Python is the honest alternative.
Statement data is personal data about an applicant, and often about third parties who happen to appear in their transaction descriptions. Minimise what you keep, restrict who can see raw transaction detail, and be deliberate about how long derived data outlives the decision it supported.
Build versus buy, for a lending team
Lenders build statement parsers more often than most, because statement analysis feels close to the core product. It is worth separating the two: analysis genuinely is your product; reading a PDF is not, and the distinction usually clarifies the decision.
The build cost is not the first parser, it is the tail. Applicants bank everywhere, and coverage in the long tail is exactly where a hand-built pipeline is weakest — which means the applications your automation handles worst are the ones your competitors are also struggling with, and where speed would actually win business.
Whatever you decide, keep the completeness check in your own code. It is a dozen lines, it works against any source, and being able to say in a file review that every statement reconciled is worth more than any vendor's accuracy claim.
A worked example: an SME lender's Monday morning
An SME lender receives applications through brokers, most arriving between Friday and Monday. A typical file is six to nine statement PDFs covering two or three accounts and six months, with an occasional duplicate month and, roughly one file in eight, at least one scan.
The pipeline: each document becomes a queued job with a hash-based idempotency key. Extraction runs, the per-statement balance check runs, documents are grouped by masked account, and the month-to-month seam check runs across the group. Anything that fails either check, plus anything unreadable, goes to an exception queue with the reason attached. Everything else feeds the affordability model, which is entirely theirs.
The measurable change was not extraction speed, it was the elimination of a silent failure mode. Previously a missing month in a packet was found by an underwriter — sometimes. Now it is a specific exception raised before the file reaches anyone, which is a smaller improvement to talk about and a much larger one to own.
Getting started
Test with the worst packet in your archive rather than a clean one. The file that taught your underwriters the most is the file that will tell you most about the integration — and it is better to meet it during a build than during a busy Monday.
1. Get a key
Issue an API key from the dashboard and store it as a secret in your worker environment.
2. One document, end to end
Extract a real statement, print the transactions, and read them against the PDF yourself. Twenty minutes, and it settles more questions than any spec.
3. Add the completeness check
Opening plus transactions equals closing, per account. Fail loudly.
4. Add the seam check
Each month's closing equals the next month's opening. This is the one that finds missing documents.
5. Queue it
One job per document, bounded concurrency, idempotency key on the file hash, retries only on 5xx.
6. Build the exception queue
Unreadable, failed reconciliation, packet gap — three reasons, each actionable.
7. Then compute your metrics
Only on statements that passed. A metric on an unproven extraction is worse than no metric.
Where to go next
The full endpoint reference — extract, validate, merge and export — is in the API docs, and keys come from the API dashboard. For the general developer view of statement handling, see the bank statement API and PDF to JSON API.
For the analyst-facing material your credit team will ask about, see bank statement analysis for loans and income verification from bank statements. For how the completeness proof works and what it does not prove, see bank statement validation.
If your team would rather write the parser, the honest comparison is on bank statement parser in Python — including the balance-check code, which belongs in your stack either way.
Put the awkward applications through the same pipeline
Statement PDFs in, normalised transactions out — with a completeness proof on every document before your model sees a single number.
