> This page is for SDKs.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# Python

> The official Python SDK, spreadspace on PyPI: typed errors, automatic retries, idempotency, and lazy cursor pagination. Requires Python 3.9+.

The official Python SDK, published to PyPI as **`spreadspace`**. It wraps the
[API Reference](/api) with a typed error hierarchy, automatic retries,
idempotency, and lazy cursor pagination. Requires Python 3.9+.

## Install

```bash
pip install spreadspace
```

## Construct the client

```python
from spreadspace import SpreadSpace

# ss_test_ -> sandbox tenant; ss_live_ -> live data. Omit api_key to read
# SPREADSPACE_API_KEY from the environment.
client = SpreadSpace(api_key="ss_test_...")
# Supports a context manager: `with SpreadSpace(...) as client:`
```

The constructor raises when it finds no key, from neither the argument nor the
environment variable. Construct the client where a missing key *should*
fail: at startup, or on first use, not at import time in a module every test
loads.

Other constructor options: `base_url` (override the base URL),
`api_version` (pins the `SpreadSpace-Version` header), `timeout` (seconds, per
request), and `max_retries`.

## Paginate (auto-paged borrowers)

List endpoints return a lazy iterator that walks cursors for you; it fetches the
next page only as you consume it.

```python
for borrower in client.borrowers.list():
    print(borrower["borrower_id"])

# Filters pass straight through and persist across pages:
for loan in client.loans.list(borrower_id="4a2c8e1f6b3d5a7c9e0f1b2d", limit=50):
    print(loan["loan_id"])
```

## Create and retrieve a loan

Borrower, loan, and job reads return typed dictionaries
with their wire identifiers.

```python
created_borrower = client.borrowers.create({"name": "Example borrower"})
created_loan = client.loans.create(created_borrower["borrower_id"], {"name": "Example loan", "requested_amount": "250000.00"})
retrieved_loan = client.loans.get(created_loan["loan_id"])
print(retrieved_loan["loan_id"], retrieved_loan["name"])
```

## Upload a document and wait

The upload helper requests a presigned URL, `PUT`s the file bytes to that URL
with the `Content-Type` it was given, then returns a job handle you can `wait`
on. Every upload belongs to a loan, so `loan_id` is required; `borrower_id` is
optional. Files up to 250 MB (a PDF over 1,500 pages is refused when processing
starts); a ZIP up to 250 MB whose members are each up to 100 MB uncompressed.
Accepted formats: PDF, HTML (rendered to PDF before classification), ZIP, Excel
and JSON.

```python
from pathlib import Path

operation_key = "statement-upload-001"  # Persist this before starting the upload.
job = client.documents.upload(
    "statement.pdf", loan_id="7d1e3b5a9c2f4e6b8d0a3c5e",
    borrower_id="4a2c8e1f6b3d5a7c9e0f1b2d", operation_key=operation_key,
)
Path("accepted-job-id.txt").write_text(job.id)
final = job.wait(timeout=600)   # seconds; polls to COMPLETED / FAILED
print(final["status"])

# After a restart, read the accepted job instead of uploading again:
accepted_job_id = Path("accepted-job-id.txt").read_text()
resumed_job = client.jobs.get(accepted_job_id)
print(resumed_job["status"])

# Raw bytes require file_name + content_type:
client.documents.upload(
    data, loan_id="7d1e3b5a9c2f4e6b8d0a3c5e", file_name="statement.pdf", content_type="application/pdf"
)
```

The SDK accepts a caller-owned `operation_key`. Persist a unique key before
starting each upload operation and reuse it only when retrying that same
operation with the same file and parameters. Persist the accepted job ID before
waiting so a restart can resume status reads. Presigned URLs expire: reusing the
same key does not make an upload restartable indefinitely or create a new
operation. Once a job is accepted, resume that job rather than upload again.

## Submit a document through intake

The intake helper creates a submission, `PUT`s the file bytes directly to
storage with the matching `Content-Type`, then confirms the upload, with no
borrower or loan modeled first: the API provisions both and returns their ids
on the handle. `file` accepts the same sources as the upload helper. Requires
an API key holding the `intake:write` scope; `wait()` and
`client.documents.status()` poll `GET /api/documents/{job_id}/status`, which
needs `documents:read` on the same key. Once your system holds the verified
business name, `borrowers.complete_intake` finishes the borrower's profile; it
needs `borrowers:write`, and a borrower already completed answers `409`.

```python
intake_job = client.intake.submit(
    "statement.pdf",
    client_reference="application-123",  # groups repeat submissions onto one loan
    operation_key="application-123-document-1",  # persist before the first attempt
)
print(intake_job.id, intake_job.loan_id, intake_job.borrower_id)  # persist all three
intake_final = intake_job.wait(timeout=600)  # seconds

# From bytes, file_name and content_type are required:
client.intake.submit(data, file_name="statement.pdf", content_type="application/pdf")
borrower = client.borrowers.complete_intake(intake_job.borrower_id, {"name": "Acme Corporation"})  # once your system holds the verified name
```

`client_reference` groups repeat submissions: the same reference resolves to
the same provisioned loan and borrower instead of creating another pair, and
omitting it creates a fresh pair every time. The operation key yields stable,
distinct create and confirm idempotency keys. After confirm is accepted, resume
status checks with `client.documents.status(job_id)`.

## Create an extraction export and wait

An export is a resource you create and poll. `create`
returns a handle; `wait`
polls it to a terminal status (raising `ExportFailedError` on a `failed`
export, or `ExportTimeoutError` on timeout). `borrower_id`, `loan_id` and
`document_ids` are required, as they are on the server. `format` is `json`,
`csv`, or `xlsx`; `xlsx` is available for bank statements only, so pass it only
when every document in the export is a bank statement.
`client.exports.get(export_id)` reads one, `client.exports.list(status=...)`
pages through yours as `ExtractionExport` rows, and
`client.exports.cancel(export_id)` stops one still queued or running. The
download link is minted fresh on every read, so read the export again when you
need a new one, or call `client.exports.download_link(export_id)`, which
returns the link together with the expiry from that same read.

```python
exp = client.exports.create(
    borrower_id="4a2c8e1f6b3d5a7c9e0f1b2d",
    loan_id="7d1e3b5a9c2f4e6b8d0a3c5e",
    document_ids=["7a1d4e9c2b3f4a5d8e6c1b0f", "2a3b4c5d6e7f809112345678"],
    format="json",
)
result = exp.wait(timeout=300)   # seconds
print(result.status, result.download_url)
```

## Read a loan's finalized attributes

When an analyst finalizes a spread, its figures freeze
as an [attribute snapshot](/api/spreads). `get` returns the latest of a status,
`versions` the history (an envelope `{ loan_id, snapshots }` whose
`snapshots` is newest first, at most 200, not paged), `get_snapshot` any one
snapshot by id. All three are typed: the reads are
annotated `AttributesEnvelope` / `AttributeVersions` / `AttributeSnapshot`, and
every payload and manifest type ships as a `TypedDict` (`AttributeSnapshot`,
`LoanAttributesPayload`, `LoanAttributesManifest`) exported from `spreadspace`,
so a type-checker knows the keys. The receiver hands your `spread.finalized`
handler the same `AttributeSnapshot`.

`versions` takes a `status` of its own. You get the filtered history, the first
row is the loan's current one of that status, and the 200 cap applies after the
filter.

```python
latest = client.loans.attributes.get(loan_id, status="final")
if latest["snapshot"]:   # None until someone finalizes
    print(latest["snapshot"]["version"], latest["snapshot"]["finalized_by"])

history = client.loans.attributes.versions(loan_id, status="final")
snapshot = client.loans.attributes.get_snapshot(loan_id, snapshot_id)
```

## Read an extracted document

`client.extractions` reads the extracted documents on a
borrower's file as typed objects: one `get_<family>` / `list_<family>s` pair
per document family (`get_tax_return` / `list_tax_returns`,
`get_bank_statement` / `list_bank_statements`, `get_debt_schedule` /
`list_debt_schedules`, ...), named after the family as its object page titles
it. A retrieve returns the family object, annotated with a `TypedDict` from
`spreadspace.extractions` that follows the schema its
[object page](/api/api-reference/debt-schedules/object) shows; a list returns
`{"borrower_id", <items>: [...]}` with one row per document. A list takes an
optional `loan_id` that scopes the rows to one loan; the two aging lists also
take a `form` (`summary` or `detail`). `get_report_data` reads any category in
the shape of its family; `get_report_data_by_category` reads the borrower's
newest document of one category by its slug, on one loan when `loan_id` is
given. Every money key is a two-decimal JSON string, typed `str`.
Other JSON numbers arrive as `int` or `Decimal`, never `float`.
Every call needs the `extractions:read` scope.

```python
first_borrower = next(iter(client.borrowers.list()))
schedules = client.extractions.list_debt_schedules(first_borrower["borrower_id"])["schedules"]
for row in schedules:
    print(row["extracted_document_id"], row["report_data"]["as_of_date"])

if schedules:
    schedule = client.extractions.get_debt_schedule(
        first_borrower["borrower_id"], schedules[0]["extracted_document_id"])
    print(schedule["form_variant"], len(schedule["debt_schedule"]))

    # Any category, in the shape of its family:
    any_body = client.extractions.get_report_data(
        first_borrower["borrower_id"], schedules[0]["extracted_document_id"])
    print(sorted(any_body))
```

## Read a statement mapping

A profit and loss statement's or a balance sheet's mapping onto the chart of
accounts, read by document id, each statement through its own read:
`client.statement_mappings.get_profit_loss(borrower_id, doc_id)` returns a
`ProfitLossMapping` dictionary and `.get_balance_sheet(borrower_id, doc_id)` a
`BalanceSheetMapping`. The `status` is `ready` or `unmappable`. A ready mapping
carries a `payload` whose `statement` names the kind (`profit_and_loss` or
`balance_sheet`) and whose `mapping_state` is `mapped` (the `tree` lists every
section and account with its figure per period, and each printed line names
the node it is filed under) or `unfooted` (the lines are returned as printed
and `foot` says by how much each period misses: a profit and loss statement's
printed bottom line, or on a balance sheet each side's printed total and the
imbalance between the sides). Every figure is a string with exactly two
decimals, positive in the direction of the node's `effect`; parse with
`Decimal` when computing.
`client.statement_mappings.list_profit_loss(borrower_id, loan_id=..., limit=...)`
and `.list_balance_sheet(borrower_id, loan_id=..., limit=...)` page the
borrower's mappings of that statement newest document first, and each envelope
of `.pages()` carries `pending_document_ids`, the documents whose first mapping
is being prepared. All four need `extractions:read` and the Statement mapping
feature on the workspace's plan.

A mapping being prepared answers 404 with a `Retry-After` header: the
`NotFoundError` carries it as `retry_after`, so read again after that many
seconds. A document with no mapping, or one of the other statement (a balance
sheet on the profit loss read), is a `NotFoundError` whose `retry_after` is
`None`. A workspace whose plan does not include the feature is a
`PermissionDeniedError` whose `type` is `statement_mapping_not_in_plan`.

```python
from spreadspace import NotFoundError, PermissionDeniedError

# One profit and loss statement's mapping, by document id. A mapping being
# prepared raises NotFoundError with retry_after set; none at all, NotFoundError
# without it.
try:
    mapping = client.statement_mappings.get_profit_loss(first_borrower["borrower_id"], "7a1d4e9c2b3f4a5d8e6c1b0f")
    print(mapping["status"], mapping["stale"])
    payload = mapping["payload"]
    if payload is not None:
        print(payload["mapping_state"])
        for node in payload["tree"] or []:
            # Every figure is a two-decimal string, positive in the direction of "effect".
            print(node["kind"], node["label"], node.get("effect"), node["values"][0], node.get("external_code"))
        for foot in payload["foot"]:
            print(foot.get("printed_bottom_line"), foot.get("residual"), foot.get("tolerance"))
except NotFoundError as e:
    if e.retry_after is not None:
        ...  # being prepared: read again after e.retry_after seconds
    else:
        ...  # no mapping for this document
except PermissionDeniedError as e:
    if e.type == "statement_mapping_not_in_plan":
        ...  # the workspace's plan does not include statement mapping

# A balance sheet's mapping, through its own read. Its foot carries each side's
# printed total, the residual on each side and the imbalance between them.
sheet_mapping = client.statement_mappings.get_balance_sheet(first_borrower["borrower_id"], "3c8b1f5e9a2d4c7b6e0f1a2b")
sheet_payload = sheet_mapping["payload"]
if sheet_payload is not None:
    for sheet_foot in sheet_payload["foot"]:
        print(sheet_foot.get("printed_total_assets"), sheet_foot.get("assets_residual"), sheet_foot.get("liabilities_and_equity_residual"), sheet_foot.get("imbalance"))

# Every profit and loss mapping on the borrower's file, newest document first.
# Each envelope also names the documents whose first mapping is being prepared;
# list_balance_sheet pages the balance sheets the same way.
for page in client.statement_mappings.list_profit_loss(first_borrower["borrower_id"], loan_id="7d1e3b5a9c2f4e6b8d0a3c5e").pages():
    for row in page["data"]:
        print(row["document_id"], row["status"])
    print(page["pending_document_ids"])
```

## The chart of accounts and the export map

`client.chart_of_accounts.get()` reads the workspace's chart document
(`chart`, `None` until the workspace saves one, when the platform's accounts
apply), its `version`, the version of its caption rules and its
`active_export_system`. `get_export_map()` reads the active export map: the
workspace's own code and name for each chart account, in one export system.
`replace_export_map({"system": ..., "entries": [...]})` replaces that system's
whole map and makes the system the active one: an account left out leaves the
map, at most 1,000 entries, and a save that crosses another raises
`ConflictError`. A mapping read after a save is `stale` until it is prepared
again with the new codes. The reads need `chart_of_accounts:read`,
`chart_of_accounts:write` or `extractions:read`; the save needs
`chart_of_accounts:write`.

```python
chart = client.chart_of_accounts.get()
print(chart["version"], chart["rules_version"], chart.get("active_export_system"))  # version is 0 until the workspace saves a chart

export_map = client.chart_of_accounts.get_export_map()
print(export_map.get("system"), len(export_map["entries"]))

# The whole map for one export system; an account left out leaves the map.
saved_map = client.chart_of_accounts.replace_export_map({
    "system": "ledger_one",
    "entries": [
        {"account_id": "pl.revenue.sales", "external_code": "4000", "external_label": "Sales"},
        {"account_id": "pl.opex.rent", "external_code": "6100", "external_label": "Rent expense"},
        {"account_id": "bs.current_assets.cash", "external_code": "1000", "external_label": "Cash"},
    ],
})
print([entry["external_code"] for entry in saved_map["entries"]])
```

## Verify a webhook signature

Verify the `SpreadSpace-Signature` header against the **exact raw request body**
(bytes or str) you received on the wire, not re-serialized JSON, or the HMAC
won't match. `verify_and_parse_webhook` raises `WebhookSignatureError` on any
failure and otherwise returns the parsed, typed event.

```python
from spreadspace import verify_and_parse_webhook, WebhookSignatureError

try:
    event = verify_and_parse_webhook(raw_body, signature_header, "whsec_...")
    if event.type == "job.completed":
        print(event.data["job_id"])
    if event.type == "spread.finalized":
        # The snapshot this event names, by id, not "the latest final".
        snapshot = client.loans.attributes.get_snapshot(
            event.data["loan_id"], event.data["snapshot_id"])
except WebhookSignatureError:
    ...  # reject with 400

# Verify-only: verify_webhook_signature(raw_body, signature_header, "whsec_...")
```

## Receive webhooks

The receiver does the whole loop (verify, dedupe, dispatch, answer) and
on `spread.finalized` reads the snapshot the event names for you (by id, never
the loan's latest: see [Spreads](/api/spreads)). Full option list on
the [Webhooks page](/api/webhooks#receive-events-with-the-sdk).

```python
import os

receiver = client.webhooks.receiver(
    secret=[os.environ["SPREADSPACE_WEBHOOK_SECRET"]],
    on_spread_finalized=lambda event, attributes: save_finalized_figures(event.data["loan_id"], attributes),
    on_spread_reopened=lambda event: mark_finalized_figures_stale(event.data["snapshot_id"], event.data["reopened_at"]),
)

# FastAPI
from fastapi import Request, Response

@app.post("/webhooks/spreadspace")
async def spreadspace_webhook(request: Request):
    body = await request.body()
    receipt = receiver.handle(body, request.headers)
    return Response(receipt.body, status_code=receipt.status)

# Flask: receipt = receiver.handle(request.get_data(), request.headers)
```

Hand it the **raw body**. A framework that parses JSON first has already
changed the bytes the signature covers.

To audit what we sent, `client.webhooks.deliveries(endpoint_id, status=…,
event_type=…, loan_id=…)` pages the delivery log filtered. Every row carries
the loan its payload named.

## Receive `extraction.mapped`

`extraction.mapped` fires once for every mapping of a profit and loss statement
or a balance sheet: the first, and each one made again after the extraction,
the chart of accounts, the caption rules or the export map changed, or the
statement was corrected (`reason` is `first`, `extraction`, `chart`,
`correction` or `logic`). A pointer, not the figures: `statement` says which
statement was mapped, `profit_and_loss` or `balance_sheet`, and so which read
returns it, `client.statement_mappings.get_profit_loss(event.data["borrower_id"],
event.data["document_id"])` or `.get_balance_sheet(...)`. The receiver
dispatches it to `on_extraction_mapped`; `event.data` has the keys of
`ExtractionMappedPayload`.

```python
mapping_receiver = client.webhooks.receiver(
    secret=[os.environ["SPREADSPACE_WEBHOOK_SECRET"]],
    # A pointer: read the mapping through the read "statement" names.
    on_extraction_mapped=lambda event: print(
        event.data["reason"],
        (
            client.statement_mappings.get_balance_sheet(event.data["borrower_id"], event.data["document_id"])
            if event.data["statement"] == "balance_sheet"
            else client.statement_mappings.get_profit_loss(event.data["borrower_id"], event.data["document_id"])
        )["status"],
    ),
)
```

## Embed

`client.embed.sessions` mints the credentials that authorize an
[embedded surface](/embed/overview). `create_iframe_url` returns a single-use
`signed_url` for the iframe. `create` returns a loan-scoped `embed_token` for a
client of your own, and `revoke` ends that session. Each mint returns the
response as a dictionary.
Both mints need `embed:write` on the key, so construct a client with the
embed-minting key, which the samples read from `SPREADSPACE_EMBED_API_KEY`.
Name `scopes` on every mint: read scopes are granted by mint authority, and a
write scope is granted only when the request names it and only if the minting
key holds it. [Mint from your server](/embed/server) covers every field.

```python
embed_client = SpreadSpace(api_key=os.environ["SPREADSPACE_EMBED_API_KEY"])

handle = embed_client.embed.sessions.create_iframe_url(
    loan_id="7d1e3b5a9c2f4e6b8d0a3c5e",
    surface="spreading",
    scopes=["documents:read", "extractions:read", "spreads:read"],
    external_user_id="usr_1234",
)
print(handle["signed_url"], handle["expires_at"])  # return signed_url to the page

session = embed_client.embed.sessions.create(
    loan_id="7d1e3b5a9c2f4e6b8d0a3c5e",
    scopes=["documents:read", "extractions:read", "spreads:read"],
    external_user_id="usr_1234",
)
print(session["session_id"], session["persistence"])  # "user" when an id was sent
embed_client.embed.sessions.revoke(session["session_id"])
```

## Handle errors

All errors derive from `SpreadSpaceError`. Match on the typed subclass, never on
the message string. Every error carries `request_id` (from the `X-Request-ID`
response header). Quote it in support tickets.

```python
from spreadspace import SpreadSpaceError, RateLimitError, NotFoundError

try:
    client.request("GET", "/api/borrowers/missing-id")
except RateLimitError as e:
    print("retry after", e.retry_after, "seconds")
except NotFoundError:
    print("not found")
except SpreadSpaceError as e:
    print(e.message, e.status_code, e.request_id)
```

| Error                 | HTTP                                 |
| --------------------- | ------------------------------------ |
| BadRequestError       | 400                                  |
| AuthenticationError   | 401                                  |
| PermissionDeniedError | 403                                  |
| NotFoundError         | 404                                  |
| ConflictError         | 409                                  |
| RateLimitError        | 429 (honors `Retry-After`)           |
| InternalServerError   | 5xx                                  |
| NetworkError          | transport failure (no HTTP response) |

The HTTP-status errors derive from `APIStatusError` → `SpreadSpaceError`;
`NetworkError` derives from `SpreadSpaceError` directly. Transient failures (429,
5xx, transport errors) retry automatically up to `max_retries` with exponential
backoff + full jitter, honoring `Retry-After`.

## Money is exact

Every money key in `report_data` is a JSON string with exactly two decimals,
typed `str`. Attribute snapshots and export payloads use the same two-decimal
strings. The SDK preserves them unchanged; parse with `Decimal` when computing,
for example `Decimal("128986.06")`. Other JSON numbers arrive as `int` or
`Decimal`, never `float`. Loan `requested_amount`, extraction query amounts
and totals, and EBITDA write-in amounts are also strings. Create or update a loan with
`"requested_amount": "250000.00"`; its allowed range is `"0.00"` through
`"999999999999.99"`. Invalid formats return `400 validation_failed`.