Summary
REST API for uploading bank statements, polling processing status, and exporting converted data. Base URL: https://api.bankstatemently.com/v1.
All API responses return JSON with camelCase keys (except error.request_id and error.doc_url). Webhook payloads use snake_case for envelope keys (event_id, event_type, created_at) and camelCase for the nested data object. All timestamps are ISO 8601 UTC. All export URLs are absolute.
Authentication
Pass your API key via the X-API-Key header:
curl -H "X-API-Key: bsk_live_..." https://api.bankstatemently.com/v1/documents
Keys are org-scoped and created from the Developer Dashboard. Each key inherits the org's rate limits and credit balance.
For frontend integrations, a Clerk JWT in the Authorization: Bearer <token> header is also accepted.
Endpoints
POST /v1/documents
Upload a PDF bank statement for conversion.
Request: Send the raw PDF as the request body with Content-Type: application/pdf.
Headers:
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key |
Content-Type | Yes | Must be application/pdf |
X-Filename | No | Original filename (for display in dashboard). Defaults to document.pdf. |
X-Webhook-URL | No | HTTPS URL to receive document.completed / document.failed events for this upload. Overrides the default webhook URL configured on the API key. |
X-Password | No | Decryption password for encrypted PDFs. When provided, the PDF is decrypted and processed in a single request — no need for the two-step unlock flow. The password is used in memory only and never stored or logged. |
Idempotency-Key | No | Deduplication key — 24-hour TTL. Replayed responses include X-Idempotency-Replayed: true. |
Query parameters:
| Parameter | Required | Description |
|---|---|---|
retry | No | Set to true to reprocess a previously failed upload of the same file. Without this, re-uploading a failed file returns already_processing (409). |
Rate limit: 10 req/min
curl -X POST https://api.bankstatemently.com/v1/documents \
-H "X-API-Key: bsk_live_..." \
-H "Content-Type: application/pdf" \
-H "X-Filename: statement-jan-2026.pdf" \
--data-binary @statement.pdf
Response (202 Accepted):
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "processing",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"statements": [],
"links": {
"self": "https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851"
}
}
Response (202 Accepted) -- password-protected PDF:
When the uploaded PDF is password-protected, the document is stored but not processed. The response includes an unlock link:
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "password_required",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"statements": [],
"links": {
"self": "https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851",
"unlock": "https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851/unlock"
}
}
To proceed, call the unlock endpoint with the password. No credits are deducted until processing begins.
GET /v1/documents/:id
Poll document processing status.
Rate limit: 120 req/min
curl https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851 \
-H "X-API-Key: bsk_live_..."
Response (200 OK) -- processing:
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "processing",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"updatedAt": "2026-02-27T10:15:00Z",
"statements": [],
"links": {
"self": "https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851"
}
}
Response (200 OK) -- completed:
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "completed",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"updatedAt": "2026-02-27T10:15:25Z",
"document": {
"bank": "DBS Bank",
"country": "SG",
"currency": "SGD",
"documentType": "bank-statement",
"pageCount": 3,
"transactionCount": 47,
"accountCount": 1
},
"statements": [
{
"index": 0,
"statementPeriod": {
"from": "2026-01-01",
"to": "2026-01-31"
},
"transactionCount": 47
}
],
"exports": {
"json": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/json",
"csv": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/csv",
"xlsx": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xlsx",
"qbo": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/qbo",
"xero": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xero"
},
"links": {
"self": "https://api.bankstatemently.com/v1/documents/d290f1ee-...",
"data": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../data"
}
}
Response (200 OK) -- failed:
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "failed",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"updatedAt": "2026-02-27T10:15:12Z",
"statements": [],
"error": "Could not extract transactions from this PDF",
"links": {
"self": "https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851"
}
}
Status values: processing, completed, failed, password_required.
POST /v1/documents/:id/unlock
Unlock a password-protected document by providing the decryption password. On success, processing begins automatically and the document transitions to processing status.
The password is used in memory only -- it is never stored, logged, or persisted.
Request: application/json
| Field | Required | Description |
|---|---|---|
password | Yes | The PDF decryption password (max 1000 chars) |
Rate limit: 5 req/min
curl -X POST https://api.bankstatemently.com/v1/documents/d290f1ee-.../unlock \
-H "X-API-Key: bsk_live_..." \
-H "Content-Type: application/json" \
-d '{"password": "my-pdf-password"}'
Response (200 OK):
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "processing",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"statements": [],
"links": {
"self": "https://api.bankstatemently.com/v1/documents/d290f1ee-6c54-4b01-90e6-d701748f0851"
}
}
After unlocking, poll GET /v1/documents/:id as usual until status is completed.
Errors:
| Code | HTTP | Description |
|---|---|---|
wrong_password | 422 | Incorrect password. meta includes { attempts, max_attempts }. |
unlock_attempts_exceeded | 429 | Too many wrong attempts (max 10). Re-upload the file to reset. |
invalid_parameters | 400 | Document is not in password_required status |
document_not_found | 404 | Document does not exist or does not belong to you |
GET /v1/documents/:id/data
Retrieve structured document data including accounts and transactions. Only available when document status is completed.
Query parameters:
| Param | Default | Description |
|---|---|---|
raw | false | Set to true to include originalData (raw pre-normalization cell values) on each transaction |
limit | 500 | Max transactions per page (max 2000, or 500 when raw=true) |
offset | 0 | Pagination offset for transactions |
Rate limit: 60 req/min
curl https://api.bankstatemently.com/v1/documents/d290f1ee-.../data \
-H "X-API-Key: bsk_live_..."
# With original data and pagination
curl "https://api.bankstatemently.com/v1/documents/d290f1ee-.../data?raw=true&limit=100&offset=0" \
-H "X-API-Key: bsk_live_..."
Response (200 OK):
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"contentHash": "a1b2c3d4e5",
"createdAt": "2026-02-27T10:15:00Z",
"updatedAt": "2026-02-27T10:15:25Z",
"processedAt": "2026-02-27T10:15:25Z",
"document": {
"documentType": "bank-statement",
"bank": "DBS Bank",
"bankId": "dbs-bank-ltd",
"country": "SG",
"currency": "SGD",
"languages": ["en"],
"statementDate": "2026-01-31",
"statementPeriod": { "from": "2026-01-01", "to": "2026-01-31" },
"pageCount": 3
},
"accounts": [
{
"accountId": 0,
"accountNumber": "XXX-X-XX1234",
"accountHolderName": "John Doe",
"currency": "SGD",
"isPrimary": true,
"totals": {
"openingBalance": 5000.00,
"closingBalance": 4250.75,
"totalCredits": 1200.00,
"totalDebits": 1949.25
}
}
],
"transactions": [
{
"sequence": 0,
"date": "2026-01-02",
"description": "SALARY CREDIT",
"amount": 1200,
"direction": "credit",
"currency": "SGD",
"balance": 6200.00,
"accountId": 0,
"accountNumber": "XXX-X-XX1234"
}
],
"pagination": {
"total": 47,
"limit": 500,
"offset": 0,
"hasMore": false
},
"exports": {
"json": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/json",
"csv": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/csv",
"xlsx": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xlsx",
"qbo": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/qbo",
"xero": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xero"
}
}
Note on sequence: The sequence field is a positional index within the current processing result. It is not a permanent identifier and may change if the document is reprocessed. Use processedAt to detect when the underlying data has changed.
Note on date: The date field is either a date (YYYY-MM-DD) or a datetime (YYYY-MM-DDTHH:mm:ss) depending on the precision available in the source document. Always parse with a library that handles both formats.
Error (409 Conflict) -- document not ready:
{
"error": {
"type": "invalid_request_error",
"code": "document_not_ready",
"message": "Document is not ready (status: processing). Poll GET /v1/documents/{id} until status is \"completed\".",
"doc_url": "https://bankstatemently.com/developers/docs/errors/document_not_ready",
"meta": {
"status": "processing"
},
"request_id": "req_7yNm3kPqW2a4bZ1c"
}
}
GET /v1/documents/:id/export/:format
Download the converted document in a specific format.
Formats: json, csv, xlsx, qbo, xero
Query parameters:
| Param | Default | Description |
|---|---|---|
flavor | enhanced | original, enhanced, or normalized |
dateFormat | -- | Date format string (Xero only, e.g. DD/MM/YYYY) |
Rate limit: 30 req/min
Exports are deterministic for a given contentHash + flavor + format combination.
# Download as CSV with normalized flavor
curl -OJ https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/csv?flavor=normalized \
-H "X-API-Key: bsk_live_..."
# Download Xero-compatible CSV with AU date format
curl -OJ https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xero?dateFormat=DD/MM/YYYY \
-H "X-API-Key: bsk_live_..."
Response: The file is returned as a download with Content-Disposition: attachment and a generated filename based on the original upload name.
| Format | Content-Type |
|---|---|
json | application/json |
csv | text/csv |
xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
qbo | application/x-ofx |
xero | text/csv; charset=utf-8 |
Use -OJ with curl to save the file with the server-provided filename.
GET /v1/documents
List recent documents with pagination.
Query parameters:
| Param | Default | Description |
|---|---|---|
limit | 20 | Max items per page (max 100) |
offset | 0 | Pagination offset |
status | -- | Filter by status: processing, completed, failed, password_required |
Rate limit: 120 req/min
curl "https://api.bankstatemently.com/v1/documents?limit=10&status=completed" \
-H "X-API-Key: bsk_live_..."
Response (200 OK):
{
"data": [
{
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
"status": "completed",
"contentHash": "a1b2c3d4e5",
"filename": "statement-jan-2026.pdf",
"pageCount": 3,
"source": "api",
"createdAt": "2026-02-27T10:15:00Z",
"updatedAt": "2026-02-27T10:15:25Z",
"exports": {
"json": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/json",
"csv": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/csv",
"xlsx": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xlsx",
"qbo": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/qbo",
"xero": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xero"
}
}
],
"pagination": {
"total": 42,
"limit": 10,
"offset": 0,
"hasMore": true
}
}
The exports field is only present when status is completed. The source field indicates the upload origin ("api" or "dashboard").
GET /v1/credits
Check your current credit balance. Recommended: call this before large uploads to ensure sufficient credits.
Rate limit: 120 req/min
curl https://api.bankstatemently.com/v1/credits \
-H "X-API-Key: bsk_live_..."
Response (200 OK):
{
"balance": 47,
"limit": 100,
"planId": "pro",
"creditsExpireAt": "2026-04-01T00:00:00.000Z"
}
| Field | Type | Description |
|---|---|---|
balance | integer | Current remaining credits. 1 credit = 1 page. |
limit | integer | Plan allowance (10 for free, plan-specific for paid). |
planId | string | Current plan: free, pro, or business. |
creditsExpireAt | string | null | When current plan credits reset, or null for plans without expiry. |
Response is cached for 5 seconds (Cache-Control: private, max-age=5).
POST /v1/benchmark/evaluate
Evaluate parsed bank statement transactions against the Bank Statement Parsing Benchmark ground truth. Free to use — no credits consumed.
The contentHash is the SHA-256 hex digest of the benchmark PDF you parsed. This ties the evaluation to the exact file, supports dataset versioning, and prevents ID-based guessing.
Request: application/json
| Field | Required | Type | Description |
|---|---|---|---|
contentHash | Yes | string | SHA-256 hex digest of the benchmark PDF (64 lowercase hex characters) |
transactions | Yes | array | Parsed transactions, 1-2000 (see schema below) |
Transaction object:
| Field | Required | Type | Description |
|---|---|---|---|
date | Yes | string | ISO 8601 date (YYYY-MM-DD) |
description | Yes | string | Transaction description |
amount | Yes | number | Transaction amount. Positive absolute value, or negative to indicate debit. |
direction | No | string | "credit" or "debit". If omitted, inferred from amount sign (negative = debit, positive/zero = credit). Negative amount with "credit" is rejected as contradictory. |
balance | No | number | Running balance (used in parsed scoring only — normalized scoring does not evaluate balance) |
currency | No | string | ISO 4217 currency code |
originalData | Yes | object | Raw column values as on the PDF (see below) |
originalData format: A flat Record<string, string> where each key is the column header exactly as it appears in the PDF, and each value is the cell text exactly as printed — no normalization, no type conversion. All values are strings, even amounts and dates.
{
"Date": "02/06/2025",
"Description": "NTUC FAIRPRICE",
"Withdrawal (-)": "12.20",
"Deposit (+)": "",
"Balance": "15,438.55"
}
Rate limit: 50 req/hour (per API key)
HASH=$(shasum -a 256 bsb-001-statement.pdf | cut -d' ' -f1)
curl -X POST https://api.bankstatemently.com/v1/benchmark/evaluate \
-H "Content-Type: application/json" \
-H "X-API-Key: bsk_live_..." \
-d "{
\"contentHash\": \"$HASH\",
\"transactions\": [
{
\"date\": \"2025-06-02\",
\"description\": \"NTUC FAIRPRICE\",
\"amount\": 12.20,
\"direction\": \"debit\",
\"balance\": 15438.55,
\"originalData\": {
\"Date\": \"02/06/2025\",
\"Description\": \"NTUC FAIRPRICE\",
\"Withdrawal (-)\": \"12.20\",
\"Balance\": \"15,438.55\"
}
}
]
}"
Response (200 OK):
{
"contentHash": "a1b2c3d4e5f6...",
"id": "bsb-001",
"datasetVersion": "2.0",
"difficulty": "basic",
"challenges": ["credit-debit-columns", "balance-validation", "multi-line-descriptions"],
"normalizedScore": {
"overall": 0.94,
"extraction": 0.97,
"integrity": 0.97,
"fields": { "date": 0.98, "description": 0.91, "amount": 0.95 },
"alignment": { "matched": 11, "missing": 1, "extra": 0, "total": 12 }
},
"parsedScore": {
"overall": 0.95,
"extraction": 0.98,
"integrity": 0.97,
"fields": { "Date": 1.00, "Description": 0.95, "Withdrawal (-)": 1.00, "Deposit (+)": 1.00, "Balance": 0.94 },
"alignment": { "matched": 11, "missing": 1, "extra": 0, "total": 12 }
}
}
| Field | Description |
|---|---|
contentHash | Echo of the submitted hash |
id | Statement ID (e.g. bsb-001) |
datasetVersion | Ground truth version |
difficulty | basic, intermediate, or advanced |
challenges | Parsing challenges present in the statement |
normalizedScore | Scores against canonical values (ISO dates, numeric amounts) |
parsedScore | Scores against raw PDF cell values |
Scoring details:
overall— headline score:extraction × integrity(0-1). Both components must be strong.extraction— weighted accuracy across all fields (0-1)integrity— structural correctness: do credit/debit sums match, are all rows present, are polarities correct? (0-1)fields— per-field accuracy across matched rows. Normalized uses canonical names (date,description,amount). Parsed uses the original column headers from the PDF (e.g.Date,Withdrawal (-),Balance) — these vary by statement.alignment—matched,missing(in ground truth but not submission),extra(in submission but not ground truth),total(ground truth count)
Webhooks
Configure webhook URLs when creating an API key in the Developer Dashboard, or update them later. HTTPS endpoints only.
Events
| Event | Fired when |
|---|---|
document.completed | Processing finished successfully |
document.failed | Processing failed permanently |
Payload
document.completed:
{
"event_id": "evt_a1b2c3d4-...",
"event_type": "document.completed",
"created_at": "2026-02-27T10:15:30Z",
"data": {
"id": "d290f1ee-...",
"contentHash": "a1b2c3d4e5",
"status": "completed",
"exports": {
"json": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/json",
"csv": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/csv",
"xlsx": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xlsx",
"qbo": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/qbo",
"xero": "https://api.bankstatemently.com/v1/documents/d290f1ee-.../export/xero"
}
}
}
document.failed:
{
"event_id": "evt_e5f6g7h8-...",
"event_type": "document.failed",
"created_at": "2026-02-27T10:15:30Z",
"data": {
"id": "d290f1ee-...",
"contentHash": "a1b2c3d4e5",
"status": "failed",
"error": "Could not extract transactions from this PDF"
}
}
Verification
Each request includes:
X-Webhook-Signature-- HMAC-SHA256 hex digest of the raw body, signed with your webhook secretX-Webhook-Id-- unique delivery ID for idempotencyX-Webhook-Event-Id-- matches theevent_idin the payload body
import hmac, hashlib
expected = hmac.new(webhook_secret.encode(), request_body.encode(), hashlib.sha256).hexdigest()
assert hmac.compare_digest(expected, request.headers["X-Webhook-Signature"])
Retries
Failed deliveries (non-2xx or timeout) retry up to 3 times with backoff: immediate, ~30s, ~5min.
Error Schema
All errors return a consistent envelope with a broad type, a specific code, a human-readable message, and an optional meta object with structured data:
{
"error": {
"type": "quota_exceeded",
"code": "insufficient_credits",
"message": "Insufficient credits. This file requires 3 credit(s) but you have 1 remaining. Purchase more at https://bankstatemently.com/pricing.",
"doc_url": "https://bankstatemently.com/developers/docs/errors/insufficient_credits",
"meta": {
"credits_required": 3,
"credits_remaining": 1
},
"request_id": "req_7yNm3kPqW2a4bZ1c"
}
}
type-- broad category (5 values, frozen). Switch on this for your retry/handling strategy.code-- specific error (append-only, never renamed). Switch on this for granular handling.message-- human-readable with actionable context. Do not parse -- usecodeinstead.doc_url-- link to the documentation page for this error code. Always present.meta-- optional structured data for programmatic use. Shape is deterministic bycode(same code always produces the samemetashape). Absent when no structured data applies.request_id-- unique request identifier. Include when contacting support. Uses snake_case for compatibility with error-handling conventions.
Error Types
| Type | Strategy | HTTP Codes |
|---|---|---|
authentication_error | Fix API key | 401 |
invalid_request_error | Fix the request -- never retry as-is | 400, 404, 409, 422, 429 |
quota_exceeded | Buy credits or upgrade plan -- do NOT retry | 403 |
rate_limit_error | Retry with exponential backoff | 429 |
api_error | Retry later or contact support | 500, 503 |
Error Codes
authentication_error
| Code | HTTP | Description |
|---|---|---|
invalid_api_key | 401 | Missing, malformed, or unrecognized API key |
key_revoked | 401 | API key has been revoked |
key_expired | 401 | API key has expired |
invalid_request_error
| Code | HTTP | Description | meta |
|---|---|---|---|
missing_file | 400 | No file attached to the request | -- |
invalid_file_type | 400 | File is not a supported format (PDF only) | -- |
wrong_password | 422 | Incorrect password for encrypted PDF | { attempts, max_attempts } |
unlock_attempts_exceeded | 429 | Too many wrong password attempts | { attempts, max_attempts } |
invalid_pdf | 400 | File could not be read as a valid PDF | -- |
unsupported_document_type | 400 | Document is not a bank or credit card statement | { detected_type, confidence, reason } — see reason values below |
invalid_parameters | 400 | Request parameters failed validation, or file field has wrong name (must be file) | { fields: [{ field, message }] } (when applicable) |
invalid_export_flavor | 400 | Invalid export flavor value | -- |
endpoint_not_found | 404 | The requested API endpoint does not exist | -- |
document_not_found | 404 | Document does not exist or does not belong to you | -- |
document_not_ready | 409 | Document processing not yet completed | { status } |
already_processing | 409 | Document is already being processed, or was previously uploaded and failed (re-upload with retry=true to reprocess) | -- |
export_failed | 422 | Export generation failed | -- |
unsupported_document_type — meta.reason values:
reason | Description |
|---|---|
document_type_mismatch | Pre-processing identified a known non-statement type (e.g. invoice, receipt, tax form) with high confidence. No VLM call was made. |
no_financial_content | Document has readable text but no financial signals (no amounts, balances, or transaction patterns). No VLM call was made. |
vlm_rejected | Document passed pre-processing checks but the VLM found zero transactions. meta.vlm_reasoning may contain additional detail. |
quota_exceeded
| Code | HTTP | Description | meta |
|---|---|---|---|
insufficient_credits | 403 | Not enough credits for this file | { credits_required, credits_remaining } |
page_limit_exceeded | 403 | File exceeds your plan's page limit | { page_count, page_limit } |
file_too_large | 403 | File exceeds your plan's size limit | { max_size_mb } |
rate_limit_error
| Code | HTTP | Description | meta |
|---|---|---|---|
rate_limit_exceeded | 429 | Too many requests | { retry_after } |
api_error
| Code | HTTP | Description |
|---|---|---|
internal_error | 500 | Unexpected server error |
service_unavailable | 503 | Service temporarily unavailable |
Response Headers
| Header | Description |
|---|---|
X-Request-Id | Unique request identifier (use when contacting support) |
X-API-Version | Always 1 |
RateLimit-Limit | Max requests allowed in the current window |
RateLimit-Remaining | Requests remaining in the current window |
RateLimit-Reset | Unix timestamp (seconds) when the window resets |
Retry-After | Seconds until rate limit resets (on 429 only) |
X-Idempotency-Replayed | true when returning a cached idempotent response |
Rate Limits
Limits are per org. Exceeding a limit returns 429 with a Retry-After header.
| Endpoint | Limit |
|---|---|
POST /v1/documents | 10 req/min |
POST /v1/documents/:id/unlock | 5 req/min |
GET /v1/documents/:id/export/:format | 30 req/min |
GET /v1/documents/:id/data | 60 req/min |
GET /v1/documents, GET /v1/documents/:id | 120 req/min |
GET /v1/credits | 120 req/min |
POST /v1/benchmark/evaluate | 50 req/hour |
| Global baseline | 120 req/min |
Quick Start Examples
Python
import requests, time
API_KEY = "bsk_live_..."
BASE = "https://api.bankstatemently.com/v1"
headers = {"X-API-Key": API_KEY}
# 1. Upload
with open("statement.pdf", "rb") as f:
resp = requests.post(f"{BASE}/documents", headers=headers, files={"file": f})
doc = resp.json()
if "error" in doc:
print(f"Upload failed: {doc['error']['code']} - {doc['error']['message']}")
exit(1)
doc_id = doc["id"]
# 2. Handle password-protected PDFs
if doc["status"] == "password_required":
password = input("PDF is encrypted. Enter password: ")
resp = requests.post(f"{BASE}/documents/{doc_id}/unlock", headers=headers,
json={"password": password})
doc = resp.json()
# 3. Poll until complete
while doc["status"] == "processing":
time.sleep(2)
doc = requests.get(f"{BASE}/documents/{doc_id}", headers=headers).json()
if doc["status"] == "failed":
print("Processing failed:", doc.get("error"))
exit(1)
# 4. Get structured data (accounts + transactions)
data = requests.get(f"{BASE}/documents/{doc_id}/data", headers=headers).json()
for txn in data["transactions"]:
print(f"{txn['date']} {txn['description']} {txn['amount']} {txn['direction']}")
# 5. Export as file
csv_resp = requests.get(f"{BASE}/documents/{doc_id}/export/csv", headers=headers)
with open("output.csv", "wb") as f:
f.write(csv_resp.content)
Node.js
import { readFileSync, writeFileSync } from "node:fs";
import { createInterface } from "node:readline/promises";
const API_KEY = "bsk_live_...";
const BASE = "https://api.bankstatemently.com/v1";
const headers = { "X-API-Key": API_KEY };
async function convert(pdfPath) {
// 1. Upload
const form = new FormData();
form.append("file", new Blob([readFileSync(pdfPath)]), "statement.pdf");
let doc = await fetch(`${BASE}/documents`, {
method: "POST", headers, body: form
}).then(r => r.json());
if (doc.error) {
console.error(`Upload failed: ${doc.error.code} - ${doc.error.message}`);
return;
}
// 2. Handle password-protected PDFs
if (doc.status === "password_required") {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const password = await rl.question("PDF is encrypted. Enter password: ");
rl.close();
doc = await fetch(`${BASE}/documents/${doc.id}/unlock`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ password })
}).then(r => r.json());
}
// 3. Poll until complete
while (doc.status === "processing") {
await new Promise(r => setTimeout(r, 2000));
doc = await fetch(`${BASE}/documents/${doc.id}`, { headers }).then(r => r.json());
}
if (doc.status === "failed") {
console.error("Processing failed:", doc.error);
return;
}
// 4. Get structured data (accounts + transactions)
const data = await fetch(`${BASE}/documents/${doc.id}/data`, { headers }).then(r => r.json());
console.log(`Found ${data.transactions.length} transactions`);
// 5. Export as file
const csv = await fetch(`${BASE}/documents/${doc.id}/export/csv`, { headers });
writeFileSync("output.csv", Buffer.from(await csv.arrayBuffer()));
}
convert("statement.pdf");
FAQ
What file formats does the API accept?
The API accepts PDF files. Upload via multipart form data or raw binary with Content-Type: application/pdf.
How does pricing work?
1 credit per page of bank statement processed. Check your balance anytime via GET /v1/credits.
What output formats are supported?
JSON (default), CSV, XLSX, QuickBooks (QBO), and Xero. CSV and XLSX open directly in Google Sheets. Specify the format in the export endpoint path.
How do I handle password-protected PDFs?
Upload the PDF normally, then use POST /v1/documents/:id/unlock with the password to decrypt and process it.
Does the API support webhooks?
Yes. Pass a webhook_url when uploading and receive POST callbacks for document.completed and document.failed events with HMAC signature verification.
Is there an OpenAPI spec?
Yes. The OpenAPI 3.1 specification is available at /v1/openapi.json for code generation and API client integration.
MCP Server