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, zero runtime dependencies. Requires Node >= 18 (uses the platform fetch and Web Crypto).

Install

$npm i @spreadspace/sdk

Construct the client

1import { SpreadSpace } from '@spreadspace/sdk';
2
3// ss_test_ -> isolated sandbox tenant; ss_live_ -> real workspace data (same
4// base URL). Omit apiKey to fall back to the SPREADSPACE_API_KEY env var.
5const client = new SpreadSpace({ apiKey: 'ss_test_...' });

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). There’s no has_more or total; just iterate.

1for await (const borrower of client.borrowers.list()) {
2 console.log(borrower.id);
3}
4
5// Filters + page size:
6for await (const b of client.borrowers.list({ intake: true, limit: 50 })) { /* ... */ }
7
8// Loans (optionally scoped to a borrower) and jobs iterate the same way:
9for await (const loan of client.loans.list({ borrowerId: 'abc123' })) { /* ... */ }
10for await (const job of client.jobs.list()) { /* ... */ }

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. With wait: true the helper also polls to a terminal job status before resolving. Files up to 100 MB; a ZIP’s members up to 50 MB each.

1const job = await client.documents.upload('./statement.pdf', {
2 borrowerId: 'abc123',
3 wait: true,
4});
5console.log(job.id);
6
7// From bytes / a Blob, fileName + contentType are required:
8await client.documents.upload(bytes, {
9 fileName: 'statement.pdf',
10 contentType: 'application/pdf',
11});

Terminal job statuses are COMPLETED / FAILED (PENDING / PROCESSING are in flight). A FAILED job rejects wait() with UploadError.

Create an extraction export and wait

Long-running work is an async operation: create() enqueues it and resolves to a handle; wait() polls to a terminal status. A failed operation rejects with AsyncOperationError; a timeout rejects with AsyncOperationTimeout. 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.

1const operation = await client.exports.create({ borrowerId: 'abc123', format: 'json' });
2
3const op = await operation.wait({ timeoutMs: 5 * 60_000 });
4console.log(op.status); // 'succeeded'
5console.log(op.resultUrl); // download link, when present

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 array: newest first, capped at 200 and 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.

1const latest = await client.loans.attributes.get(loanId, { status: 'final' });
2console.log(latest.snapshot?.version, latest.snapshot?.finalized_by);
3
4const history = await client.loans.attributes.versions(loanId, { status: 'final' });
5const snapshot = await client.loans.attributes.retrieve(loanId, snapshotId);

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.

1import { verifyAndParseWebhook, WebhookSignatureError } from '@spreadspace/sdk';
2
3try {
4 const event = verifyAndParseWebhook(rawBody, signatureHeader, 'whsec_...');
5 if (event.type === 'job.completed') console.log(event.data.job_id);
6 if (event.type === 'spread.finalized') {
7 // The snapshot this event names, by id, not "the latest final".
8 const snapshot = await client.loans.attributes.retrieve(
9 event.data.loan_id, event.data.snapshot_id);
10 }
11} catch (err) {
12 if (err instanceof WebhookSignatureError) {
13 // reject with 400
14 }
15}
16
17// Verify-only (no parse): verifyWebhook(rawBody, signatureHeader, '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.

1const receiver = client.webhooks.receiver({
2 secret: [process.env.SPREADSPACE_WEBHOOK_SECRET!, process.env.SPREADSPACE_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
3 onSpreadFinalized: async ({ event, attributes }) => {
4 await db.saveFinalizedFigures(event.data.loan_id, attributes);
5 },
6 onSpreadReopened: async ({ event }) => {
7 await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
8 },
9});
10
11app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
12 const r = await receiver.handle({ rawBody: req.body, headers: req.headers });
13 res.status(r.status).send(r.body);
14});
15
16// Hono: receiver.handle({ rawBody: await c.req.text(), headers: c.req.raw.headers })
17// Next.js: receiver.handle({ rawBody: await req.text(), headers: req.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(endpointId, { status, eventType, loanId }) pages the delivery log filtered. Every row carries the loan its payload named.

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.

1import { RateLimitError, SpreadSpaceError } from '@spreadspace/sdk';
2
3try {
4 for await (const b of client.borrowers.list()) { /* ... */ }
5} catch (err) {
6 if (err instanceof RateLimitError) {
7 console.log(`rate limited; retry after ${err.retryAfter}s (request ${err.requestId})`);
8 } else if (err instanceof SpreadSpaceError) {
9 console.log(`${err.type}: ${err.message} (request ${err.requestId})`);
10 }
11}
ErrorHTTP
InvalidRequestError400
AuthenticationError401
PermissionError403
NotFoundError404
ConflictError409
RateLimitError429 (honors Retry-After)
ServerError5xx
NetworkErrortransport failure (no HTTP response)

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 is exact

On the reads that send money as a JSON number (loans, borrowers, jobs), amounts decode to a decimal.js Decimal (exact, never IEEE-754 float64), matching the Python (Decimal) and .NET (System.Decimal) SDKs. The SDK parses responses with a lossless JSON reader, so cents never drift even when you sum many amounts. Those wires are unchanged; only the decoded JS type differs.

Attribute snapshots and export payloads send money as a string with exactly two decimals instead, per the value conventions, and the SDK hands that string through. Parse it with a decimal type, never Number.

1import { Decimal } from '@spreadspace/sdk';
2
3const current = await client.loans.attributes.get(loanId, { status: 'final' });
4// Values are strings, e.g. "754144.00", and null for a period with no figure.
5const printed = current.snapshot?.payload.attributes.tax_return_book_ebitda.values['2024'];
6const ebitda = new Decimal(printed ?? '0');
7ebitda.toFixed(2); // "754144.00", exact
8ebitda.toNumber(); // plain number

Non-money numbers (counts, limits, progress) stay plain numbers. One edge: inside the opaque payload blob (spreads / P&L / cash-flow / aging), money is matched by field name; the current vocabulary is covered exactly; a brand-new payload money key the SDK doesn’t yet recognize falls back to a plain number until the set is extended.