> This page is for SDKs.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# TypeScript

> The official TypeScript and Node SDK, @spreadspace/sdk on npm: typed extraction results, error handling, retries, idempotency, and pagination.

The official TypeScript / Node SDK, published to npm as **`@spreadspace/sdk`**.
It wraps the [API Reference](/api) 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

```bash
npm i @spreadspace/sdk
```

## Construct the client

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

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

```ts
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, `PUT`s 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.

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

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

```ts
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](/api/spreads). `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.

```ts
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](/api/api-reference/debt-schedules/object)
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.

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

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

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

```ts
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](/api/spreads)). Full option list on
the [Webhooks page](/api/webhooks#receive-events-with-the-sdk).

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

```ts
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](/embed/overview). `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](/embed/server) covers every field.

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

```ts
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})`);
  }
}
```

| Error               | HTTP                                 |
| ------------------- | ------------------------------------ |
| InvalidRequestError | 400                                  |
| AuthenticationError | 401                                  |
| PermissionError     | 403                                  |
| NotFoundError       | 404                                  |
| ConflictError       | 409                                  |
| RateLimitError      | 429 (honors `Retry-After`)           |
| ServerError         | 5xx                                  |
| NetworkError        | transport 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`.

```ts
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);
}
```