SpreadSpace Python SDK
Official Python client for the SpreadSpace API — document extraction and financial spreading for lending.
Install
pip install spreadspace
Requires Python 3.9+.
Authentication
from spreadspace import SpreadSpace
client = SpreadSpace(api_key="ss_test_...") # or omit and set SPREADSPACE_API_KEY
The key prefix selects the environment:
ss_test_...— routes to your sandbox tenant (seed data, safe to experiment).ss_live_...— routes to your live tenant (real data).
If api_key is omitted, the client reads SPREADSPACE_API_KEY from the
environment. Never hard-code a live key; never commit any key.
Client options
client = SpreadSpace(
api_key="ss_test_...",
base_url="https://api.spreadspace.app", # override for a private deployment
api_version="2026-05-03", # pins the SpreadSpace-Version header
timeout=60.0, # seconds, per request
max_retries=2, # 429 + 5xx + transport errors
)
API version pinning
Every request sends a dated SpreadSpace-Version header. The SDK pins a default
version per release (decoupled from the SDK's own semver). Pin it explicitly to
insulate your integration from server-side changes, and override per call when
you need a newer surface:
client.borrowers.list(api_version="2026-06-01") # one call on a newer version
Pagination (lazy, auto cursor)
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 job in client.jobs.list(limit=50):
print(job["job_id"])
Core lists yield typed Borrower, Loan, and JobStatus dictionaries. They
remain ordinary dictionaries at runtime.
borrower = client.borrowers.create({"name": "Example borrower"})
loan = client.loans.create(borrower["borrower_id"], {"name": "Example loan", "requested_amount": "250000.00"})
current_loan = client.loans.get(loan["loan_id"])
Extraction export + wait
Exports run asynchronously. create returns a handle; wait polls to
completion (raising ExportFailedError on failed, ExportTimeoutError on
timeout). Terminal statuses are succeeded / failed / cancelled;
cancelling — a cancel that landed mid-bundle — is NOT terminal, so wait
polls through it. format is json, csv, or xlsx; xlsx is available for
bank statements only.
borrower_id, loan_id and document_ids are required, as they are on the server.
export = client.exports.create(
borrower_id="abc123",
loan_id="def456",
document_ids=["7a1d4e9c2b3f4a5d8e6c1b0f", "2a3b4c5d6e7f809112345678"],
format="json",
)
result = export.wait(timeout=300) # seconds
print(result.status, result.download_url) # fresh download link on each retrieval
print(result.document_counts) # {"requested": .., "exported": .., "skipped": ..}
print(result.bundle) # {"name": .., "size_bytes": .., "line_item_count": ..}
# Or ask for a fresh link by id (raises ExportNotReadyError until succeeded).
# The expiry comes off the same read, so it describes this link:
link = client.exports.download_link(result.export_id)
print(link.url, link.expires_at)
# List exports as ExtractionExport rows:
for row in client.exports.list(status="running"):
print(row.export_id, row.status)
# Cancel a still-running export (cancelling a finished one raises ConflictError;
# cancelling an already-cancelled one is an idempotent success):
export.cancel()
# ...or by id: client.exports.cancel(result.export_id)
Upload a document + wait for processing
job = client.documents.upload(
"statement.pdf", loan_id="def456", borrower_id="abc123",
operation_key="application-123-document-1", # persist before the first attempt
)
print(job.id) # persist the accepted job ID alongside the operation key
status = client.jobs.get(job.id) # JobStatus dictionary
final = job.wait(timeout=600) # seconds
print(final["status"])
The upload helper requests a presigned URL, PUTs the file bytes directly to
storage with the matching Content-Type (part of the V4 signature), then returns
a job handle you can wait on. Every upload belongs to a loan, so loan_id is
required; borrower_id is optional.
Persist the operation key and returned job ID. Reusing the key produces stable,
distinct presign and confirm idempotency keys. After accepted confirm, use
client.jobs.get(job_id) to resume status checks. Presigned URLs expire, so a
replayed presign response does not permit indefinite upload restarts. API version
and retry overrides apply to both API stages and stay off the storage PUT.
Submit a document through 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(job.id, job.loan_id, job.borrower_id) # persist all three
final = 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")
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; job.wait() and client.documents.status()
poll GET /api/documents/{job_id}/status, which needs documents:read on the
same key.
client_reference groups repeat submissions: the same reference resolves to the
same provisioned loan and borrower instead of creating new ones, 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).
Read an extracted document (typed)
client.documents has one get_<family> / list_<family>s pair per
document family (get_tax_return / list_tax_returns, get_bank_statement /
list_bank_statements, ...) over the document read and the document list,
annotated with TypedDicts from spreadspace.extractions: a read takes the
document id and returns the document, its family object in report_data (a
document of another type is refused with document_type_mismatch); a list
returns one page, {"data", "has_more", "next_cursor"}. get_document and
list_documents read documents of every type:
schedule = client.documents.get_debt_schedule(document_id)
print(schedule["document_type"], schedule["period"])
page = client.documents.list_debt_schedules(borrower_id=borrower_id, include="report_data")
for document in page["data"]:
print(document["document_id"], document.get("report_data"))
Error handling
All errors derive from SpreadSpaceError. Match on the typed subclass, never on
the message string:
from spreadspace import (
SpreadSpaceError, # base
NetworkError, # transport failure, no HTTP response
BadRequestError, # 400
AuthenticationError, # 401
PermissionDeniedError, # 403
NotFoundError, # 404
ConflictError, # 409
RateLimitError, # 429
InternalServerError, # 5xx
)
try:
client.borrowers.get("missing-id")
except RateLimitError as e:
print("retry after", e.retry_after, "seconds")
except NotFoundError as e:
print("not found")
except SpreadSpaceError as e:
# Every error carries request_id — quote it in support tickets.
print(e.message, e.status_code, e.request_id)
request_id comes from the X-Request-ID response header (falling back to the
error body). Transient failures (429, 5xx, transport errors) are retried
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.
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.
from decimal import Decimal
amount = Decimal("128986.06")
statement = client.documents.get_bank_statement(document_id)["report_data"]
if statement is not None:
transaction_amount = Decimal(statement["transactions"][0]["amount"])
Development
The generated OpenAPI core lives in src/spreadspace/_generated/ and is built in
CI (scripts/generate.sh, Java-based — not run locally). It is gitignored and
must never be hand-edited. The hand-written ergonomic layer (transport,
helpers, typed errors) is the only code committed by hand.
pip install -e '.[dev]'
pytest
License
MIT
Metadata
Release files for spreadspace 2.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| spreadspace-2.0.0.tar.gz | 180.7 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| spreadspace-2.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 324.8 kB
Release files / spreadspace-2.0.0.tar.gz
| Download URL | spreadspace-2.0.0.tar.gz |
|---|---|
| Size | 180.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f442014058dad4db5f6fc4f0ac84d278ea6e0e3cc2f90c3adb32267c29a99269
|
|
BLAKE2b-256 checksum How to use checksums |
432339b3922dfd7cfb47fb3a6c2c660e211975b09d3780975f17f70fb5c5d787
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logRelease files / spreadspace-2.0.0-py3-none-any.whl
| Download URL | spreadspace-2.0.0-py3-none-any.whl |
|---|---|
| Size | 144.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
635f0706479dab7ede590a60d61691ceaa9f70deb15d5f83401700432c7c82ef
|
|
BLAKE2b-256 checksum How to use checksums |
264cbed7834df01d5f6e34e67bdb549daae3223b4881ee03d23fe8762c21a815
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logSecurity for spreadspace 2.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for spreadspace-2.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 19, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.