REST API

Upload a statement, poll it, read the transactions. Seven endpoints, JSON over HTTPS, and amounts as exact decimal strings.

https://extractbankstatements.com/api/v1

Upload a statement, poll it, read the transactions. Seven endpoints and no SDK required: everything is JSON over HTTPS, and the two file formats are the ones a spreadsheet already opens.

Included in Pro and Business. See pricing.

Authentication#

Every request carries an API key as a bearer token:

curl https://extractbankstatements.com/api/v1/documents \
  -H "Authorization: Bearer sk_live_..."

Keys are hashed at rest. The plaintext is shown once, when it is created, and cannot be recovered afterwards, so a leaked database leaks no working credentials. If you lose a key, make a new one and revoke the old.

An invalid key returns 401 and says only that it is invalid. It will not tell you whether it expired, was revoked, or never existed; that distinction is useful to an attacker and to nobody else.

Our own web app reaches the same endpoints with a session cookie. That is a convenience for the browser, not a supported integration path, and the API key is checked first, so a request carrying one is never silently answered as whoever happens to be logged in.

The shape of a job#

  1. POST /v1/documents with the file. You get 202 back with an id and status: "pending". The extraction is queued, not run inline.
  2. A worker reads the document, classifies the rows, and reconciles the figures against the statement's own arithmetic.
  3. GET /v1/documents/{id} on a loop until status is completed or failed.
  4. GET /v1/documents/{id}/transactions for every row, with amounts as strings.
  5. If a row came back flagged, PATCH it with the right figure and we check the statement's arithmetic again.

Extraction is asynchronous because it is not fast: a 37-page statement takes seconds, and a scan takes longer still. 202 means accepted, not finished.

Endpoints#

POST /v1/documents#

multipart/form-data with the document under file. PDF, PNG, JPEG, WebP or HEIC, up to 25MB. A receipt photographed on an iPhone is a HEIC and needs no converting first.

curl -X POST https://extractbankstatements.com/api/v1/documents \
  -H "Authorization: Bearer $KEY" \
  -F "[email protected];type=application/pdf"
{ "id": "f2f567db-...", "filename": "january.pdf", "status": "pending", "reconciled": false }

Uploading a file we have already read for you returns 200 with the existing statement and a duplicate_of field, rather than reading it again. It does not count against your allowance. Retries are therefore free and safe, which matters because OCR is billed per page.

You may send kind as statement or invoice in the same form, and normally you should not. Leave it off and every way of reading the page is tried, with the one the document's own arithmetic proves being the one kept. Send it when you already know, or when a file is genuinely two documents and you want a particular reading of it. It restricts which readings are tried; it does not assert the answer, so a document declared as one thing and not proved as one still comes back unproved.

curl -X POST https://extractbankstatements.com/api/v1/documents \
  -H "Authorization: Bearer $KEY" \
  -F "[email protected];type=application/pdf" \
  -F "kind=invoice"

GET /v1/documents#

Your statements, newest first. ?limit= between 1 and 100, default 25.

GET /v1/documents/{id}#

{
  "id": "f2f567db-...",
  "status": "completed",
  "kind": "statement",
  "verification": "verified",
  "verification_summary": "Every transaction agrees with the running balance the statement prints (31 checkpoints).",
  "reconciled": true,
  "review_reason": null,
  "pages": 4,
  "currency": "EUR",
  "period_start": "2026-01-01",
  "period_end": "2026-01-31",
  "opening_balance": "1204.55",
  "closing_balance": "1766.35",
  "subtotal": null,
  "net_total": null,
  "tax_total": null,
  "gross_total": null,
  "tax_treatment": null,
  "tax_bands": []
}

Gate on verification, not on status. A document can complete without being provable. completed means we finished; verification says what the arithmetic then found, and it has three values:

verification Meaning reconciled review_reason
verified Checked against the document's own arithmetic and it agreed true null
contradicted Checked and it disagreed. The rows involved are flagged where a check can name them false what we found
not_verifiable The document carries no arithmetic that could check the figures false null

All three return the transactions. None of them is a refusal or a partial result: the rows, the exports and the totals are the same whichever answer you get. verification says what we can claim about the figures, not whether you may have them.

reconciled is verification == "verified" and is kept for clients written before there were three answers. It is safe to gate on, but it cannot tell you which of the other two you have, and those are not the same thing: one is a document that disagrees with itself, and the other is one that never made a claim you could check. verification_summary is the verdict in a sentence whichever way it went, including when it went well.

not_verifiable is not an error and not a complaint about your file. An invoice with no declared totals, or a statement with no running balance, gives us nothing to check against. We report the figures as printed and say we could not confirm them. Do not present those figures to your users as proved, and do not present them as suspect either.

contradicted says which side of the disagreement is in doubt. Usually it is our reading rather than the document: a line-item sum that misses on an invoice whose own subtotal, tax and total agree with each other is a line we read wrongly, not an invoice that is wrong, and verification_summary says so in those words. Pass it through to your users rather than replacing it with a flag of your own.

Statuses are pending, processing, completed, failed. That vocabulary is a contract; our internal ones are not, and are mapped onto it.

kind is statement or invoice, and it is what the document turned out to be rather than a guess made before reading it: every way of reading the page is tried and the one the document's own arithmetic proves is kept. It is null while the document is still being read, and null afterwards if none of the readings found anything.

An invoice's rows differ from a statement's in two ways that matter to a client. Amounts are as the invoice printed them, not signed by direction: an invoice is money out to whoever received it and money in to whoever sent it, and the page does not say which, so we do not invent a sign. Every line carries the invoice's own date, because a line item is something billed rather than something that happened on a day of its own.

Tax on an invoice#

The four figures an invoice declares about itself, and its tax broken out by rate. All null and empty on a bank statement.

{
  "kind": "invoice",
  "currency": "EUR",
  "subtotal": "978.13",
  "net_total": "978.13",
  "tax_total": "162.36",
  "gross_total": "1140.49",
  "tax_treatment": null,
  "tax_bands": [
    { "rate": "5.5", "base": "229.41", "tax": "12.62", "gross": "242.03", "treatment": null },
    { "rate": "20", "base": "748.72", "tax": "149.74", "gross": "898.46", "treatment": null }
  ]
}

tax_total: null does not mean zero. It means the document stated no tax or none could be read. A stated zero comes back as "0.00", and where the document says why it charges none, tax_treatment says so too: reverse_charge when the buyer accounts for the tax, exempt when the supply itself is. Those are different facts from a missing figure and different facts from each other, and an accounts payable system treats all three differently. tax_treatment is only ever set on a document whose tax really is zero.

rate is a percentage as a string, exact and unpadded: "20", "5.5", "0". It is null on a band the document NAMES rather than rates, which a retail invoice does with an exempt supply beside a taxed one; treatment on that band carries the reason, and a zero in its place would have lost it.

base is usually null, and that is a fact about invoices. It is filled only where the document printed a base for that rate, which the per-rate block on a wholesaler's invoice does. Most invoices state one rate and one overall net_total, and the two are not always the same quantity: part of a net can be outside the scope of the tax, as a tourist tax on a venue hire invoice is. So the overall base is in net_total and is not repeated here as if it were a per-rate one.

subtotal and net_total are two figures. They are the same number on most invoices. On one quoted tax-inclusive they are not, and both are printed: the line items add up to the subtotal, and the tax was charged on a base that covers shipping as well.

GET /v1/documents/{id}/transactions?currency=EUR#

{
  "document": { "id": "...", "verification": "verified", "reconciled": true, "currency": "EUR" },
  "data": [
    {
      "date": "2026-01-03",
      "description": "VIR INST TGD SOLUTIONS Loyer",
      "amount": "541.80",
      "balance": "1746.35",
      "net_amount": null,
      "tax_rate": null,
      "tax_amount": null,
      "verification": "verified",
      "needs_review": false,
      "review_reason": null,
      "edited_at": null
    }
  ]
}

currency narrows data to one of the accounts a statement holds, as an ISO 4217 code. A business account PDF can carry three of them and each row states its own, so this is the filter you would otherwise write yourself. document is still the whole document: the currency is a view of it rather than a second document, and its verdict was reached over every account at once.

An invoice line carries net_amount, tax_rate and tax_amount where the invoice printed them per line, and null where it did not. tax_amount is never worked out from the other two. A rate with a null tax beside it means the invoice stated the rate and not the tax; a figure we calculated would look exactly like one the supplier charged, and telling those apart is what this service is for. If you want it, round(net_amount * tax_rate / 100, 2) is yours to compute rather than ours to assert.

Every row carries its own verification, with the same three values and the same meanings as the document's. needs_review is verification == "contradicted": it is the rows the arithmetic disagrees with, and review_reason says what we found on each. A not_verifiable row has needs_review: false and no reason, because there is nothing wrong with it that anybody found. The fact that the document holds no arithmetic belongs to the document and is stated there once.

A contradicted document can carry no contradicted rows at all, and that is not a bug. Some checks disagree with an aggregate rather than with a row, an invoice's line items missing a total it declares, or a statement's transactions not reaching its stated closing balance, and there is no honest way to point at one of forty rows. The document says so, the rows stay not_verifiable, and nothing is flagged that nothing could name.

Amounts are strings. Always. 0.1 + 0.2 is not 0.3 in any language with IEEE 754 floats, and a client summing a statement must not inherit that from us. Parse them with whatever decimal type your language has: Decimal in Python, BigDecimal in Java, integer minor units in C. Negative means money left the account.

Dates are YYYY-MM-DD calendar dates with no time and no zone, because that is what a bank statement contains.

edited_at is set when somebody corrected the row by hand, and null otherwise. See the endpoint below.

PATCH /v1/documents/{id}/transactions/{transaction_id}#

Correct a figure we read wrongly. Send only the fields you want to change:

curl -X PATCH "$API/documents/$ID/transactions/$TRANSACTION_ID" \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"amount": "-25.63"}'
Field
date YYYY-MM-DD, and a real calendar date
description 1 to 500 characters
amount An exact decimal string, signed. Up to 16 digits and 2 decimal places
balance The same, or null for a row the statement printed no balance on

We check the document again before answering, which is the point of the endpoint. Your corrected figure goes through the same arithmetic as everything we read ourselves: if the running balance now holds, the row loses needs_review and the statement comes back reconciled. If it does not, it does not, and we say what still fails. Correcting a figure is not a way to override the check.

A not_verifiable document stays not_verifiable through this, and correcting a figure on one flags nothing, because there was no contradiction to clear. That is worth stating because it used to be otherwise: the re-check treated "nothing to check against" as a failure, so every row came back flagged however many figures you fixed.

So the response is the whole transaction list, not the one row, the same shape GET .../transactions returns. A correction settles the rows either side of it, because a running balance is a chain, and document.reconciled is the flag you were told to gate on.

The corrected row carries edited_at from then on, and the CSV and xlsx exports gain an Edited by hand column. We keep the two apart deliberately: a corrected row is proved exactly like any other, so nothing else about it would tell you a person supplied the number.

A statement that is still being read returns 409, because extraction replaces every row when it finishes and would throw the correction away.

There is no equivalent MCP tool. Those are read only.

GET /v1/documents/{id}/export?format=csv|xlsx|ofx&currency=EUR#

The same rows as a file.

csv is RFC 4180 with a UTF-8 BOM so Excel on Windows reads accented descriptions correctly. xlsx writes amounts as numbers and dates as dates, so the column sums and sorts without a re-format. The columns and the three verification states are set out in exporting to Excel.

ofx is an OFX 1.6 bank statement, which Xero, QuickBooks, Sage, FreeAgent, Wave, MYOB, Zoho, Odoo and GnuCash all import. It is on this endpoint and not only in the app, which is the part worth knowing if you are building on us: exporting to an accounting package covers what is in the file, where the verification verdict travels, and the three documents it refuses rather than guesses at.

A refusal is a 422 carrying code: "format_not_applicable" and a sentence saying why. csv and xlsx never refuse.

currency picks one of the accounts a statement holds, as an ISO 4217 code. A business account PDF can carry a euro account, a sterling account and a dollar account, and an OFX statement describes one account in one currency, so ask for one at a time and you get one file per account, each with its own CURDEF and LEDGERBAL. Leave it off and you get every row, which is what csv and xlsx have always given you and is the right answer for a spreadsheet. A currency the statement does not hold is a 422 currency_not_on_document naming the ones it does.

DELETE /v1/documents/{id}#

204. Removes the stored file and the extracted rows. It does not refund the allowance it used.

Errors#

RFC 9457 problem details, application/problem+json:

{
  "type": "https://www.extractbankstatements.com/errors/quota_exceeded",
  "title": "The Pro plan includes 400 statements per period, and 400 have been used.",
  "status": 402,
  "code": "quota_exceeded",
  "plan": "pro",
  "included": 400,
  "used": 400,
  "resets_at": "2026-09-11T18:42:34.323Z"
}

Branch on code, never on title. The prose is written for people and will be improved without warning. type is a stable identifier for the kind of problem, and following it brings you back to this table.

Status code What happened
400 missing_file, invalid_id, invalid_request The request was malformed. invalid_request names the field, for instance an amount that is not an exact decimal.
401 unauthenticated, invalid_api_key No key, or not a usable one.
402 quota_exceeded Allowance spent. Carries used, included and resets_at.
403 api_not_included The key's plan has no API access. Pro or Business.
404 not_found No such statement or transaction, or it belongs to someone else.
409 extraction_in_progress A correction was sent to a statement still being read. Poll it, then correct the figure.
413 file_too_large Over 25MB.
422 format_not_applicable The document cannot be written in the format asked for. Only ?format=ofx raises it: an invoice has no debits and credits, and a statement in several currencies has no single ledger balance until you say which account you want. The message says which, and csv and xlsx will still give you every figure.
422 currency_not_on_document A currency the document holds no rows in. Carries currencies, which are the ones it does hold.
422 rows_without_currency Some rows on this document carry no currency at all, so they belong to no account and asking for one account at a time would leave them out of every file. Take csv or xlsx, which carry every row.
422 currency_required A FreeAgent send of a statement holding several currency accounts, with no currency named. Carries currencies. Send one account to one FreeAgent bank account, and post again for the next.
415 unsupported_type Not a PDF or one of the image types above. The message says which types are read.
429 rate_limited Too many requests, either on this key or from this address. Carries retry_after in seconds, and a Retry-After header. Wait and retry; the key is fine.
500 internal_error Ours. The details are in our logs, not your response.

A statement or a transaction belonging to another account returns 404 rather than 403. 403 would confirm the id exists, which is a slow way to enumerate our customers.

Machine readable#

The API describes itself, and the description is generated from the same schemas the endpoints are validated against, so it cannot drift from what they actually return.

GET https://www.extractbankstatements.com/api/v1/openapi.json

It needs no key: point a client generator at it, or hand it to an agent. Discovery starts one level up, at /.well-known/api-catalog (RFC 9727), which every page also advertises in a Link header.

MCP#

The same endpoints are also an MCP server, so a model can read your statements directly rather than being handed a file. Same key, four read-only tools.

Limits#

File size 25MB
Pages per document 50
Documents a month Your plan's allowance
Requests per key 60 a minute
Rejected keys per address 30 in 5 minutes

The page limit is there because the allowance counts documents and our cost is per page. A year of statements merged into one PDF is twelve statements of work, and splitting it back up is the difference between a limit and a loophole.

A document over the limit is refused rather than partly read, and it does not count against your allowance: a document we would not read is not one you should pay for.

Rate limits#

60 requests a minute per key, the same on every plan. Over it you get a 429 with code: "rate_limited", a Retry-After header and a retry_after in the body, both in seconds. The key is still good: wait and carry on. This is counted on the key itself, so two keys do not share a budget.

30 rejected keys in 5 minutes per address. Only requests that fail to authenticate are counted, so no working integration can ever reach this: it is there so that guessing keys costs something. If you meet it while setting an integration up, you have sent us thirty bad keys in five minutes, which is usually a stale key in a retry loop. It answers with the same 429 and the same retry_after.

Uploads are additionally bounded by your plan's monthly allowance, which is the limit you will actually meet first.

A complete integration#

import time, requests

API = "https://extractbankstatements.com/api/v1"
H = {"Authorization": f"Bearer {KEY}"}

with open("january.pdf", "rb") as f:
    r = requests.post(f"{API}/documents", headers=H,
                      files={"file": ("january.pdf", f, "application/pdf")})
r.raise_for_status()
document_id = r.json()["id"]

while True:
    s = requests.get(f"{API}/documents/{document_id}", headers=H).json()
    if s["status"] in ("completed", "failed"):
        break
    time.sleep(2)

if s["status"] == "failed":
    raise RuntimeError(s["error"])
if s["verification"] == "contradicted":
    # A figure we read and the document's own arithmetic disagree. The rows are
    # all here; look at this one before you post it to a ledger.
    print("mismatch:", s["review_reason"])
elif s["verification"] == "not_verifiable":
    # Nothing on it could check the figures. Not an error, not a proof either.
    print("read, not confirmed:", s["verification_summary"])

rows = requests.get(f"{API}/documents/{document_id}/transactions", headers=H).json()["data"]

from decimal import Decimal
total = sum(Decimal(t["amount"]) for t in rows)          # Decimal, not float
print(len(rows), "transactions, net", total)