Skip to main content

Bank statements (beta)

Upload a bank statement and get its transactions as JSON, CSV, Excel, QuickBooks or Quicken files. Create, upload, then poll: three calls and one download.

How does the FastOCR bank statement API work?

Create a statement to get a presigned upload URL, upload the PDF or image to it, then poll the statement. The first poll after the upload starts extraction. When the status is completed, the response carries a download link for each format: JSON, CSV, Excel (XLSX), QuickBooks QBO, QuickBooks CSV, QFX and OFX. The API is in beta, like the web converter. It uses the same bank statement pages as the website, and your first 50 pages are free.

Python SDKfastocr-sdk 0.4.0PyPIGitHub

Docs menu
On this page

Quickstart

Install the Python SDK, or use Node.js or plain HTTP. Choose a language in any code block. Every example reads the API key from FASTOCR_API_KEY. Get an API key first.

Terminalbash
pip install fastocr-sdk
Python
from fastocr_sdk import FastOCR

# Reads the key from the FASTOCR_API_KEY environment variable.
with FastOCR() as client:
    job = client.extract_statement("statement.pdf")
    client.bank_statements.save(job.id, "statement.xlsx", "xlsx")
    print(job.status, job.pages_billed, job.needs_review)

extract_statement creates the statement, uploads the file, waits for the result and returns it. The SDK always sends size_bytes. save downloads one format.

Existing integrations keep working. The older flow, with size_bytes, POST /start and GET /output, is unchanged. See Start and output.

Authentication

Send your API key as Bearer <key> in the Authorization header. Create keys in the developer console. One key works for Document OCR and bank statements.

Authenticationbash
curl https://api.fastocr.org/v1/bank-statements \
  -H "Authorization: Bearer $FASTOCR_API_KEY"

A missing, unknown or revoked key returns a gateway error before the request reaches the API. See Errors.

POST/v1/bank-statements

Create a statement and get a presigned upload URL.

Required headers

Idempotency-Key: a unique value for each statement, up to 255 characters. Repeat a request with the same key and body to get the same statement back. A replay after the upload URL expired returns a fresh URL. A different body with the same key returns 409 idempotency_key_conflict.

Request body

FieldfilenameTypestringRequiredYesDescriptionUp to 255 characters. The extension sets the upload Content-Type: .pdf, .png, .jpg, .jpeg or .webp.
Fieldsize_bytesTypeintegerRequiredNoDescriptionExact size of the file in bytes. If you send it, the API checks the uploaded file against it. If you leave it out, the API checks the real size when extraction starts.
Fieldexternal_idTypestringRequiredNoDescriptionYour own reference, up to 256 characters. The API returns it on every read.
Python
statement = client.bank_statements.create("statement.pdf", size_bytes=1_048_576, external_id="ACME-2026-10")
print(statement.id, statement.upload_url)

Response

JSON
{
  "id": "stmt_9f2c4e1a7b3d",
  "status": "awaiting_upload",
  "upload_url": "https://...s3.amazonaws.com/...",
  "expires_at": "2026-11-05T13:00:00.123456+00:00"
}

Send the file with PUT to upload_url before expires_at (one hour). Set Content-Type to application/pdf, image/png, image/jpeg or image/webp to match the extension. The URL is signed for that type.

GET/v1/bank-statements/{id}

Get one statement. This is the call you poll. When the status is awaiting_upload and the file has arrived, the call starts extraction, and the response already shows processing. A call never returns an error for a bad upload: the statement becomes failed with a code instead, so a polling loop always ends.

FieldidTypestringRequiredYesDescriptionThe statement id from the create response.
Python
statement = client.bank_statements.get(statement_id)
print(statement.status)
JSON
{
  "id": "stmt_9f2c4e1a7b3d",
  "status": "completed",
  "external_id": "ACME-2026-10",
  "pages_billed": 3,
  "pages_total": 3,
  "has_transactions": true,
  "needs_review": false,
  "coverage": "complete",
  "outputs": {
    "json": { "available": true, "url": "https://...", "expires_at": "2026-11-05T14:00:00.123456+00:00" },
    "csv": { "available": true, "url": "https://...", "expires_at": "2026-11-05T14:00:00.123456+00:00" },
    "xlsx": { "available": true, "url": "https://...", "expires_at": "2026-11-05T14:00:00.123456+00:00" },
    "qbo": {
      "available": true,
      "accounts": [1],
      "files": [{ "account": 1, "url": "https://...", "expires_at": "2026-11-05T14:00:00.123456+00:00" }]
    }
  }
}

Response fields

  • pages_billed and pages_total: pages charged and pages in the file. A failed or refunded statement bills 0.
  • needs_review and coverage (complete, unconfirmed or partial) are present on a completed statement; has_transactions is always present. The JSON file carries the detail. See JSON schema 3.
  • outputs.<format>.url and expires_at: a download link, valid for one hour. Call GET again for fresh links.
  • Accounting formats list their accounts (numbers from 1) and one link per account in files: [{ account, url, expires_at }].
  • error: on a failed statement, with type, code and message. See Errors.

GET/v1/bank-statements

List statements, newest first, in the bank_statements array. Pass next_cursor as cursor until it is null. The list shows outputs.<format>.available but no download links: get one statement for links.

FieldlimitTypeintegerRequiredNoDescription1 to 100. Default 20.
FieldcursorTypestringRequiredNoDescriptionThe next_cursor of the previous page. A cursor expires after 24 hours.
FieldstatusTypestringRequiredNoDescriptionOne of awaiting_upload, queued, processing, completed, failed.
Fieldupdated_sinceTypestringRequiredNoDescriptionOnly statements whose status changed at or after this ISO 8601 time. Percent-encode a + offset.
Python
page = client.bank_statements.list(status="completed")
for statement in page.bank_statements:
    print(statement.id, statement.pages_billed)
print(page.next_cursor)

DELETE/v1/bank-statements/{id}

Delete a statement and its files. The API returns 202 with {"status":"deleting"} and removes the files in the background. A statement in processing returns 409 statement_processing.

Python
client.bank_statements.delete(statement_id)

Start and output (optional)

Two calls from the original flow stay available. New code does not need them.

POST /v1/bank-statements/{id}/start

Starts extraction without a poll. Use it to retry a statement that failed with buy_pages after you buy a pack: a GET never restarts that statement. It returns 202. When the account is at its concurrency limit it returns 429 concurrency_limit with Retry-After. If you sent size_bytes at create, a different uploaded size returns 400 size_mismatch.

GET /v1/bank-statements/{id}/output

Returns { format, url, expires_at, pages_billed, pages_total } for one format of a completed statement. It is the only way to ask for schema=3 or include=source.

FieldformatTypestringRequiredYesDescriptionOne of json, csv, xlsx, qbo, quickbooks_csv, qfx, ofx.
FieldaccountTypeintegerRequiredNoDescriptionThe 1-based account for an accounting format. Required when the statement has several accounts.
FieldschemaTypestringRequiredNoDescription3 returns the schema 3 JSON document.
FieldincludeTypestringRequiredNoDescriptionsource adds the as-read source text to format=json.

Output formats

formatjsonFileSchema 3 JSONNotesAccounts, summaries, transactions and review signals. See JSON schema 3.
formatcsvFileCSVNotesOne file for all accounts. UTF-8 with a BOM, dot decimals, ISO dates, rows oldest first.
formatxlsxFileExcelNotesOne sheet per account with the CSV columns, plus a Summary sheet.
formatqboFileQuickBooks Web ConnectNotesOne file per account.
formatquickbooks_csvFileQuickBooks Online CSVNotesThe 3-column import CSV. One file per account.
formatqfxFileQuickenNotesOne file per account.
formatofxFileOFX 1.0.2NotesFor Xero, Wave and other tools. One file per account.

The accounting formats hold one account each. For a statement with one account, GET /output needs no account; with several, leave it out and the API returns 400 account_required.

Python
client.bank_statements.save(statement_id, "statement.qbo", "qbo", account=1)
data = client.bank_statements.json(statement_id)

Fields and columns are never renamed, removed or reordered. The API may add JSON fields and trailing CSV and Excel columns, so ignore what you do not know.

JSON schema 3

format=json returns one document with a list of accounts, each with its own transactions. Money fields are decimal strings, such as "2500.00". Parse them with a decimal type, not a float. amount is unsigned and direction gives the sign.

statement.jsonjson
{
  "schema_version": "3",
  "status": "completed",
  "needs_review": true,
  "coverage": { "state": "complete", "issues": [] },
  "document": { "bank": "Example Bank", "pages": [{ "page": 1, "status": "read" }] },
  "accounts": [
    {
      "account_number": "12345678",
      "holder": "Jane Smith",
      "type": "deposit",
      "currency": "USD",
      "period": { "start": "2025-06-01", "end": "2025-06-30" },
      "summary": {
        "opening_balance": "5000.00",
        "opening_balance_printed": true,
        "closing_balance": "7412.50",
        "closing_balance_printed": true,
        "total_in": "2500.00",
        "total_out": "87.50",
        "rows": 2,
        "rows_verified": 1,
        "ties_out": true
      },
      "issues": [],
      "transactions": [
        {
          "id": "txn_3f9a1c2b7d8e4a5f60b1c2d3",
          "date": "2025-06-02",
          "date_printed": "02/06/2025",
          "description": "Direct Deposit - Payroll",
          "reference": "PAY-2025-06",
          "amount": "2500.00",
          "direction": "in",
          "balance": "7500.00",
          "page": 1,
          "status": "verified",
          "reasons": []
        }
      ]
    }
  ]
}

Document

Fieldschema_versionTypestringDescription"3".
FieldstatusTypestringDescriptioncompleted or no_transactions.
Fieldneeds_reviewTypebooleanDescriptionA workflow signal, not a guarantee. false only when no check found an issue in any account. It is true when coverage.state is partial. Read coverage to learn whether anything is missing.
FieldcoverageTypeobjectDescription{ state, issues }. state is complete, unconfirmed or partial. Each issue is { page, type, state, detail }. Treat only complete as nothing known to be missing. Absent on old results.
FielddocumentTypeobjectDescription{ bank, pages }. pages is [{ page, status }] with read, blank, no_transactions, unsupported_script, unreadable, empty, rows_empty, rows_failed or ungrounded.
FieldaccountsTypearrayDescriptionOne object per detected account.

Account

Fieldaccount_numberTypestring?DescriptionAs printed, or null.
FieldholderTypestring?DescriptionAccount holder name.
FieldtypeTypestringDescriptiondeposit, credit_card, loan or other.
FieldcurrencyTypestring?DescriptionISO 4217 code.
FieldperiodTypeobjectDescription{ start, end } as ISO dates, or null.
FieldsummaryTypeobjectDescriptionopening_balance, opening_balance_printed, closing_balance, closing_balance_printed, total_in, total_out, rows, rows_verified and ties_out. Money is a decimal string. ties_out is null when an endpoint is missing or a row direction is unclear.
FieldissuesTypearrayDescriptionItems shaped { type, page, detail } that need review, such as unverified_rows or does_not_tie_out.
FieldtransactionsTypearrayDescriptionOldest first.

Transaction

FieldidTypestringDescriptionUnique in the document and identical across the JSON, CSV and Excel of one statement.
FielddateTypestring?DescriptionISO 8601 date. null when the year is not proven. The API never guesses a year.
Fielddate_printedTypestringDescriptionThe date as printed.
FielddescriptionTypestringDescriptionNarration. Empty, never null.
FieldreferenceTypestring?DescriptionReference or cheque number.
FieldamountTypestring?DescriptionUnsigned decimal string.
FielddirectionTypein | out | nullDescriptionnull when the statement does not show which way the amount goes.
FieldbalanceTypestring?DescriptionRunning balance as printed. Negative when the statement prints it with a minus, brackets or a debit mark.
FieldpageTypeintegerDescriptionPage number, from 1.
FieldstatusTypestringDescriptionverified: the balance chain and source checks passed. check: a check failed, so compare the row with the source. unchecked: nothing could check the row, for example the statement prints no running balances.
FieldreasonsTypestring[]DescriptionAt least one code on every row that is not verified, such as chain_break, direction_unknown or date_unproven. Ignore codes you do not know.

Verification is a review aid, not a guarantee. Apply your own controls before you automate on verified rows.

Statuses

Statusawaiting_uploadMeaningThe statement exists and the file has not arrived.FinalNo
StatusqueuedMeaningThe file arrived and the account is at its concurrency limit. The response carries Retry-After. Keep polling: a later GET starts it.FinalNo
StatusprocessingMeaningExtraction is running.FinalNo
StatuscompletedMeaningThe result is ready, including a statement with no transactions (has_transactions is false). outputs carries the download links.FinalYes
StatusfailedMeaningExtraction failed. error has the type, code and message.FinalYes

Poll every few seconds until the status is final. A statement that waits for a slot shows queued and starts on a later poll.

Pages and limits

  • The API uses the same bank statement pages as the website. Your first 50 pages are free, once per account. Then buy a Bank Statement Pack. A pack bought on the website works for the API, and the reverse. GET /v1/balance shows statement_pages_remaining.
  • Each page counts. A statement has up to 100 pages. A refused or failed statement is not charged.
  • Files: PDF up to 50 MiB, and PNG, JPG, JPEG or WebP images up to 20 MiB. Scanned statements may need more review. Loan statements are refused and not charged.
  • Concurrency, the same rule as Document OCR: 2 statements at once; 10 with a document contract or 10,000+ pages bought. Statements and documents are counted separately.

Errors

A request error returns { "error": { type, code, message, request_id } } with an HTTP status. A statement that fails during extraction is a 200 with status failed and an error object.

Failure codes

Codebuy_pagesTypeinsufficient_creditsWhenThe account has no bank statement pages left.RetryableNo. Buy a Bank Statement Pack. Then call POST /start again on the same statement.
Codeunreadable_statementTypeinvalid_requestWhenThe API could not read the statement. Nothing is charged.RetryableNo
Codenot_a_bank_statementTypeinvalid_requestWhenThe file is not a bank account statement. Nothing is charged.RetryableNo
Codeunsupported_statement_typeTypeinvalid_requestWhenThe file is a loan statement, or a scanned card statement.RetryableNo
Codescan_limit_reachedTyperate_limitWhenToo many scans or photos from this account could not be read today. Upload the PDF from online banking, or try again tomorrow.RetryableNo
Codeinvalid_uploadTypeinvalid_requestWhenThe uploaded file is over the size limit for its type, or its size does not match size_bytes. Create a new statement and upload the file again.RetryableNo
Codestatement_pages_unavailableTypeprocessing_errorWhenThe API could not reserve pages right now. Nothing is charged.RetryableYes
Codepassword_protectedTypeinvalid_requestWhenThe file has a password.RetryableNo
Codeunreadable_documentTypeinvalid_requestWhenThe API could not read the PDF.RetryableNo
Codeno_text_detectedTypeinvalid_requestWhenThe API found no readable text in the file.RetryableNo
Codecorrupted_fileTypeinvalid_requestWhenThe file has damage, or the API does not support it.RetryableNo
Codeimage_too_largeTypeinvalid_requestWhenThe image is too large or has too many pixels to read. Upload a smaller copy.RetryableNo
Codetoo_many_pagesTypeinvalid_requestWhenThe file has more pages than one document may hold.RetryableNo
Codeempty_fileTypeinvalid_requestWhenThe file has no pages.RetryableNo
CodetimeoutTypeprocessing_errorWhenProcessing took too long.RetryableYes
Codeout_of_memoryTypeprocessing_errorWhenProcessing used all available memory.RetryableYes
Codeprocessing_failedTypeprocessing_errorWhenAny other failure.RetryableYes

Request error codes

HTTP400Codemissing_idempotency_keyTypeinvalid_requestWhenA create request has no Idempotency-Key header.
HTTP400Codeinvalid_idempotency_keyTypeinvalid_requestWhenThe Idempotency-Key header is longer than 255 characters.
HTTP400Codeinvalid_filenameTypeinvalid_requestWhenfilename is missing, is not a string, or is longer than 255 characters.
HTTP400Codeunsupported_file_typeTypeinvalid_requestWhenfilename does not end in .pdf, .png, .jpg, .jpeg or .webp.
HTTP400Codeinvalid_size_bytesTypeinvalid_requestWhensize_bytes is sent and is not a positive integer.
HTTP400Codefile_too_largeTypeinvalid_requestWhensize_bytes is more than the PDF or image limit.
HTTP400Codeinvalid_external_idTypeinvalid_requestWhenexternal_id is not a string of at most 256 characters.
HTTP400Codeupload_not_foundTypeinvalid_requestWhenA POST /start request came before the file upload.
HTTP400Codesize_mismatchTypeinvalid_requestWhenA POST /start request found an uploaded file whose size differs from the size_bytes sent at create. A GET makes the statement fail with invalid_upload instead.
HTTP400Codeinvalid_formatTypeinvalid_requestWhenformat is not one of the output formats.
HTTP400Codeinvalid_accountTypeinvalid_requestWhenaccount is not a positive integer, or it is sent with a format that is not an accounting format.
HTTP400Codeaccount_requiredTypeinvalid_requestWhenThe statement has several accounts and an accounting format request has no account.
HTTP400Codeinvalid_schemaTypeinvalid_requestWhenschema is not 3.
HTTP400Codeinvalid_includeTypeinvalid_requestWheninclude is not source, or it is sent with a format other than json.
HTTP400Codeinvalid_limitTypeinvalid_requestWhenlimit is not an integer from 1 to 100.
HTTP400Codeinvalid_statusTypeinvalid_requestWhenstatus is not a public statement status.
HTTP400Codeinvalid_cursorTypeinvalid_requestWhencursor has the wrong format, or it is more than 24 hours old.
HTTP404Codestatement_not_foundTypenot_foundWhenThis account has no such statement.
HTTP404Codeoutput_not_foundTypenot_foundWhenThe statement has no file in the requested format.
HTTP409Codeidempotency_key_conflictTypeconflictWhenA request used the key before with a different body.
HTTP409Codestatement_not_startableTypeconflictWhenThe statement is completed, or failed with a code other than buy_pages.
HTTP409Codestatement_not_readyTypeconflictWhenA client requested GET /output before the statement completed.
HTTP409Codestatement_processingTypeconflictWhenA client sent DELETE for a statement that is in flight.
HTTP429Codeconcurrency_limitTyperate_limitWhenToo many statements are in flight on POST /start. Wait the Retry-After seconds, then call it again. A GET shows queued instead of this error.

Gateway error codes

HTTP401CodeunauthorizedTypegateway_errorWhenThe request has no Authorization header.
HTTP403Codeaccess_deniedTypegateway_errorWhenThe API key is unknown, malformed, or revoked.
HTTP429CodethrottledTypegateway_errorWhenThe request is above the per-key or per-address rate limit on /v1, or the shared throttle of the API gateway. A per-key or per-address 429 has message only and a Retry-After header. Wait, then retry.
HTTP403Codemissing_authentication_tokenTypegateway_errorWhenThe route does not exist and the request has no Authorization header. With a Bearer API key, the same request returns 403 gateway_error.
HTTPOtherCodegateway_errorTypegateway_errorWhenAny other gateway condition. Use the HTTP status and message.