Node embed SDK

@spreadspace/embed is the server-side companion for embedding SpreadSpace. Use it to mint short-lived embed sessions and iframe handles from your backend, and to verify webhook signatures. It is the same typed client documented on the TypeScript SDK page, highlighted here for the embed flow.

Server-side use only. Never ship this package to a browser: API keys must never appear in client code. Mint a short-lived token server-side and pass only that token, or the signed_url, to the browser.

Install

$npm i @spreadspace/embed

Construct the client

1import { SpreadSpaceClient } from '@spreadspace/embed';
2
3const client = new SpreadSpaceClient({
4 apiKey: process.env.SPREADSPACE_API_KEY!, // 'ss_live_...' or 'ss_test_...'
5});

Other constructor options: baseUrl (override the base URL), apiVersion (pins the SpreadSpace-Version header), timeout (milliseconds per attempt), and maxRetries.

Mint an embed session

An embed session is a token scoped to one loan. Mint it server-side, then pass session.embed_token to the browser. The token is locked to that single loan and expires automatically (default one hour, maximum 24 hours).

1const session = await client.embed.sessions.create<{
2 session_id: string;
3 embed_token: string;
4 expires_at: string;
5 scopes: string[];
6 persistence: 'user' | 'session';
7}>({
8 loan_id: 'loan_abc',
9});
10
11// session.embed_token is the browser-facing bearer string, scoped to this
12// single loan. session.scopes reports the scopes it carries;
13// session.expires_at is the expiry.

Optional fields on the request body: scopes (a subset of the calling key’s scopes; omit to inherit the read-only default: the in-widget upload and review scopes documents:write / extractions:write are granted only when named explicitly, and stay bound to the session’s loan), expires_in_seconds (defaults to 3600, capped at 86400), and external_user_id (a stable id for your end user, covered under per-user state below). Revoke a session early when your UI flow ends before its natural expiry:

1await client.embed.sessions.revoke(session.session_id);

Mint an iframe handle

For the <iframe> integration used by @spreadspace/react, mint a single-use handle instead. Your backend returns the signed_url, which the SDK drops into the iframe source. The handle is exchanged for the token at SpreadSpace’s origin, so the token never crosses into the parent page.

1const result = await client.request<{ signed_url: string; handle_id: string }>(
2 'POST', '/api/embed/iframe-urls', {
3 body: { surface: 'spreading', loan_id: 'loan_abc' },
4 });
5
6// result.signed_url -> hand to the browser as the iframe src.
7// result.handle_id -> correlate with your own audit log.

The handle is short-lived by design (default 60 seconds, maximum 300). For a longer-lived browser token, mint an embed session instead.

Per-user state

Both mint shapes accept an optional external_user_id: an opaque, stable identifier for the end user you are embedding for, up to 256 characters. Use a durable id from your own system rather than an email address, which can change. Supply it and the embed keeps that user’s state on the server, keyed to your organization and the id you send, so ids only need to be unique within your own system. Saved boards and view settings, and the memo template library, follow the user across sessions and devices. Omit the field and each session starts from defaults.

1const session = await client.embed.sessions.create({
2 loan_id: 'loan_abc',
3 external_user_id: 'usr_1234', // your app's id for the viewing user
4 display_name: 'Dana Whitfield', // optional label for that user
5});

The session response echoes external_user_id back, trimmed, and omits the field when the mint carried none. On the iframe path the id is stored on the handle and copied onto the session at exchange, so both routes yield the same identity-carrying session. Send the same id for the same person on every mint. An empty string is rejected rather than treated as omitted, so a bug that sends one fails loudly.

display_name is optional, up to 120 characters. Reserved. Stored with the id, not shown anywhere yet. It is echoed back, never written to a log and never placed in the token. It requires external_user_id in the same request, and sending one alone is rejected.

Every session response also carries persistence, which says which mode you got: user when the mint included an external_user_id, session when it did not. A session value means the embed renders and works normally but saves nothing for the viewer. Assert on it in your integration tests, so a dropped external_user_id fails your build instead of quietly discarding a day of your analyst’s work.

1if (session.persistence !== 'user') {
2 throw new Error('embed session will not persist end-user state');
3}

Verify a webhook

Verify the spreadspace-signature header against the exact bytes you received, never a re-serialized JSON object, or the HMAC will not match.

1import { verifyAndParseWebhook } from '@spreadspace/embed/webhooks';
2
3app.post('/webhooks/spreadspace', async (req, res) => {
4 const event = verifyAndParseWebhook(
5 req.rawBody, // the exact bytes received, not re-stringified JSON
6 req.headers['spreadspace-signature'] as string,
7 process.env.SPREADSPACE_WEBHOOK_SECRET!,
8 );
9
10 switch (event.type) {
11 case 'extraction.ready':
12 await markExtractionReady(event.data.loan_id, event.data.extraction_id);
13 break;
14 case 'job.completed':
15 break;
16 }
17 res.status(200).end();
18});

verifyAndParseWebhook throws WebhookSignatureError on any failure and otherwise returns the parsed, typed event. The verifier is a tree-shakeable @spreadspace/embed/webhooks import for receiver functions.

Scopes

The API key that mints embed sessions and iframe handles needs the embed:write scope. Issue a key from the dashboard at Settings → Live API (or Settings → Sandbox API for a test key) and grant it embed:write. Minting is a service-account operation, so use an API key, not a user session. The browser-facing token that results never carries embed:write, so a leaked token cannot mint further sessions or widen its own scope.

The security model

The token minting stays on your server; only a short-lived, loan-scoped token (read-only unless the upload/review writes were explicitly minted), or a single-use signed_url, ever reaches the browser. An embed token is locked to the one loan it was minted for and cannot mint further tokens. Revoking embed:write from the minting key immediately severs every issued token and in-flight handle, and a per-session revoke is available for targeted teardown.

Errors

Every API error maps to a typed error you can match with instanceof. Each carries requestId (the server-generated X-Request-ID), type (the canonical error-type string), and statusCode. Quote requestId in support tickets.

1import { SpreadSpaceClient, RateLimitError, PermissionError } from '@spreadspace/embed';
2
3const client = new SpreadSpaceClient({ apiKey: process.env.SPREADSPACE_API_KEY! });
4
5try {
6 await client.embed.sessions.create({ loan_id: 'loan_abc' });
7} catch (err) {
8 if (err instanceof PermissionError) {
9 // e.g. the API key is missing the embed:write scope
10 } else if (err instanceof RateLimitError) {
11 // the SDK already retried; back off further or surface to the caller
12 }
13}