Python

The official Python SDK, published to PyPI as spreadspace. It wraps the API Reference with a typed error hierarchy, automatic retries, idempotency, and lazy cursor pagination. Requires Python 3.9+.

Install

$pip install spreadspace

Construct the client

1from spreadspace import SpreadSpace
2
3# ss_test_ -> sandbox tenant; ss_live_ -> live data. Omit api_key to read
4# SPREADSPACE_API_KEY from the environment.
5client = SpreadSpace(api_key="ss_test_...")
6# 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.

1for borrower in client.borrowers.list():
2 print(borrower["id"])
3
4# Filters pass straight through and persist across pages:
5for loan in client.loans.list(borrower_id="abc123", limit=50):
6 print(loan["id"])

Upload a document and wait

The upload helper requests a presigned URL, PUTs the file bytes to that URL with the Content-Type it was given, then returns a job handle you can wait on. Files up to 100 MB; a ZIP’s members up to 50 MB each.

1job = client.documents.upload("statement.pdf", borrower_id="abc123")
2final = job.wait(timeout=600) # seconds; polls to COMPLETED / FAILED
3print(final["status"])
4
5# Raw bytes require file_name + content_type:
6client.documents.upload(data, file_name="statement.pdf", content_type="application/pdf")

Create an extraction export and wait

Exports are asynchronous operations. create returns a handle; wait polls to completion (raising AsyncOperationError on a failed operation, or AsyncOperationTimeout on timeout). 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.

1op = client.exports.create(borrower_id="abc123", format="json")
2result = op.wait(timeout=300) # seconds
3print(result.status, result.result_url)

Read a loan’s finalized attributes

When an analyst finalizes a spread, its figures freeze as an attribute snapshot. get returns the latest of a status, versions the history (a plain list: newest first, capped at 200 and 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.

1latest = client.loans.attributes.get(loan_id, status="final")
2if latest["snapshot"]: # None until someone finalizes
3 print(latest["snapshot"]["version"], latest["snapshot"]["finalized_by"])
4
5history = client.loans.attributes.versions(loan_id, status="final")
6snapshot = client.loans.attributes.get_snapshot(loan_id, snapshot_id)

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.

1from spreadspace import verify_and_parse_webhook, WebhookSignatureError
2
3try:
4 event = verify_and_parse_webhook(raw_body, signature_header, "whsec_...")
5 if event.type == "job.completed":
6 print(event.data["job_id"])
7 if event.type == "spread.finalized":
8 # The snapshot this event names, by id, not "the latest final".
9 snapshot = client.loans.attributes.get_snapshot(
10 event.data["loan_id"], event.data["snapshot_id"])
11except WebhookSignatureError:
12 ... # reject with 400
13
14# 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). Full option list on the Webhooks page.

1import os
2
3receiver = client.webhooks.receiver(
4 secret=[os.environ["SPREADSPACE_WEBHOOK_SECRET"]],
5 on_spread_finalized=lambda event, attributes: save_finalized_figures(event.data["loan_id"], attributes),
6 on_spread_reopened=lambda event: mark_finalized_figures_stale(event.data["snapshot_id"], event.data["reopened_at"]),
7)
8
9# FastAPI
10@app.post("/webhooks/spreadspace")
11async def spreadspace_webhook(request):
12 body = await request.body()
13 receipt = receiver.handle(body, request.headers)
14 return Response(receipt.body, status_code=receipt.status)
15
16# 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.

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.

1from spreadspace import SpreadSpaceError, RateLimitError, NotFoundError
2
3try:
4 client.request("GET", "/api/borrowers/missing-id")
5except RateLimitError as e:
6 print("retry after", e.retry_after, "seconds")
7except NotFoundError:
8 print("not found")
9except SpreadSpaceError as e:
10 print(e.message, e.status_code, e.request_id)
ErrorHTTP
BadRequestError400
AuthenticationError401
PermissionDeniedError403
NotFoundError404
ConflictError409
RateLimitError429 (honors Retry-After)
InternalServerError5xx
NetworkErrortransport failure (no HTTP response)

All derive from APIStatusErrorSpreadSpaceError. Transient failures (429, 5xx, transport errors) retry automatically up to max_retries with exponential backoff + full jitter, honoring Retry-After.

Money is exact

Monetary values decode as decimal.Decimal, not float: the SDK reads the literal digits off the wire, so amounts are exact with no float rounding (e.g. Decimal("1234.56"), never 1234.5600000000001).