REST API
Upload a statement, poll it, read the transactions. Six endpoints, JSON over HTTPS, and amounts as exact decimal strings.
https://extractbankstatements.com/api/v1
Upload a statement, poll it, read the transactions. Six 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/statements \
-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/statementswith 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/statements/{id}on a loop untilstatusiscompletedorfailed.GET /v1/statements/{id}/transactionsfor every row, with amounts as strings.
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/statements#
multipart/form-data with the document under file. PDF, PNG, JPEG or WebP,
up to 25MB.
curl -X POST https://extractbankstatements.com/api/v1/statements \
-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.
GET /v1/statements#
Your statements, newest first. ?limit= between 1 and 100, default 25.
GET /v1/statements/{id}#
{
"id": "f2f567db-...",
"status": "completed",
"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"
}
Gate on reconciled, not on status. A statement can complete without
being provable. completed means we finished; reconciled means every figure
was checked against the statement's own arithmetic, so that the opening balance
plus the transactions equals the closing balance and the declared totals agree.
When reconciled is false, review_reason says what did not add up and the
affected rows are flagged individually.
Statuses are pending, processing, completed, failed. That vocabulary is
a contract; our internal ones are not, and are mapped onto it.
GET /v1/statements/{id}/transactions#
{
"statement": { "id": "...", "reconciled": true, "currency": "EUR" },
"data": [
{
"date": "2026-01-03",
"description": "VIR INST TGD SOLUTIONS Loyer",
"amount": "541.80",
"balance": "1746.35",
"needs_review": false
}
]
}
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.
GET /v1/statements/{id}/export?format=csv|xlsx#
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 rather than text, so they sum without a re-format.
DELETE /v1/statements/{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://extractbankstatements.com/errors/quota_exceeded",
"title": "The Pro plan includes 300 statements per period, and 300 have been used.",
"status": 402,
"code": "quota_exceeded",
"plan": "pro",
"included": 300,
"used": 300,
"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.
| Status | code |
What happened |
|---|---|---|
| 400 | missing_file, invalid_id |
The request was malformed. |
| 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 it belongs to someone else. |
| 413 | file_too_large |
Over 25MB. |
| 415 | unsupported_type |
Not a PDF or an image. |
| 429 | (none) | Too many requests on this key. Back off and retry. |
| 500 | internal_error |
Ours. The details are in our logs, not your response. |
A statement belonging to another account returns 404 rather than 403. 403
would confirm the id exists, which is a slow way to enumerate our customers.
Rate limits#
60 requests per minute per key. Uploads are additionally bounded by your plan's monthly allowance, which is the limit you will actually meet.
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}/statements", headers=H,
files={"file": ("january.pdf", f, "application/pdf")})
r.raise_for_status()
statement_id = r.json()["id"]
while True:
s = requests.get(f"{API}/statements/{statement_id}", headers=H).json()
if s["status"] in ("completed", "failed"):
break
time.sleep(2)
if s["status"] == "failed":
raise RuntimeError(s["error"])
if not s["reconciled"]:
# We read it, but could not prove it. Look before you post it to a ledger.
print("needs review:", s["review_reason"])
rows = requests.get(f"{API}/statements/{statement_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)