# Quickstart

> Parse your first statement in about five minutes, from creating a key to reading the response.

Section: Get started
Source: https://statementbear.com/docs/quickstart
Product: StatementBear statement parsing API, $0.49 per document, 25 free.

---

From signing up to parsed JSON in about five minutes.

## 1. Create a key

Sign in and open the [developer console](https://statementbear.com/dashboard/api). Creating an organisation issues your first key in the same step. No payment method is needed. The key is shown once, so copy it into your secret store now.

> **There is no test mode**
>
> No `sb_test_` key and no sandbox host. Your first 25 documents are free on the live endpoint. See [Authentication](https://statementbear.com/docs/authentication).

## 2. Send a statement

POST the PDF as a raw body with `Content-Type: application/pdf`, or as `multipart/form-data` with a part named `file`. Up to 20MB. A parse takes 10 to 40 seconds, so set a client timeout of at least 180 seconds.

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))
	}
}
```

## 3. Read the response

A success is a `document` object. Every account on the PDF is in `accounts`, with its rows inside it. `amount` is always positive and `direction` carries the sign.

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 }
}
```

## 4. Check the response

1. **Only count credits as income on `type: "bank"` accounts.** A credit card's credits are the monthly repayment and merchant refunds. See [Amounts and directions](https://statementbear.com/docs/amounts-and-directions).
2. **Check `verification.reconciled`.** When it is `false`, send the document to manual review instead of using the totals. See [Verification](https://statementbear.com/docs/verification).

Node.js:

```javascript
// Credit card credits are repayments and refunds. Only count
// credits on bank accounts.
const income = doc.accounts
  .filter((a) => a.type === "bank")
  .reduce((sum, a) => sum + a.totals.credits, 0);

// Send anything that did not reconcile to manual review.
if (!doc.verification.reconciled) {
  await queueForManualReview(doc.id, doc.verification.issues);
}
```

Python:

```python
# Credit card credits are repayments and refunds. Only count
# credits on bank accounts.
income = sum(
    a["totals"]["credits"] for a in doc["accounts"] if a["type"] == "bank"
)

# Send anything that did not reconcile to manual review.
if not doc["verification"]["reconciled"]:
    queue_for_manual_review(doc["id"], doc["verification"]["issues"])
```

Ruby:

```ruby
# Credit card credits are repayments and refunds. Only count
# credits on bank accounts.
income = doc["accounts"]
  .select { |a| a["type"] == "bank" }
  .sum { |a| a["totals"]["credits"] }

# Send anything that did not reconcile to manual review.
unless doc["verification"]["reconciled"]
  queue_for_manual_review(doc["id"], doc["verification"]["issues"])
end
```

## 5. Make retries safe

Send an `Idempotency-Key` with every request: one unique string per document, reused by every attempt at that document. A repeat of a successful call returns the stored response instead of parsing again. See [Idempotency and retries](https://statementbear.com/docs/idempotency).

## Next

- [The document object](https://statementbear.com/docs/api/document-object): Every field in the response.
- [Errors](https://statementbear.com/docs/errors): Every code, and which to retry.
- [Going live](https://statementbear.com/docs/going-live): What to check before real traffic.
- [Limits](https://statementbear.com/docs/api/limits): Size, concurrency and the monthly cap.

---

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