TypeScript
The official TypeScript / Node SDK, published to npm as @spreadspace/sdk.
It wraps the API Reference with a typed error
hierarchy, automatic retries, idempotency, and cursor pagination as a native
for await … of async iterable. ESM + CommonJS, full type declarations, no
runtime dependencies. Requires Node >= 18 (uses the platform fetch and Web
Crypto).
Install
Construct the client
The constructor throws 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.
Paginate (auto-paged borrowers)
List methods return a lazy async iterable that fetches pages on demand and stops
when the cursor is exhausted (next_cursor === null). No has_more / total
bookkeeping; just iterate.
Create and retrieve a loan
Borrower, loan, and job reads return typed objects with their wire identifiers.
Upload a document and wait
Upload mints a presigned URL, PUTs the bytes to that URL, then confirms;
raw bytes never transit a SpreadSpace endpoint body. file may be a path
string, raw bytes, a Blob / File, or a Readable / iterable of chunks.
Every upload belongs to a loan, so loanId is required; borrowerId is
optional. With wait: true the helper also polls to a terminal job status
before resolving. 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.
Terminal job statuses are COMPLETED / FAILED (PENDING / PROCESSING
are in flight). A FAILED job rejects wait() with UploadError.
The SDK accepts a caller-owned operationKey. 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, uploads the file, and 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.completeIntake finishes the borrower’s profile; it
needs borrowers:write, and a borrower already completed answers 409.
clientReference 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(jobId).
Create an extraction export and wait
An export is a resource you create and poll: create() starts it and resolves
to a handle; wait() polls it to a terminal status. A failed export rejects
with ExportFailedError; a timeout rejects with ExportTimeoutError.
borrowerId, loanId and documentIds 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(exportId) reads one,
client.exports.list({ status }) pages through yours as ExtractionExport
rows, and client.exports.cancel(exportId) (or exp.cancel() on the handle)
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.downloadLink(exportId), which returns the link together with
the expiry from that same read.
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), retrieve any one
snapshot by id. All three are typed: get returns
an AttributesEnvelope, versions an AttributeVersions, retrieve an
AttributeSnapshot. Its payload and manifest carry the attributes.v1
types (LoanAttributesPayload, LoanAttributesManifest), exported from the
package alongside them. 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.
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 (getTaxReturn / listTaxReturns, getBankStatement /
listBankStatements, getDebtSchedule / listDebtSchedules, …), named after
the family as its object page titles it. A retrieve resolves to the family
object, typed with the schema its object page
shows; a list resolves to { borrower_id, <items>: [...] } with one row per
document. A list takes an optional loanId that scopes the rows to one loan;
the two aging lists also take a form (summary or detail).
getReportData reads any category in the shape of its family;
getReportDataByCategory reads the borrower’s newest document of one category
by its slug, on one loan when loanId is given. The wire types
are exported under the names the OpenAPI document gives them
(DebtScheduleReportData, TaxReturnReportData, …). Every call needs the
extractions:read scope.
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.statementMappings.getProfitLoss(borrowerId, docId) resolves to a
ProfitLossMapping and .getBalanceSheet(borrowerId, docId) to 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.
client.statementMappings.listProfitLoss(borrowerId, { loanId, limit }) and
.listBalanceSheet(borrowerId, { loanId, limit }) page the borrower’s mappings
of that statement newest document first, and each page 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 retryAfter, 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 with no retryAfter. A
workspace whose plan does not include the feature is a PermissionError
whose type is statement_mapping_not_in_plan.
The chart of accounts and the export map
client.chartOfAccounts.get() reads the workspace’s chart document (chart,
null until the workspace saves one, when the platform’s accounts apply), its
version, the version of its caption rules and its active_export_system.
getExportMap() reads the active export map: the workspace’s own code and
name for each chart account, in one export system. replaceExportMap({ 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 rejects with ConflictError. A mapping read after a save is
stale: true 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.
Verify a webhook signature
Verify the SpreadSpace-Signature header against the exact raw request body
you received on the wire, never a re-serialized JSON object, or the HMAC won’t
match. verifyAndParseWebhook throws WebhookSignatureError on any failure and
otherwise returns the parsed, typed event.
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.
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(endpointId, { status, eventType, loanId })
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.statementMappings.getProfitLoss(event.data.borrower_id, event.data.document_id) or .getBalanceSheet(...). The receiver dispatches
it to onExtractionMapped; the payload is typed ExtractionMappedPayload.
Embed
client.embed mints the credentials that authorize an
embedded surface. iframeUrls.create returns a single-use
signed_url for the iframe, typed EmbedIframeUrlResult. sessions.create
returns a loan-scoped embed_token for a client of your own, typed
EmbedSessionResult, and sessions.revoke ends that session.
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.
Handle errors
Every API error maps to a typed error. Match on the class; for the stable
machine code read err.type (the wire error.type), never the message. Each
error carries requestId (from the X-Request-ID response header). Quote it
in support tickets.
The HTTP-status errors derive from SpreadSpaceError; the webhook verifier
throws the standalone WebhookSignatureError. 429, 5xx, and transport
errors retry automatically with exponential backoff + full jitter; other 4xx
never retry. Tune with maxRetries in the constructor.
Money
Every money key in report_data is a JSON string with exactly two decimals,
including statement summaries. These fields are typed string, with null
where the schema allows it. Attribute snapshots and export payloads use the
same two-decimal strings, such as "9200000.00" and "-1250.50".
The SDK passes values through unchanged and converts nothing. Parse money
strings with a decimal library when computing, rather than with Number, to
preserve decimal precision. Loan requested_amount, extraction query amounts
and totals, and EBITDA write-in amounts follow the same string convention. 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.