Skip to navigation

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

npm i @spreadspace/sdk

Construct the client

import { SpreadSpace } from '@spreadspace/sdk';
// ss_test_ -> isolated sandbox tenant; ss_live_ -> real workspace data (same
// base URL). Omit apiKey to fall back to the SPREADSPACE_API_KEY env var.
const 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). No has_more / total bookkeeping; just iterate.

for await (const borrower of client.borrowers.list()) {
console.log(borrower.borrower_id);
}
// Filters + page size:
for await (const b of client.borrowers.list({ intake: true, limit: 50 })) { /* ... */ }
// Loans (optionally scoped to a borrower) and jobs iterate the same way:
for await (const loan of client.loans.list({ borrowerId: '4a2c8e1f6b3d5a7c9e0f1b2d' })) { /* ... */ }
for await (const job of client.jobs.list()) { /* ... */ }

Create and retrieve a loan

Borrower, loan, and job reads return typed objects with their wire identifiers.

const createdBorrower = await client.borrowers.create({ name: 'Example borrower' });
const createdLoan = await client.loans.create(createdBorrower.borrower_id, { name: 'Example loan', requested_amount: '250000.00' });
const retrievedLoan = await client.loans.get(createdLoan.loan_id);
console.log(retrievedLoan.loan_id, retrievedLoan.name);

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.

import { readFile, writeFile } from 'node:fs/promises';
const operationKey = 'statement-upload-001'; // Persist this before starting the upload.
const job = await client.documents.upload('./statement.pdf', {
loanId: '7d1e3b5a9c2f4e6b8d0a3c5e',
borrowerId: '4a2c8e1f6b3d5a7c9e0f1b2d',
operationKey,
});
await writeFile('accepted-job-id.txt', job.id);
const completedJob = await job.wait({ timeoutMs: 10 * 60_000 });
console.log(completedJob.status);
// After a restart, read the accepted job instead of uploading again:
const acceptedJobId = await readFile('accepted-job-id.txt', 'utf8');
const resumedJob = await client.jobs.get(acceptedJobId);
console.log(resumedJob.status);
// From bytes / a Blob, fileName + contentType are required:
await client.documents.upload(bytes, {
loanId: '7d1e3b5a9c2f4e6b8d0a3c5e',
fileName: 'statement.pdf',
contentType: 'application/pdf',
});

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.

const intakeJob = await client.intake.submit('./statement.pdf', {
clientReference: 'application-123', // groups repeat submissions onto one loan
operationKey: 'application-123-document-1', // persist before the first attempt
});
console.log(intakeJob.id, intakeJob.loanId, intakeJob.borrowerId); // persist all three
const intakeStatus = await intakeJob.wait(); // JobStatus
// From bytes, fileName and contentType are required:
await client.intake.submit(bytes, {
fileName: 'statement.pdf',
contentType: 'application/pdf',
});
const borrower = await client.borrowers.completeIntake(intakeJob.borrowerId, { name: 'Acme Corporation' }); // once your system holds the verified name

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.

const exp = await client.exports.create({
borrowerId: '4a2c8e1f6b3d5a7c9e0f1b2d',
loanId: '7d1e3b5a9c2f4e6b8d0a3c5e',
documentIds: ['7a1d4e9c2b3f4a5d8e6c1b0f', '2a3b4c5d6e7f809112345678'],
format: 'json',
});
const done = await exp.wait({ timeoutMs: 5 * 60_000 });
console.log(done.status); // 'succeeded'
console.log(done.downloadUrl); // 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 (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.

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

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.

const [firstBorrower] = await client.borrowers.list<{ borrower_id: string }>().toArray();
const { schedules } = await client.extractions.listDebtSchedules(firstBorrower.borrower_id);
for (const row of schedules) console.log(row.extracted_document_id, row.report_data.as_of_date);
if (schedules[0]) {
const schedule = await client.extractions.getDebtSchedule(
firstBorrower.borrower_id, schedules[0].extracted_document_id);
console.log(schedule.form_variant, schedule.debt_schedule.length);
// Any category, in the shape of its family:
const anyBody = await client.extractions.getReportData(
firstBorrower.borrower_id, schedules[0].extracted_document_id);
console.log(Object.keys(anyBody));
}

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.

import { NotFoundError, PermissionError } from '@spreadspace/sdk';
// One profit and loss statement's mapping, by document id. A mapping being
// prepared rejects with NotFoundError and retryAfter set; none at all,
// NotFoundError without it.
try {
const mapping = await client.statementMappings.getProfitLoss(firstBorrower.borrower_id, '7a1d4e9c2b3f4a5d8e6c1b0f');
console.log(mapping.status, mapping.stale, mapping.payload?.mapping_state);
for (const node of mapping.payload?.tree ?? []) {
// Every figure is a two-decimal string, positive in the direction of `effect`.
console.log(node.kind, node.label, node.effect, node.values[0], node.external_code);
}
for (const foot of mapping.payload?.foot ?? []) {
console.log(foot.printed_bottom_line, foot.residual, foot.tolerance);
}
} catch (err) {
if (err instanceof NotFoundError && err.retryAfter !== undefined) {
// Being prepared: read again after err.retryAfter seconds.
} else if (err instanceof PermissionError && err.type === 'statement_mapping_not_in_plan') {
// The workspace's plan does not include statement mapping.
} else {
throw err;
}
}
// 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.
const sheetMapping = await client.statementMappings.getBalanceSheet(firstBorrower.borrower_id, '3c8b1f5e9a2d4c7b6e0f1a2b');
for (const sheetFoot of sheetMapping.payload?.foot ?? []) {
console.log(sheetFoot.printed_total_assets, sheetFoot.assets_residual, sheetFoot.liabilities_and_equity_residual, sheetFoot.imbalance);
}
// Every profit and loss mapping on the borrower's file, newest document first.
// Each page also names the documents whose first mapping is being prepared;
// listBalanceSheet pages the balance sheets the same way.
for await (const page of client.statementMappings.listProfitLoss(firstBorrower.borrower_id, { loanId: '7d1e3b5a9c2f4e6b8d0a3c5e' }).pages()) {
for (const row of page.data) console.log(row.document_id, row.status, row.payload?.mapping_state);
console.log(page.pending_document_ids);
}

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.

const chart = await client.chartOfAccounts.get();
console.log(chart.version, chart.rules_version, chart.active_export_system); // version is 0 until the workspace saves a chart
const exportMap = await client.chartOfAccounts.getExportMap();
console.log(exportMap.system, exportMap.entries.length);
// The whole map for one export system; an account left out leaves the map.
const savedMap = await client.chartOfAccounts.replaceExportMap({
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' },
],
});
console.log(savedMap.entries.map((entry) => entry.external_code));

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.

import { verifyAndParseWebhook, WebhookSignatureError } from '@spreadspace/sdk';
try {
const event = verifyAndParseWebhook(rawBody, signatureHeader, 'whsec_...');
if (event.type === 'job.completed') console.log(event.data.job_id);
if (event.type === 'spread.finalized') {
// The snapshot this event names, by id, not "the latest final".
const snapshot = await client.loans.attributes.retrieve(
event.data.loan_id, event.data.snapshot_id);
}
} catch (err) {
if (err instanceof WebhookSignatureError) {
// reject with 400
}
}
// 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.

const receiver = client.webhooks.receiver({
secret: [process.env.SPREADSPACE_WEBHOOK_SECRET!, process.env.SPREADSPACE_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
onSpreadFinalized: async ({ event, attributes }) => {
await db.saveFinalizedFigures(event.data.loan_id, attributes);
},
onSpreadReopened: async ({ event }) => {
await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
},
});
app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
const r = await receiver.handle({ rawBody: req.body, headers: req.headers });
res.status(r.status).send(r.body);
});
// Hono: receiver.handle({ rawBody: await c.req.text(), headers: c.req.raw.headers })
// 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.

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.

const mappingReceiver = client.webhooks.receiver({
secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
onExtractionMapped: async ({ event }) => {
// A pointer: read the mapping through the read `statement` names, where your own scope and plan apply.
const { borrower_id, document_id } = event.data;
const mapped = event.data.statement === 'balance_sheet'
? await client.statementMappings.getBalanceSheet(borrower_id, document_id)
: await client.statementMappings.getProfitLoss(borrower_id, document_id);
console.log(event.data.reason, mapped.status, mapped.payload?.mapping_state);
},
});

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.

const embedClient = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });
const handle = await embedClient.embed.iframeUrls.create({
loan_id: '7d1e3b5a9c2f4e6b8d0a3c5e',
surface: 'spreading',
scopes: ['documents:read', 'extractions:read', 'spreads:read'],
external_user_id: 'usr_1234',
});
console.log(handle.signed_url, handle.expires_at); // return signed_url to the page
const session = await embedClient.embed.sessions.create({
loan_id: '7d1e3b5a9c2f4e6b8d0a3c5e',
scopes: ['documents:read', 'extractions:read', 'spreads:read'],
external_user_id: 'usr_1234',
});
console.log(session.session_id, session.persistence); // 'user' when an id was sent
await embedClient.embed.sessions.revoke(session.session_id);

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.

import { RateLimitError, SpreadSpaceError } from '@spreadspace/sdk';
try {
for await (const b of client.borrowers.list()) { /* ... */ }
} catch (err) {
if (err instanceof RateLimitError) {
console.log(`rate limited; retry after ${err.retryAfter}s (request ${err.requestId})`);
} else if (err instanceof SpreadSpaceError) {
console.log(`${err.type}: ${err.message} (request ${err.requestId})`);
}
}
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

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.

const { statements } = await client.extractions.listBankStatements(firstBorrower.borrower_id);
const amount = statements[0]?.report_data.transactions[0]?.amount; // string | undefined
const { returns } = await client.extractions.listTaxReturns(firstBorrower.borrower_id);
const taxReturn = returns[0]?.report_data;
if (taxReturn?.form === '1065') {
const receipts = taxReturn.data.income.line_1a_gross_receipts; // string | null, e.g. "9200000.00"
console.log(amount, receipts);
}