A REST API for bank statement data
A bank statement API is an HTTP endpoint you send a statement file to and get structured data back — no templates, no manual mapping, no screen-scraping. FlowParse exposes exactly that: `POST /api/v1/extract` accepts a statement as base64 (PDF, scanned image, XLSX or CSV) and responds with typed JSON — the same multi-model pipeline the FlowParse workspace uses, now callable from your own backend.
It is built for teams that process statements at volume: lenders pulling income and balance signals for underwriting, fintechs onboarding business accounts, accounting platforms importing client books, and bookkeeping tools that need a reliable bank statement converter behind an API rather than a web upload. Authentication is a single bearer key, billing is per page, and the response schema is stable and documented.
Every call returns the account context (bank name, account holder, masked number, currency, opening and closing balance) and a `transactions` array where each row is normalised: ISO dates, a single signed `amount` (credits positive, debits negative) and the running `balance`. The original layout is never lost — a `raw_table` carries every source column exactly as printed, so reference codes, transaction types and card last-fours survive into your database.
Bank statement API vs open-banking API: two different things
The phrase bank statement API is used for two products that work in opposite directions, and it is worth being precise about which one you need. An open-banking or account-aggregation API — the kind Plaid offers — connects to the customer's live bank account. The customer consents through a connection flow and the provider pulls transactions and balances from the institution. Plaid's own documentation says the initial pull defaults to 90 days of history and can be set to a maximum of 730 days (about 24 months), with more history accumulating while the connection stays alive. UK Open Banking likewise works only with the customer's explicit consent to a regulated firm.
FlowParse is the other kind. We parse documents; we do not connect to bank accounts. There is no login, no consent screen and no live feed. Your system sends the statement file the customer already has — a downloaded PDF, a scan, a CSV or XLSX export — and the API returns what is printed on it as structured JSON. The data is exactly as fresh as the document: a March statement tells you about March.
A live feed is the right tool for today's balance and an ongoing stream of new transactions; a document API is the right tool when the customer won't connect, the bank or account isn't reachable, or your process already works from documents, as most lending files and bookkeeping catch-ups do. Many teams run both.
| Document API (FlowParse) | Open-banking / aggregation API | Manual keying | |
|---|---|---|---|
| Input | A statement file: PDF, scan (PNG/JPG), CSV or XLSX | A live connection the customer authorises | A person reading the PDF |
| Period covered | Whatever the statement covers, any year | What the provider can pull from the institution (Plaid: 90 days by default, up to 730) | Whatever the statement covers |
| Live / current balance | No — the balances printed on the document only | Yes, while the connection is active | No |
| Closed accounts, uncovered banks | Yes, if a statement exists | Depends on provider coverage and an open account | Yes |
| Built-in arithmetic check | Opening + Σ transactions vs printed closing, row-by-row running balance | Not applicable (data comes from the bank's systems) | Only if someone adds it up |
Your first request in 60 seconds
Create a key in the API dashboard, base64-encode a statement, and POST it. The endpoint is `https://flowparse.io/api/v1/extract`; authenticate with `Authorization: Bearer pf_live_…` (an `X-API-Key` header also works). Base64 keeps the request a plain JSON body, so it works from any language and any HTTP client with zero multipart handling.
curl -X POST https://flowparse.io/api/v1/extract \
-H "Authorization: Bearer pf_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"file": "JVBERi0xLjcKJ...", # base64 of october.pdf
"filename": "october.pdf"
}'Bank statement PDF to JSON: one request, everything back
Converting a bank statement to JSON over the API is a single `POST /api/v1/extract`. The body is plain JSON: `file` (the document as base64 — a `data:` URI prefix is stripped for you), an optional `filename` so the type is detected from the extension, or an explicit `mimeType`. Accepted types are PDF, PNG, JPG, XLSX and CSV, up to 20 MB per file. If the PDF is password-protected, send the password in `password`; without it the API answers `422` with `code: "PASSWORD_REQUIRED"`, and with the wrong one `PASSWORD_INCORRECT`.
Two optional switches make the one call do everything an upload does in the web app. `validate: true` adds the deterministic quality report to the response, and `export: { format: "xlsx" }` (or `csv`, `xml`, a Google Sheets target or an accounting format) adds the finished file. Both ride on the extraction charge: you pay for the document's pages once, not again for the check or the file.
The response opens with `type` (`bank_statement`, `credit_card`, or `mixed` when a statement and an invoice share one file), `pages`, `billedPages` and a `price` object; then `data`, the extraction, with its own `type`, a `confidence` value and the statement object.
curl -X POST https://flowparse.io/api/v1/extract \
-H "Authorization: Bearer pf_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "file": "JVBERi0xLjcK...", "filename": "acme-october.pdf", "validate": true }'
{
"type": "bank_statement",
"pages": 3,
"billedPages": 3,
"price": { "eur": 0.105, "perPageEur": 0.035, "complexity": "standard" },
"data": {
"type": "bank_statement",
"confidence": 0.94,
"data": {
"bank_name": "Example Bank",
"account_holder": "ACME TRADING LTD",
"account_number_masked": "****9471",
"sort_code": "000000",
"statement_period": "01/10/2026 - 31/10/2026",
"currency": "GBP",
"opening_balance": 4120.55,
"closing_balance": 6134.80,
"transactions": [
{ "date": "2026-10-03", "description": "CARD PROCESSOR PAYOUT", "category": null, "amount": 2480.00, "balance": 6600.55 },
{ "date": "2026-10-05", "description": "CLOUD HOSTING", "category": null, "amount": -312.40, "balance": 6288.15 },
{ "date": "2026-10-19", "description": "OFFICE SUPPLIES", "category": null, "amount": -153.35, "balance": 6134.80 }
],
"raw_table": {
"columns": ["Date", "Description", "Money Out", "Money In", "Balance"],
"rows": [ ["03 Oct", "CARD PROCESSOR PAYOUT", "", "2,480.00", "6,600.55"], ... ]
},
"balance_check": { "status": "reconciled", "rowsChecked": 3, "rowsOk": 3, "failedRows": [], "accounts": 1,
"message": "All 3 transactions reconcile against the statement's own running balance." },
"totals_check": { "status": "reconciled", "openingBalance": 4120.55, "printedClosing": 6134.80,
"computedClosing": 6134.80, "gap": 0, "turnover": 2945.75, "accounts": 1 },
"language": "en"
}
},
"validations": { "score": { "value": 100, "grade": "green" }, "issues": [ ... ], ... }
}Every field the API returns
The schema is snake_case and identical to the payload the validation, export and reconcile endpoints accept — so the output of one call is the input of the next with no reshaping.
| Field | Type | Description |
|---|---|---|
| bank_name | string | Issuing bank or neobank as printed on the statement |
| account_holder | string | Name on the account |
| account_number_masked | string | The account number as printed (never the IBAN) |
| iban / bic / sort_code | string, optional | Present only when the statement prints them |
| statement_period | string | The period as printed on the statement |
| currency | string | Currency code (GBP, USD, EUR…) |
| opening_balance / closing_balance | number | null | Period balances used for the totals check |
| transactions[] | array | date, description, category (may be null), signed amount, balance; extra_columns when the statement has more |
| raw_table | object | Every original column and row, preserved 1:1 for audit |
| balance_check / totals_check | object | Arithmetic verdicts: reconciled, failed or not_verifiable |
The balance-check fields: how the statement proves the extraction
A bank statement carries its own proof, and the API returns that proof with every result instead of asking you to trust the parse. Two verdicts travel inside `data.data`. `balance_check` works row by row on statements that print a running balance: each printed balance must equal the previous one plus the amount beside it. It reports `rowsChecked`, `rowsOk`, `failedRows` (the exact failing row indices) and an `accounts` count, since one PDF can hold several accounts.
`totals_check` asks the question an accountant asks first: does the opening balance plus the sum of all amounts land on the closing balance the statement prints? It needs no running-balance column, which matters for card statements and many European layouts that print only date, description and amount. When it fails it reports `computedClosing` and the `gap`, so a phantom or missing row shows up as money, not a percentage. Both allow two cents for rounding, nothing more.
Each verdict is `reconciled`, `failed`, or `not_verifiable` when the statement does not print what the check needs — use them as routing signals: auto-accept when both reconcile, send `failed` to a reviewer with the rows highlighted.
| Response field | What it tells you | Typical use |
|---|---|---|
| data.data.opening_balance / closing_balance | The balances printed on the statement (null when not printed) | Period start/end balance in your ledger or credit file |
| transactions[].balance | Running balance printed beside each row, when the statement has one | Balance history, low-balance days, overdraft spotting |
| balance_check.status / failedRows | Whether every printed running balance chains to the one before it | Highlight exact rows for a reviewer |
| totals_check.status / gap | Opening + Σ amounts vs the printed closing balance, and the money unaccounted for | Hard gate before posting or scoring |
| validations.score.grade | green 90–100 · yellow 70–89 · red below 70 (with validate: true) | Auto-accept vs human review |
Error handling: every status the extract endpoint returns
Errors are JSON with an `error` message (and a `code` where a client should act on it). Two behaviours matter. First, the page balance is checked before any work starts: if the document needs more pages than the balance holds you get `429` immediately, with the needed and available counts in the message. Second, a document that turns out to have nothing extractable — a cover letter, a blank scan — returns `422` and is billed zero pages.
| Status | When it happens | What to do |
|---|---|---|
| 400 | No `file`, invalid base64, empty file, file over 20 MB, unsupported type, or an unknown `export.format` | Fix the request; the error lists supported formats |
| 401 | Missing, invalid or revoked API key | Check the key; rotate in the API dashboard |
| 422 PASSWORD_REQUIRED / PASSWORD_INCORRECT | Encrypted PDF without, or with the wrong, `password` | Ask for the password or an unlocked copy |
| 422 | No readable text, extraction failed or timed out, or nothing extractable (billed 0) | Don't retry blindly — ask for a cleaner scan or a shorter file |
| 429 | Page balance lower than the document's page count | Top up (also over the API: POST /api/v1/topup), then retry |
| 503 | Extraction temporarily unavailable | Retry with exponential backoff |
Why the data is trustworthy
Statement parsing fails silently when a converter drops rows or mis-signs a debit, so accuracy is the whole game. FlowParse runs a coordinate pass first (reading the PDF's own text geometry), falls back to AI OCR only for scanned pages, then applies multi-model field extraction and a deterministic check: opening balance plus the sum of transactions must equal the closing balance. When it doesn't, the discrepancy is flagged rather than hidden.
You can gate every result programmatically. Add `validate: true` to the extract call (or send the returned statement to `POST /api/v1/validate`) to get a 0–100 quality score, a green/yellow/red grade and a list of specific checks (balance reconciliation, duplicate rows, running-balance consistency, missing dates or amounts). That lets you auto-accept clean statements and route only the genuinely ambiguous ones to a human — see the validation engine for the rule set.
Credit card statements to structured data
The same endpoint reads credit card statement PDFs. A cardholder statement is classified as `credit_card` and comes back in its own shape, because a card bill has a due date, a minimum payment and a credit limit, and usually no running balance. The `data.data` object carries `card_issuer`, `card_last4`, the statement period, `payment_due_date`, the summary box (`previous_balance`, `payments_and_credits`, `purchases`, `fees_charged`, `interest_charged`, `new_balance`, `minimum_payment_due`, `credit_limit`) and a `transactions` array of date, description, category and signed amount — charges positive, payments and credits negative.
Card statements get their own arithmetic gate, `credit_card_check`: previous balance plus purchases, cash advances, fees and interest, minus payments and credits, should equal the new balance the issuer prints. Its `checks` array names each comparison and whether it held. For merchant settlement statements from card processors — batches, fees and net deposits rather than a cardholder bill — see the credit card processor statement converter; for cardholder statements in a spreadsheet instead of JSON, the credit card statement converter uses the same engine.
Duplicate transaction detection — what it does and what it doesn't
Duplicate rows are the quiet way a statement parse goes wrong, so here is exactly what the pipeline does on bank statements. Statements repeat payments on purpose — a direct debit in the table and again in a notice block, or a detail section that the day-by-day ledger repeats. During extraction, a repeat that has the same date and amount as a real row but no running balance of its own is removed, because it is not part of the account's balance chain. Exact repeats on the same page are removed only when the statement's own printed lines show the row was read twice — so two genuine identical €8.00 purchases on the same day, each with its own balance, both stay.
Whatever survives is still checked. With `validate: true`, the bank-statement rule `bank_no_dupes` flags any rows that share date, description and amount, and returns their indices in `lines`. If two identical rows carry different balances, the money moved twice, so they are not flagged. A flagged pair is a warning, not a deletion — the API never decides for you that a genuine repeated charge is an error.
For credit card results the honest answer is narrower. The card pipeline checks the summary arithmetic (`credit_card_check`), but there is no dedicated duplicate-flag rule on `credit_card` results today. If duplicate charges matter to you, group the returned `transactions` yourself — a few lines, shown below. For matching payments across many documents rather than within one, see duplicate payment detection.
type Tx = { date: string; description: string; amount: number | null }
export function possibleDuplicates(txs: Tx[]): number[][] {
const groups = new Map<string, number[]>()
txs.forEach((t, i) => {
if (t.amount == null) return
const key = `${t.date}|${t.amount.toFixed(2)}|${t.description.trim().toLowerCase()}`
groups.set(key, [...(groups.get(key) ?? []), i])
})
return [...groups.values()].filter((g) => g.length > 1) // review, don't auto-delete
}Turn the JSON into accounting files
Most teams don't want JSON as the final artefact — they want it inside QuickBooks, Quicken or Xero. The same key calls `POST /api/v1/export` to turn any extracted statement into a real bank-feed file: `.QBO`/`.QFX`/`.OFX` (OFX 1.0.2, with FITID de-duplication so re-imports never double-post), or a clean CSV/XLSX. File exports are billed per page; accounting previews are free so you can inspect the mapping first.
curl -X POST https://flowparse.io/api/v1/export \
-H "Authorization: Bearer pf_live_xxx" \
-d '{ "format": "quickbooks", "type": "bank_statement", "data": { ... } }'
# → { "format":"qbo", "filename":"acme-oct.qbo", "encoding":"base64", "content":"T0ZYSER..." }Many statements, one call
For a year of statements or a whole portfolio, `POST /api/v1/merge` consolidates up to 100 already-extracted documents into a single reconciled Excel — unified columns across banks, duplicate rows removed, per-row source tracking — exactly like Smart Merge in the app. Pass `preview: true` to get the summary and sheet previews for free before you spend pages on the file.
Pricing, keys and limits
Billing is simple and per page. Extraction and file exports draw from their own balance — top-up pages, separate from your account's monthly site-plan pages, which the API never spends; validation and previews are free. When a request would exceed your balance the API returns `429` with a clear message, so you never get a surprise bill — top up on the pricing page. Each key tracks its own request and page totals, visible in the dashboard.
Keys come in under a minute from the API dashboard: create, reveal once, and revoke at any time. Use a separate key per environment or per customer so you can rotate without downtime. Full request/response reference, every format and live examples live in the API docs; you can also try calls in the browser with the API playground.
| Operation | Endpoint | Billing |
|---|---|---|
| Extract PDF → JSON (+ optional validate and export in the same call) | POST /api/v1/extract | Per page, once |
| Validate / quality score | POST /api/v1/validate | Free |
| Export data you already hold (QBO/QFX/OFX/Xero/CSV/XLSX) | POST /api/v1/export | Per page (preview free) |
| Reconcile invoices ↔ payments | POST /api/v1/reconcile | Free |
| Merge many → one Excel | POST /api/v1/merge | Per page (preview free) |
| Balance and spend | GET /api/v1/usage | Free |
Security & data handling
Statements are sensitive, so the API is built to hold as little as possible. Calls run over HTTPS, authenticate with a hashed key, and the uploaded file is processed in memory to produce the JSON response — it is not retained as a downloadable document on your behalf. Each key is scoped to your account, request-counted, and instantly revocable, and every call is logged with the document label and page cost for a clean audit trail.
Because you control the request, you also control retention: store only the fields you need, drop the `raw_table` if you don't, and keep PII out of logs. FlowParse never uses your documents to train models. For the platform's wider posture see the security page.
Using a bank statement API in lending and underwriting — without replacing your systems
The most common reason teams look for an API for mortgage or commercial loan underwriting is not that they want a new platform. They already have a loan origination system and a credit model; what slows them down is typing months of applicant statements into a spreadsheet. A document API removes that step and nothing else. Your system keeps receiving the applicant's PDFs exactly as it does today, a small worker sends each one to `/api/v1/extract`, and the JSON lands in the fields and tables your system already has.
What the API gives an underwriter is data, not a decision. It returns every transaction with a signed amount and date, the printed opening and closing balances, the running balance per row, the account holder and masked account number, and the two arithmetic verdicts that show whether the statement adds up. From that your own logic computes whatever your credit policy needs — monthly inflows, average and lowest balances, overdraft days, large deposits. FlowParse does not score applicants, verify income or make lending decisions, and it does not check whether a document has been altered; it reports what is printed and whether the printed numbers are internally consistent.
A statement whose rows do not chain to its own balances is exactly the file an underwriter should look at twice. For a lender-specific walkthrough see the bank statement API for lenders and bank statement analysis for loans.
Keep your intake
Applicants upload statements to your portal or LOS exactly as before.
Send each file to /extract
A background worker posts the file with `validate: true`. Password-protected PDFs return `PASSWORD_REQUIRED`, so the worker can ask for the password.
Gate on the checks
Both `balance_check` and `totals_check` reconciled → straight through. Failed or not verifiable → the analyst queue, with the failing rows.
Compute your own metrics
Your code turns `transactions` into the figures your credit policy uses and writes them into the existing file.
Bank statement analysis and balance data from the JSON
Searches for a bank statement analysis API or a bank balance API usually want one of two things. A live account's current balance is an open-banking product — a document only shows the balances on the dates it prints. For balances and cash flow over the statement period, the JSON already holds everything: opening and closing balances, a running balance per row where printed, and every signed movement. Group `amount` by month for inflows and outflows; take the minimum `balance` for the low point.
Because every bank is normalised to the same shape, that code is written once. For the no-code route see cash flow from bank statements and the bank statement to JSON converter for the no-code route. To try the engine on a real file before writing any code, the free plan in the web app includes 10 pages a month; API calls themselves draw on a separate pre-paid page balance at €0.035 per page.
How it handles every bank format
The reason a template-based parser keeps breaking is that there is no single 'bank statement format'. One bank prints separate Money In and Money Out columns; another uses a single amount column with a Dr/Cr suffix; a third puts the running balance on the left, uses `DD/MM/YYYY` dates and a comma as the decimal separator. A US statement might show `MM/DD/YYYY` and `$`, a European one `1.234,56 €`. A naive parser needs a new rule for each of these; the FlowParse engine recognises the *meaning* of each column and normalises them all to one shape — ISO dates, a single signed amount, a numeric balance — so your code never branches on the bank.
Designing a robust integration
The extract call is synchronous: you POST a statement and the structured JSON comes back on the same request. Large multi-page scans take longer than short digital PDFs, so set a generous client timeout, and at volume put extraction behind a queue and a pool of workers so a slow document never blocks a user-facing request.
Start building today
The fastest path is: grab a key, send one statement to `/api/v1/extract`, and read the JSON. From there add `/validate` for a quality gate and `/export` or `/merge` for the output your users actually need. The guide to parsing bank statements with an API walks through a complete, production-ready integration with error handling and batching, including the queue-and-worker pattern above.
Looking for a specific format or document type? See PDF to JSON API for the generic schema view, the bank statement OCR API for scanned documents, and the document extraction API for invoices and receipts alongside statements. When you're ready to wire output into accounting software, PDF to QBO and bank statement to Xero cover the import side end to end.
Ship statement parsing this week
Create a key, POST one statement to /api/v1/extract, and get clean transaction JSON back. Validate, export to QBO/Xero and reconcile over the same API.
