Reconcile every transaction on a bank statement
Bank and brokerage statements arrive as PDFs and scans that spreadsheets can't read. Reducto's Extract turns one into a clean, typed list of every transaction, ready to load into a ledger, dashboard, or audit trail.
Reconciling a bank statement requires going line by line, tallying debits and credits, to ensure the total is correct. On a statement with dozens of pages, one missed row can be painful to track down. With Reducto, you describe the fields you need once and get every transaction back as structured data you can total in code. In this cookbook you'll build that pipeline with a single Extract call, or turn on Deep Extract, its self-verifying mode, so the agent keeps re-extracting until the balance reconciles.
What you'll build
markdownBank statement (PDF or scan) -> Reducto Extract with your schema -> Every transaction as structured JSON -> Flip on deep_extract for long statements and to reconcile the totals
Setup
Grab an API key from studio.reducto.ai → API Keys → Create new API key, then set it and install the SDK:
bashexport REDUCTO_API_KEY="your-api-key-here" pip install reducto # Python npm install reducto # JavaScript
Use any statement: a downloaded bank PDF, a brokerage statement, or a scan. No template setup or pre-labeling required.
Built for PHI. Reducto is SOC 2 Type II compliant on every plan, with HIPAA (under a signed BAA) and zero data retention available on Growth and Enterprise plans. For data that cannot leave your environment, Enterprise adds VPC and on-premise deployment. See Enterprise Readiness (https://docs.reducto.ai/enterprise/enterprise-readiness) for details.
Step 1: Define your schema
Extract is schema driven. The field names and descriptions you write are the search hints the model uses to locate values, so be specific. Use enum for known value sets, and keep nesting shallow.
pythonschema = { "type": "object", "properties": { "account_holder": { "type": "string", "description": "Name on the account", }, "account_number": { "type": "string", "description": "Account number as printed, often masked", }, "statement_period": { "type": "string", "description": "The statement period or date range", }, "opening_balance": { "type": "number", "description": "Beginning balance for the period", }, "closing_balance": { "type": "number", "description": "Ending balance for the period", }, "transactions": { "type": "array", "items": { "type": "object", "properties": { "date": {"type": "string", "description": "Transaction date"}, "description": {"type": "string", "description": "Transaction description or payee"}, "amount": {"type": "number", "description": "Transaction amount as a positive number"}, "type": { "type": "string", "enum": ["debit", "credit"], "description": "debit if it reduces the balance, credit if it increases it", }, }, }, "description": "Every transaction row in the statement activity table", }, }, }
javascriptconst schema = { type: "object", properties: { account_holder: { type: "string", description: "Name on the account" }, account_number: { type: "string", description: "Account number as printed, often masked", }, statement_period: { type: "string", description: "The statement period or date range", }, opening_balance: { type: "number", description: "Beginning balance for the period", }, closing_balance: { type: "number", description: "Ending balance for the period", }, transactions: { type: "array", items: { type: "object", properties: { date: { type: "string", description: "Transaction date" }, description: { type: "string", description: "Transaction description or payee" }, amount: { type: "number", description: "Transaction amount as a positive number" }, type: { type: "string", enum: ["debit", "credit"], description: "debit if it reduces the balance, credit if it increases it", }, }, }, description: "Every transaction row in the statement activity table", }, }, };
Step 2: Run Extract
Upload the form and call Extract with your schema. Enable citations so every value comes back with the page and location it was read from, an audit trail you want for anything touching PHI. The system_prompt gives the model document-level context.
pythonfrom pathlib import Path from reducto import Reducto client = Reducto() upload = client.upload(file=Path("bank_statement.pdf")) result = client.extract.run( input=upload.file_id, instructions={ "schema": schema, "system_prompt": ( "This is a bank statement. Extract the account summary and every " "transaction in the activity table. Do not treat summary or " "running-balance rows as transactions." ), }, ) print(f"Pages: {result.usage.num_pages}, fields: {result.usage.num_fields}, credits: {result.usage.credits}")
javascriptimport Reducto from "reductoai"; import fs from "fs"; const client = new Reducto(); const upload = await client.upload({ file: fs.createReadStream("bank_statement.pdf"), }); const result = await client.extract.run({ input: upload.file_id, instructions: { schema, system_prompt: "This is a bank statement. Extract the account summary and every " + "transaction in the activity table. Do not treat summary or " + "running-balance rows as transactions.", }, }); console.log(`Pages: ${result.usage.num_pages}, fields: ${result.usage.num_fields}`);
Step 3: Read the output and reconcile
The result mirrors your schema: a summary object with a transactions array. Extract returns only what is on the page, so do the math yourself. Tally the credits and debits and check them against the stated closing balance.
pythondata = result.result[0] credits = round(sum(t["amount"] for t in data["transactions"] if t["type"] == "credit"), 2) debits = round(sum(t["amount"] for t in data["transactions"] if t["type"] == "debit"), 2) expected_close = round(data["opening_balance"] + credits - debits, 2) print(f"{len(data['transactions'])} transactions") print(f"Opening {data['opening_balance']}, computed close {expected_close}, stated {data['closing_balance']}") print("Reconciled" if expected_close == round(data["closing_balance"], 2) else "Mismatch")
javascriptconst data = result.result[0]; const sum = (type) => Math.round( data.transactions .filter((t) => t.type === type) .reduce((acc, t) => acc + t.amount, 0) * 100 ) / 100; const expectedClose = Math.round((data.opening_balance + sum("credit") - sum("debit")) * 100) / 100; console.log(`${data.transactions.length} transactions`); console.log(`Computed close ${expectedClose}, stated ${data.closing_balance}`); console.log(expectedClose === Math.round(data.closing_balance * 100) / 100 ? "Reconciled" : "Mismatch");
A trimmed example of the structured result (illustrative):
json{ "account_holder": "Maria Sandoval", "account_number": "****4821", "statement_period": "May 1 - May 31, 2026", "opening_balance": 4120.55, "closing_balance": 5267.93, "transactions": [ { "date": "2026-05-03", "description": "Payroll Deposit", "amount": 2200.00, "type": "credit" }, { "date": "2026-05-06", "description": "Rent", "amount": 1850.00, "type": "debit" }, { "date": "2026-05-09", "description": "Grocery Outlet", "amount": 112.62, "type": "debit" } ] }
To get an audit trail, add citations to settings({"citations": {"enabled": True}})to get a page and bounding box for every figure. Turning citations on changes the response shape:result.resultcomes back as a single object instead of a list (drop the[0]), and every value is wrapped as{value, citations}. Read the list asresult.result["transactions"]and each field astxn["amount"].value.
Step 4: Turn on Deep Extract to reconcile
A single pass handles short statements. Reach for Deep Extract when a statement runs long, has hundreds of rows a single pass might truncate, or the totals must tie out exactly. It is the same call with one extra setting, plus a verification instruction that tells the agent what "done" means.
python# Same extract.run call as Step 2, with two changes: settings = { "deep_extract": True, # turn on the agentic loop } # Add one line to the system_prompt so the loop reconciles before it stops: # "Iterate until opening_balance plus all credits minus all debits equals closing_balance."
javascript// Same extract.run call as Step 2, with two changes: const settings = { deep_extract: true, // turn on the agentic loop }; // Add one line to the system_prompt so the loop reconciles before it stops: // "Iterate until opening_balance plus all credits minus all debits equals closing_balance."
Nothing else changes: the schema, the upload, and the Step 3 read code all stay exactly the same.
Under the hood, Deep Extract trades the single pass for an agent-in-the-loop: it breaks the statement into sub-agents, checks its output against the source, and re-extracts until your criteria are met. That same loop is what pulls long, repeating lists back in full instead of stopping short, and reconciliation is its canonical task. In Reducto's beta it reached 99-100% field accuracy on long, repetitive documents like these, outperforming expert human labelers.
It does more work than a single pass, so it runs longer. Both run through the same API, so pick the right one for the task: a single Extract pass for short statements, Deep Extract when a statement is long or the totals must tie out exactly.
Two things make the biggest difference:
- Verification criteria in the system prompt. Tell the loop when it's done ("iterate until the transactions reconcile to the closing balance"). Leave it out and Deep Extract will infer a reasonable one.
- A well-described schema. The agent uses your field names and descriptions on every iteration, so specific beats generic.
Prefer no code?
Do the same thing in Reducto Studio: upload a statement, define your fields, turn on the settings you need, and Run. Click any value to highlight its source on the page, then deploy the config as an API endpoint for the rest of your statements.
Where this goes next
Extract is one task on one platform. Chain the rest of the toolkit onto the same intake pipeline:
- Sort first with Classify: route bank, brokerage, and credit card statements to the right schema before extracting.
- Break apart packets with Split: divide a multi-account or multi-month statement bundle into separate documents.
- Add an audit trail with Citations: attach a page and bounding box to every figure for review and compliance.
- Add humans where it matters with Pipelines: hold any statement that does not reconcile for human review before it posts.