Skip to navigation

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

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.

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.

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, PUTs 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.

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, PUTs 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.

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.

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

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

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.

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.

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.

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). Full option list on the Webhooks page.

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.

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. 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 covers every field.

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.

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)
ErrorHTTP
BadRequestError400
AuthenticationError401
PermissionDeniedError403
NotFoundError404
ConflictError409
RateLimitError429 (honors Retry-After)
InternalServerError5xx
NetworkErrortransport 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.