BankSheet Pro BankSheet Pro Document operations

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.

3 free documents No third-party OCR service EU-hosted On-premise available

Quickstart

Three calls: create a key, submit a PDF, read the result.

  1. 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. 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. 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

FieldTypeNotes
filemultipartThe PDF itself. Use this unless you have a reason not to.
urlstringA public https URL we fetch instead. Private and reserved addresses are refused.
file_base64stringBase64 bytes, for clients that cannot do multipart.
typestring 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"
  }
}
CodeHTTPWhat 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.

PlanPriceAPI 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.json

PHP

$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.