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#
POST /v1/documentswith the file. You get202back with an id andstatus: "pending". The extraction is queued, not run inline.- A worker reads the document, classifies the rows, and reconciles the figures against the statement's own arithmetic.
GET /v1/documents/{id}on a loop untilstatusiscompletedorfailed.GET /v1/documents/{id}/transactionsfor every row, with amounts as strings.- If a row came back flagged,
PATCHit 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¤cy=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)