# Parse a document

> POST /api/v1/documents: request headers, body, response headers, statuses and versioning.

Section: API reference
Source: https://statementbear.com/docs/api/documents
Product: StatementBear statement parsing API, $0.49 per document, 25 free.

---

`POST /api/v1/documents`

Parses one bank or credit card statement PDF and returns it as JSON. This is the whole API.

## Request headers

| Header | Required | Value |
| --- | --- | --- |
| `Authorization` | Yes | `Bearer sb_live_...`. See [Authentication](https://statementbear.com/docs/authentication). |
| `Content-Type` | Yes | `application/pdf` for a raw body, or `multipart/form-data` with a part named `file`. |
| `Idempotency-Key` | Recommended | One unique string per document, reused by every retry. See [Idempotency and retries](https://statementbear.com/docs/idempotency). |
| `Content-Length` | No | When present, an oversized file is refused before the upload finishes. |

## Request body

The PDF itself, up to 20MB, in one of two shapes:

- **Raw body.** `Content-Type: application/pdf` and the bytes.
- **Multipart.** `Content-Type: multipart/form-data` with the PDF in a part named `file`. Other parts are ignored.

The file is identified from its own contents rather than the content type you declare, so a mislabelled PNG fails immediately with `415 not_a_pdf`.

## Response headers

| Header | On | Meaning |
| --- | --- | --- |
| `X-Billable-Documents` | Every response | `1` when the call is on your invoice, `0` when it is not. Free trial documents, replays and errors return `0`. |
| `Idempotent-Replayed` | Replays | `true` when the body came from storage. |
| `Allow` | `405` | `POST`. |
| `Cache-Control` | Every response | `no-store`. |

## Responses

| Status | Body | Billable |
| --- | --- | --- |
| `200` | A [document object](https://statementbear.com/docs/api/document-object). | Yes |
| `400` | `empty_body` or `missing_file`. | No |
| `401` | `missing_key` or `invalid_key`. | No |
| `402` | `trial_exhausted`, `monthly_cap_reached` or `canceled`. | No |
| `405` | `method_not_allowed`, with an `Allow` header. | No |
| `409` | `idempotency_key_not_replayable`, with a `reason`. | No |
| `413` | `file_too_large`. | No |
| `415` | `not_a_pdf` or `unsupported_media_type`. | No |
| `422` | `not_a_bank_statement`. | No |
| `429` | `too_many_concurrent_requests`. | No |
| `500` | `parse_failed`. No allowance is spent. | No |

## Example request

cURL:

```bash
curl -X POST https://statementbear.com/api/v1/documents \
  -H "Authorization: Bearer $STATEMENTBEAR_KEY" \
  -H "Content-Type: application/pdf" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @statement.pdf
```

Node.js:

```javascript
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";

const pdf = await readFile("statement.pdf");

const res = await fetch("https://statementbear.com/api/v1/documents", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.STATEMENTBEAR_KEY}`,
    "Content-Type": "application/pdf",
    // One per document. Retrying with it does not charge twice.
    "Idempotency-Key": randomUUID(),
  },
  body: pdf,
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}

const doc = await res.json();
for (const account of doc.accounts) {
  console.log(account.type, account.last4, account.transactions.length);
}
```

Python:

```python
import os, uuid, requests

with open("statement.pdf", "rb") as f:
    res = requests.post(
        "https://statementbear.com/api/v1/documents",
        headers={
            "Authorization": f"Bearer {os.environ['STATEMENTBEAR_KEY']}",
            "Content-Type": "application/pdf",
            # One per document. Retrying with it does not charge twice.
            "Idempotency-Key": str(uuid.uuid4()),
        },
        data=f.read(),
        timeout=180,
    )

if not res.ok:
    err = res.json()["error"]
    raise RuntimeError(f"{err['code']}: {err['message']}")

doc = res.json()
for account in doc["accounts"]:
    print(account["type"], account["last4"], len(account["transactions"]))
```

Java:

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.UUID;

byte[] pdf = Files.readAllBytes(Path.of("statement.pdf"));

HttpRequest request = HttpRequest.newBuilder(URI.create("https://statementbear.com/api/v1/documents"))
    .header("Authorization", "Bearer " + System.getenv("STATEMENTBEAR_KEY"))
    .header("Content-Type", "application/pdf")
    // One per document. Retrying with it does not charge twice.
    .header("Idempotency-Key", UUID.randomUUID().toString())
    .timeout(Duration.ofSeconds(180))
    .POST(HttpRequest.BodyPublishers.ofByteArray(pdf))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());

if (response.statusCode() != 200) {
    throw new IllegalStateException(response.body());
}
```

PHP:

```php
<?php
$pdf = file_get_contents('statement.pdf');

$ch = curl_init('https://statementbear.com/api/v1/documents');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $pdf,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 180,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ' . getenv('STATEMENTBEAR_KEY'),
        'Content-Type: application/pdf',
        // One per document. Retrying with it does not charge twice.
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
]);

$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data   = json_decode($body, true);

if ($status !== 200) {
    throw new RuntimeException("{$data['error']['code']}: {$data['error']['message']}");
}

foreach ($data['accounts'] as $account) {
    echo $account['type'], ' ', count($account['transactions']), PHP_EOL;
}
```

Ruby:

```ruby
require "net/http"
require "json"
require "securerandom"

uri = URI("https://statementbear.com/api/v1/documents")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV.fetch('STATEMENTBEAR_KEY')}"
req["Content-Type"]  = "application/pdf"
# One per document. Retrying with it does not charge twice.
req["Idempotency-Key"] = SecureRandom.uuid
req.body = File.binread("statement.pdf")

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 180) do |http|
  http.request(req)
end

doc = JSON.parse(res.body)
raise "#{doc['error']['code']}: #{doc['error']['message']}" unless res.code == "200"

doc["accounts"].each do |account|
  puts [account["type"], account["last4"], account["transactions"].size].join(" ")
end
```

Go:

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"

	"github.com/google/uuid"
)

type Document struct {
	ID       string `json:"id"`
	Accounts []struct {
		Type         string `json:"type"`
		Last4        string `json:"last4"`
		Transactions []struct {
			Date        string  `json:"date"`
			Description string  `json:"description"`
			Amount      float64 `json:"amount"`
			Direction   string  `json:"direction"`
		} `json:"transactions"`
	} `json:"accounts"`
}

func main() {
	pdf, err := os.ReadFile("statement.pdf")
	if err != nil {
		panic(err)
	}

	req, _ := http.NewRequest("POST", "https://statementbear.com/api/v1/documents", bytes.NewReader(pdf))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("STATEMENTBEAR_KEY"))
	req.Header.Set("Content-Type", "application/pdf")
	// One per document. Retrying with it does not charge twice.
	req.Header.Set("Idempotency-Key", uuid.NewString())

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	var doc Document
	json.NewDecoder(res.Body).Decode(&doc)

	for _, a := range doc.Accounts {
		fmt.Println(a.Type, a.Last4, len(a.Transactions))
	}
}
```

## Example response

200 OK:

```json
{
  "id": "doc_5f2a9c4b1e77d0a3c8b6e412",
  "object": "document",
  "created": 1785372094,
  "issuer": { "name": "Monzo", "domain": "monzo.com" },
  "currency": "GBP",
  "period": { "year": 2026, "month": 3 },
  "accounts": [
    {
      "type": "bank",
      "last4": "4412",
      "name": null,
      "period": { "year": 2026, "month": 3 },
      "primary": true,
      "totals": { "credits": 3240.00, "debits": 2841.55 },
      "statedTotals": { "credits": 3240.00, "debits": 2841.55 },
      "openingBalance": 1204.11,
      "closingBalance": 1602.56,
      "transactions": [
        {
          "date": "2026-03-04",
          "description": "TESCO STORES 3412",
          "amount": 42.10,
          "direction": "debit",
          "category": "groceries"
        }
      ]
    }
  ],
  "verification": { "reconciled": true, "issues": [] },
  "usage": { "documents": 1 }
}
```

## Example error

422 Unprocessable:

```json
{
  "error": {
    "code": "not_a_bank_statement",
    "message": "This document does not look like a bank or card statement.",
    "documentId": "doc_5f2a9c4b1e77d0a3c8b6e412"
  }
}
```

Branch on `error.code`. The full list is on [Errors](https://statementbear.com/docs/errors).

## Versioning

The version is in the path. Anything that would break code written against `v1` ships as `v2` at a new path, with `v1` still answering, and we email the address on your organisation first.

| Ships as a new version | Can happen in v1 |
| --- | --- |
| Removing or renaming a field. | Adding an optional field. Parse leniently and ignore what you do not recognise. |
| Changing a field's type or meaning, including the units of an amount. | Adding an error `code` under a status you already handle. |
| Adding a required request header or parameter. | Rewording an error `message`. |
| Changing which status code a failure returns. | Improving extraction accuracy, which changes the rows a given PDF returns. |

---

All documentation: https://statementbear.com/docs/llms.txt
Questions: api@statementbear.com
