# Bank statement extraction
Source: https://docs.file.ai/examples/bank-statement-extraction

> Turn a multi-page bank statement into a transactions table and balances, including statements with more than 100 rows.

Examples

# Bank statement extraction

Copy pageCopy page

Turn a multi-page bank statement into a transactions table and balances, including statements with more than 100 rows.

Copy pageCopy page

## 

[​

](#the-job)

The job

A customer sends a 12-page monthly bank statement. You want the account details and balances as fields, and every transaction as a row, even when there are hundreds of them.

## 

[​

](#sample-input)

Sample input

A 12-page PDF, `statement-2026-09.pdf`. Page 1 shows the bank’s name, the account holder, the account number, the statement period, the opening balance and the closing balance. Pages 1 to 12 hold a running transactions table (date, description, debit, credit, balance) with 240 rows.

## 

[​

](#steps)

Steps

The cURL commands use two environment variables:

```
export FILEAI_API_KEY="your-api-key"
export FILEAI_BASE_URL="https://api.orion.file.ai/prod/v1"   # or your regional host
```

See [Regions](/authentication#regions) for regional hosts.

1

Upload the statement

Request an upload URL, then `PUT` the file to it within 300 seconds, as in the [financial report example](/examples/financial-report#steps). See [Uploads](/ingest/uploads) for every option.

cURL

Python

Node.js

```
curl -X POST "$FILEAI_BASE_URL/files/upload" \
  -H "x-api-key: $FILEAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "fileName": "statement-2026-09.pdf", "fileType": "application/pdf" }'

curl -X PUT "<presignedUploadURL>" \
  -H "Content-Type: application/pdf" \
  --data-binary @statement-2026-09.pdf
```

```
import os, requests

BASE = "https://api.orion.file.ai/prod/v1"
HEADERS = {"x-api-key": os.environ["FILEAI_API_KEY"]}

upload = requests.post(
    f"{BASE}/files/upload",
    headers=HEADERS,
    json={"fileName": "statement-2026-09.pdf", "fileType": "application/pdf"},
).json()

with open("statement-2026-09.pdf", "rb") as f:
    requests.put(
        upload["presignedUploadURL"], data=f, headers={"Content-Type": "application/pdf"}
    ).raise_for_status()
```

```
import { readFile } from "node:fs/promises";

const BASE = "https://api.orion.file.ai/prod/v1";
const headers = { "x-api-key": process.env.FILEAI_API_KEY };

const upload = await fetch(`${BASE}/files/upload`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ fileName: "statement-2026-09.pdf", fileType: "application/pdf" }),
}).then((r) => r.json());

await fetch(upload.presignedUploadURL, {
  method: "PUT",
  headers: { "Content-Type": "application/pdf" },
  body: await readFile("statement-2026-09.pdf"),
});
```

2

Wait for processing

Poll `GET /v1/uploads/{uploadId}` until the documents in `files` are `processed`, or wait for a [processing callback](/deliver/callbacks). Take the `fileId` from `files`.

3

Read every row

Tables longer than 100 rows are truncated unless you ask for the full table. Pass `includeFullTableData=true`:

cURL

Python

Node.js

```
curl "$FILEAI_BASE_URL/files/<fileId>/values?includeFullTableData=true" \
  -H "x-api-key: $FILEAI_API_KEY"
```

```
values = requests.get(
    f"{BASE}/files/{file_id}/values",
    headers=HEADERS,
    params={"includeFullTableData": "true"},
).json()

form = values["formValues"][0]
fields = {f["title"]: f for f in form["fields"]}
rows = [
    {cell["title"]: cell["value"] for cell in row}
    for row in fields["Transactions"]["valueArray"]
]
```

```
const values = await fetch(
  `${BASE}/files/${fileId}/values?includeFullTableData=true`,
  { headers },
).then((r) => r.json());

const form = values.formValues[0];
const fields = Object.fromEntries(form.fields.map((f) => [f.title, f]));
const rows = fields["Transactions"].valueArray.map((row) =>
  Object.fromEntries(row.map((cell) => [cell.title, cell.value])),
);
```

4

Check the totals

Compare the extracted balances with the transactions before you load them: opening balance, plus credits, minus debits, should equal the closing balance.

Python

```
credits = sum(r["Credit"] or 0 for r in rows)
debits = sum(r["Debit"] or 0 for r in rows)
opening = fields["Opening balance"]["value"] or 0   # a zero balance comes back as null
closing = fields["Closing balance"]["value"] or 0
expected = opening + credits - debits
if abs(expected - closing) > 0.005:
    flag_for_review(file_id)
```

## 

[​

](#result)

Result

Illustrative: shape from the API reference. Field and column titles depend on the bank statement schema in your workspace. Rows are shortened here.

GET /v1/files/{fileId}/values?includeFullTableData=true

```
{
  "formValues": [
    {
      "id": "67ea5f20650d2fb5e33c2a04",
      "fileId": "3c1a9f02-77e4-4b1e-9a51-2f0d6c8e4a10",
      "schemaName": "Bank Statement",
      "fields": [
        { "title": "Account number", "type": "string", "value": "12-3456-7890123-00" },
        { "title": "Statement period start", "type": "string", "format": "date-time", "value": "2026-09-01" },
        { "title": "Opening balance", "type": "number", "value": 18250.4 },
        { "title": "Closing balance", "type": "number", "value": 16912.15 },
        {
          "title": "Transactions",
          "type": "list",
          "value": null,
          "valueArray": [
            [
              { "title": "Date", "type": "string", "value": "2026-09-01" },
              { "title": "Description", "type": "string", "value": "Card payment, Office supplies" },
              { "title": "Debit", "type": "number", "value": 84.2 },
              { "title": "Credit", "type": "number", "value": null },
              { "title": "Balance", "type": "number", "value": 18166.2 }
            ]
          ]
        }
      ]
    }
  ],
  "count": 1,
  "currentPage": 1
}
```

*   `valueArray` holds every transaction row when you pass `includeFullTableData=true`. Without it, a table over 100 rows returns only its first 100.
*   The response is `formValues` (one entry per schema), with `count` and `currentPage` alongside.
*   Empty cells, such as `Credit` on a debit row, are `null`. **A number that is `0` also comes back as `null`**, so treat `null` as zero when you sum amounts. See [Data types](/extract/data-types).
*   Grouped amounts in the document, like `"18,250.40"`, come back as JSON numbers.

`page` and `limit` on `GET /v1/files/{fileId}/values` page through **form values** (one per schema), not table rows. There is no row-level pagination: use `includeFullTableData=true` to get the whole table in one response.

## 

[​

](#what-it-shows)

What it shows

*   Large tables need `includeFullTableData=true`. Without it you get the first 100 rows and no error.
*   A totals check catches a missed or misread row before it reaches your books.
*   The response nests rows as arrays of cells; flatten them by column `title` before loading.

To get the statement into Xero, download it from the app as **XERO\_CSV** and import it there. See [Xero export](/deliver/xero-export#download-a-bank-statement-for-xero).

## 

[​

](#next-steps)

Next steps

## Data types

Tables, `valueArray` and how values are typed.

## AI Schemas

Adjust the statement fields and columns.

## Xero export

Download bank statements as XERO\_CSV.

## Multi-page financial report

The full upload and polling flow, step by step.

[Previous](/examples/claim-evidence-pack)[

Warehouse sync

Load new and changed files into your warehouse on a schedule, with a stored watermark, cursor pagination and deduplication.

Next

](/examples/warehouse-sync)

⌘I

## On this page

*   [The job](#the-job)
*   [Sample input](#sample-input)
*   [Steps](#steps)
*   [Result](#result)
*   [What it shows](#what-it-shows)
*   [Next steps](#next-steps)
