Developers
Document Extraction API
POST a bank statement or invoice PDF. Get back every transaction, the account metadata, and a reconciliation verdict you can verify against the document yourself.
Quickstart
Three calls: create a key, submit a PDF, read the result.
-
1. Create an API key
Register, then open Settings → API keys. Create a free account — every workspace gets 3 free API documents, no card required.
-
2. Submit a PDF
curl -X POST https://banksheet.pro/api/v1/documents \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -F "file=@statement.pdf" \ -F "type=bank_statement"Responds 202 with a job id and a polling URL.
-
3. Read the result
curl https://banksheet.pro/api/v1/documents/JOB_ID \ -H "Authorization: Bearer sk_live_YOUR_KEY"
How it works
Extraction takes between a few seconds and a couple of minutes depending on the document, so the API is asynchronous. A synchronous endpoint would time out at your HTTP client for exactly the documents that matter most.
POST /v1/documents ──▶ 202 { id, status: "queued", polling_url }
│
├──▶ GET /v1/documents/{id} ──▶ 200 { status: "processing" } (Retry-After: 3)
│ ──▶ 200 { status: "succeeded", … }
│
└──▶ POST your webhook URL (same body as the GET above)
The webhook payload is byte-identical to the polling response, so you write one parser, not two.
Try it live
Paste a key, choose a PDF, and watch the real API respond. Nothing is simulated — this runs the same endpoint your code will call.
Used only by your browser to call the API directly. It is never sent to this page, stored, or logged. Need a key?
Response
Authentication
Every request carries your key as a bearer token.
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
- sk_live_ — production keys, billed against your plan.
- sk_test_ — the same behaviour, useful for keeping staging traffic identifiable in your logs.
- Keys are stored hashed. The value is shown once at creation and cannot be recovered — rotate it if you lose it.
- Never put a key in frontend code. It carries your whole workspace quota and can read every document submitted with it.
Submit a document
POST https://banksheet.pro/api/v1/documents
| Field | Type | Notes |
|---|---|---|
| file | multipart | The PDF itself. Use this unless you have a reason not to. |
| url | string | A public https URL we fetch instead. Private and reserved addresses are refused. |
| file_base64 | string | Base64 bytes, for clients that cannot do multipart. |
| type | string |
bank_statement, invoice — defaults to bank_statement.
|
Limits: 25 MB and 200 pages per document. Larger volumes are available on request.
Polling
Poll the URL returned by the submit call. While the job is unfinished the response carries a Retry-After header — honour it rather than polling in a tight loop.
queued
Accepted, waiting for a worker.
processing
Extraction is running.
succeeded
Done. The body is final and will not change.
failed
Could not be processed. An error code explains why.
The response
Three guarantees you can build against: a field we could not read is present with a null value rather than missing, confidence is one of four words rather than a float nobody can act on, and warnings is always an array.
{
"id": "01KYJXZZPE5KWG51EVCJ1Z22WR",
"object": "document",
"type": "bank_statement",
"status": "succeeded",
"engine": "python",
"pages": 6,
"confidence": "high",
"document": {
"bank_name": { "value": "Intesa Sanpaolo", "confidence": "high" },
"account_holder": { "value": "ACME S.r.l.", "confidence": "high" },
"account_number": { "value": null, "confidence": null },
"iban": { "value": "IT60X0542811101000000123456", "confidence": "certain" },
"bic": { "value": "BCITITMM", "confidence": "certain" },
"currency": { "value": "EUR", "confidence": "certain" },
"period_start": { "value": "2026-01-01", "confidence": "high" },
"period_end": { "value": "2026-01-31", "confidence": "high" },
"opening_balance": { "value": 12450.30, "confidence": "certain" },
"closing_balance": { "value": 14980.75, "confidence": "certain" }
},
"reconciliation": {
"status": "reconciled",
"statement_balanced": true,
"opening_balance": 12450.30,
"closing_balance": 14980.75,
"expected_net_change": 2530.45,
"computed_net_change": 2530.45,
"discrepancy": 0.00,
"rows_checked": 84,
"rows_matched": 84,
"rows_flagged": 0
},
"totals": {
"transaction_count": 84,
"total_debits": 9120.10,
"total_credits": 11650.55,
"net_change": 2530.45
},
"transactions": [
{
"row": 1,
"date": "2026-01-03",
"value_date": "2026-01-03",
"description": "BONIFICO SEPA DA ROSSI SRL",
"reference": "TRN2026010312345",
"debit": null,
"credit": 1500.00,
"amount": 1500.00,
"balance": 13950.30,
"currency": "EUR",
"counterparty": "ROSSI SRL",
"category": "incoming_transfer",
"page": 1,
"confidence": "high",
"needs_review": false,
"warnings": []
},
{
"row": 2,
"date": "2026-01-04",
"description": "PAGAMENTO POS 4829",
"debit": 84.20,
"credit": null,
"amount": -84.20,
"balance": 13866.10,
"page": 1,
"confidence": "medium",
"needs_review": true,
"warnings": ["low_contrast_row"]
}
],
"warnings": [],
"completed_at": "2026-01-31T21:14:41Z",
"expires_at": "2026-02-07T21:14:41Z"
}
Null, not missing
account_number is present with a null value. Your field mapping never breaks on a key that vanished.
Four buckets, not floats
certain / high / medium / low. You can write a rule against those. Nobody can write one against 0.83.
needs_review is yours
We flag uncertain rows and return them anyway. You decide what to auto-post and what a human sees.
Reconciliation
Every vendor claims 99% accuracy and none of it is checkable. Reconciliation is: opening balance + credits − debits either equals the closing balance printed on the statement, or it does not.
We return the arithmetic, not an adjective:
"reconciliation": {
"status": "unreconciled",
"statement_balanced": false,
"opening_balance": 12450.30,
"closing_balance": 14980.75,
"expected_net_change": 2530.45,
"computed_net_change": 2489.10,
"discrepancy": -41.35,
"rows_checked": 84,
"rows_matched": 82,
"rows_flagged": 2
}
How to use it
Treat status = "reconciled" and discrepancy = 0 as safe to post automatically. Anything else should go to a human — and the rows to look at are the ones with needs_review: true. That is a far more useful automation rule than any accuracy percentage.
Webhooks
Register one callback URL and we POST to it when a document finishes.
curl -X POST https://banksheet.pro/api/v1/webhooks \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-erp.example.com/hooks/banksheet"}'
Every delivery is signed. Verify before you trust it:
// X-BankSheet-Signature: t=1785190000,v1=abc123...
[$t, $v1] = sscanf($request->header('X-BankSheet-Signature'), 't=%d,v1=%s');
$expected = hash_hmac('sha256', $t.'.'.$request->getContent(), $yourWebhookSecret);
// Constant-time compare, and reject anything older than five minutes.
if (! hash_equals($expected, $v1) || abs(time() - $t) > 300) {
abort(403);
}
Retry schedule: a non-2xx response is retried :n times at 1m, 5m, 30m, 2h, 6h, 24h. After that the delivery is abandoned — poll for anything you may have missed.
Idempotency
Send an Idempotency-Key on every submit. If your client times out and retries, you get the original job back instead of a second one — and you are not charged twice.
- Same key, same document → the original job, with an Idempotent-Replay: true header.
- Same key, different document → 409. That is a bug in your code, so we refuse rather than guess.
- Keys are honoured for 24 hours.
Errors
Branch on code — it is stable. Never parse title or detail.
{
"error": {
"code": "no_transactions_detected",
"status": 422,
"title": "No transactions detected",
"detail": "The document was readable but no transaction rows matched a known statement layout.",
"doc_url": "https://banksheet.pro/api-docs#error-no_transactions_detected"
}
}
| Code | HTTP | What to do |
|---|---|---|
| unauthorized | 401 | Send your key as: Authorization: Bearer sk_live_... |
| invalid_api_key | 401 | The key is unknown, revoked, or expired. |
| workspace_unavailable | 403 | The workspace this key belongs to no longer exists. |
| insufficient_scope | 403 | This API key is not permitted to perform that action. |
| trial_exhausted | 402 | You have used all free API documents. Upgrade to a plan that includes API access to continue. |
| api_not_included | 402 | Upgrade to Starter or above to use the API beyond the free trial. |
| quota_exceeded | 402 | Your plan's monthly API document allowance is used. It resets at the start of your next billing period, or upgrade for a higher limit. |
| file_required | 422 | Send a PDF as multipart "file", a public "url", or base64 in "file_base64". |
| invalid_file | 422 | The uploaded bytes are not a PDF. Check the file before sending. |
| file_too_large | 413 | The document exceeds the maximum accepted size. |
| too_many_pages | 422 | The document exceeds the maximum accepted page count. |
| unsupported_type | 422 | Supported values for "type" are listed at https://banksheet.pro/api-docs. |
| invalid_url | 422 | The URL must be a public https address returning a PDF. |
| invalid_request | 422 | One or more parameters are missing or malformed. |
| no_text_found | 422 | The PDF has no extractable text and OCR produced nothing. It may be a blank or image-only scan. |
| no_transactions_detected | 422 | The document was readable but no transaction rows matched a known statement layout. |
| extraction_failed | 422 | The document could not be processed. If it reproduces, contact support with the job id. |
| idempotency_conflict | 409 | This Idempotency-Key was already used with different parameters. Use a new key. |
| not_found | 404 | No such job or document for this workspace. |
| result_expired | 410 | Results are retained for a limited window and this one has been deleted. |
| rate_limited | 429 | Slow down and retry after the interval in the Retry-After header. |
| server_error | 500 | Something went wrong on our side. Retry, and contact support if it persists. |
| service_unavailable | 503 | The extraction service is busy. Retry after the interval in the Retry-After header. |
Rate limits & quotas
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset so you can self-throttle instead of discovering the limit by being rejected.
- Submissions: 60 per minute per key.
- Reads: 300 per minute per key.
- Check what is left at any time:
GET https://banksheet.pro/api/v1/usage
Pricing
API access is included in your subscription. There is no separate API bill.
| Plan | Price | API documents / month |
|---|---|---|
| Free trial | €0 | 3 documents, one-off, on any plan |
| Free | €0/mo | Trial only |
| Starter | €20/mo | 250 |
| Professional | €79/mo | 1,500 |
| Enterprise | Contact us | 8,000 |
Higher volumes, a dedicated instance or an on-premise licence: get in touch.
Security & data residency
No third-party processing
Extraction runs on local libraries on our own servers — poppler, Tesseract and our own parsing engines. Your documents are not sent to any external OCR or AI service.
EU hosting
Documents are processed and stored in the EU. A dedicated single-tenant instance in a region of your choice is available.
Retention you control
Results and source files are deleted automatically after 7 days, and DELETE /v1/documents/{id} removes a document and its result immediately.
No training on your data
Your documents are never used to train or tune anything. They are extracted and then deleted.
On-premise
The whole pipeline can run inside your own infrastructure under licence, with no outbound connections at all.
Credentials
API keys and webhook secrets are stored hashed. Keys can be revoked instantly and every request records which key was used.
A Data Processing Agreement and our sub-processor list are available on request.
OpenAPI & SDKs
The full specification is published, so you can generate a client in your own language rather than waiting for us to ship an SDK.
Download openapi.jsonPHP
$response = Http::withToken($apiKey)
->attach('file', file_get_contents($path), 'statement.pdf')
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->post('https://banksheet.pro/api/v1/documents');
$jobId = $response->json('id');
Python
import requests, uuid, time
submit = requests.post(
"https://banksheet.pro/api/v1/documents",
headers={"Authorization": f"Bearer {api_key}", "Idempotency-Key": str(uuid.uuid4())},
files={"file": open("statement.pdf", "rb")},
data={"type": "bank_statement"},
).json()
while True:
r = requests.get(submit["polling_url"], headers={"Authorization": f"Bearer {api_key}"})
doc = r.json()
if doc["status"] in ("succeeded", "failed"):
break
time.sleep(int(r.headers.get("Retry-After", 3)))
print(doc["reconciliation"]["statement_balanced"], len(doc["transactions"]))
C#
using var form = new MultipartFormDataContent();
form.Add(new StreamContent(File.OpenRead("statement.pdf")), "file", "statement.pdf");
form.Add(new StringContent("bank_statement"), "type");
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
form.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
var response = await http.PostAsync("https://banksheet.pro/api/v1/documents", form);
Ready to build?
Create a workspace, generate a key, and run your first extraction in a couple of minutes. The first 3 documents are free.