Mint from your server

Your server mints the credential that authorizes an embed, and the API key that mints it never leaves your server. The SpreadSpace SDKs for TypeScript, Python and C# carry both mints, the session revoke and the webhook receiver. A backend in any other language calls the same endpoints directly, shown here as cURL.

Every mint on this page reads the embed-minting key, the key that holds embed:write, from SPREADSPACE_EMBED_API_KEY. The Backend key from the Quick start stays in SPREADSPACE_API_KEY, and the receiver under Verify webhooks is the one sample on this page that uses it.

Server-side use only. An API key must never appear in browser code: the browser receives a signed_url and nothing else.

Install

npm i @spreadspace/sdk

Construct the client

import { SpreadSpace } from '@spreadspace/sdk';
const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });

Pass the embed key explicitly. A client that is given no key reads SPREADSPACE_API_KEY. A key from the Backend preset does not hold embed:write, so a mint with it answers 403 insufficient_scope. In TypeScript and C#, an unset SPREADSPACE_EMBED_API_KEY is that case.

Mint an iframe handle

POST /api/embed/iframe-urls mints a single-use handle for one loan and one surface and returns it inside a signed_url. Return that URL to your page: <SpreadSpaceEmbed> from @spreadspace/react loads it, and so does a plain iframe src. The handle is exchanged for the session token inside the frame, so the token never reaches your page. The loan_id is the loan intake provisioned for the application, as Board loans from your system shows, or one you created.

FieldRequiredMeaning
loan_idYesThe loan the session is locked to.
surfaceYesspreading or documents. Any other value is rejected.
scopesThe scopes the session may use. Name them on every mint. See Scopes.
handle_lifetime_secondsHow long the handle stays exchangeable. Default 60, at most 300.
token_lifetime_secondsHow long the session token lives after the exchange. Default 3600, at most 86400.
external_user_idYour own stable id for the viewing user. See Per-user state.
display_nameA label for that id. Requires external_user_id.
const handle = await client.embed.iframeUrls.create({
loan_id: '6b1d9e2f4a7c3b5d8e0f2a1c',
surface: 'spreading',
scopes: ['documents:read', 'extractions:read', 'spreads:read'],
external_user_id: 'usr_1234', // your app's id for the viewing user
});
// handle.signed_url -> return it to the page unchanged.
// handle.handle_id -> record it in your own audit log.

The response:

{
"signed_url": "https://embed.spreadspace.app/embed/spreading/6b1d9e2f4a7c3b5d8e0f2a1c?session=ih_4d5e6f708192a3b4c5d6e7f8",
"handle_id": "ih_4d5e6f708192a3b4c5d6e7f8",
"expires_at": "2026-09-05T16:31:00.791760+00:00",
"surface": "spreading",
"loan_id": "6b1d9e2f4a7c3b5d8e0f2a1c",
"borrower_id": "9f8e7d6c5b4a3210a1b2c3d4",
"scopes": ["documents:read", "extractions:read", "spreads:read"]
}
FieldMeaning
signed_urlThe URL the frame loads. Hand it to the page unchanged; it may carry more query parameters than shown here.
handle_idThe handle the URL carries, returned separately so you can record it without parsing the URL.
expires_atWhen the handle stops being exchangeable. It is not when the session ends.
surfaceThe surface the handle was minted for.
loan_idThe loan the session is locked to.
borrower_idThe borrower that loan belongs to.
scopesThe scopes the session will hold.
display_nameThe label as stored. Present only when the mint carried one.

Each call mints a fresh handle, and a handle is exchanged once: a second load of the same URL fails inside the frame. Mint at the moment the page asks, and never cache or reuse a signed_url.

Mint an embed session

POST /api/embed/sessions returns the token itself, embed_token, a loan-scoped bearer for a client of your own that calls the API without the iframe. The token is returned once, and no endpoint retrieves it again. The request takes loan_id (required), scopes, expires_in_seconds (default 3600, at most 86400), external_user_id and display_name.

const session = await client.embed.sessions.create({
loan_id: '6b1d9e2f4a7c3b5d8e0f2a1c',
scopes: ['documents:read', 'extractions:read', 'spreads:read'],
external_user_id: 'usr_1234',
});
// session.embed_token is the bearer, scoped to this one loan.
// session.session_id is the id a revoke takes.

The response carries embed_token, session_id, expires_at (when the token expires), loan_id, borrower_id, scopes and persistence, plus external_user_id and display_name when the mint carried them.

Scopes

Name scopes on every mint. Read scopes are granted by mint authority: a key that holds embed:write may name documents:read, extractions:read and spreads:read whether or not it holds them. A write scope is granted only when the request names it and only if the minting key holds it. No other scope is mintable: any other named scope, or a wildcard, fails the mint with 400 embed_scope_not_allowed, and a named write the key does not hold fails with 400 embed_scope_exceeds_key.

Write scopeWhat it adds inside the frame
spreads:writeSaving, finalizing and reopening spreads.
extractions:writeThe analyst’s extraction edits, and everything spreads:write adds.
extractions:editEditing an extracted figure or caption in the row editor.
documents:writeUploading documents.

To give an analyst write access, name the writes beside the reads. This body mints a session that may finalize spreads and upload documents, from a key that holds both writes, as the dashboard’s Embed preset does:

{
"loan_id": "6b1d9e2f4a7c3b5d8e0f2a1c",
"surface": "spreading",
"scopes": ["documents:read", "extractions:read", "spreads:read", "spreads:write", "documents:write"],
"external_user_id": "usr_1234"
}

In the SDKs the same list goes in scopes. A session with none of the writes renders read-only and offers no Finalize. The token never carries embed:write, so a leaked token cannot mint further sessions or widen its own scope, and every scope it holds stays bound to the one loan.

Lifetimes

ClockSet byDefaultMaximum
Handlehandle_lifetime_seconds60300
Token from a handle exchangetoken_lifetime_seconds360086400
Token from a session mintexpires_in_seconds360086400

A value above its maximum is rejected with 400 invalid_request. The handle dies at its first exchange, and the token’s lifetime runs from that exchange. When the token nears expiry, or the API rejects it, the frame asks the host page for a fresh handle. <SpreadSpaceEmbed> answers by calling your mint endpoint again, so your server sees one more mint. The Embed integration guide walks through the refresh.

Per-user state

Both mints accept an optional external_user_id: an opaque, stable identifier for the end user you are embedding for, up to 256 characters after trimming. Use a durable id from your own system rather than an email address, which can change. Derive it from your backend’s own session, never from the browser request, so one user cannot open another’s saved state. 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. Without it each session starts from defaults and saves nothing.

The session response echoes external_user_id back, trimmed. 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. A blank external_user_id is rejected.

display_name is optional, up to 120 characters. It is the name the workspace shows for that user on the spreads they save and finalize, on their comments and in live presence. 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. The handle response carries no persistence; to assert it, mint a session.

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

Revoke a session

DELETE /api/embed/sessions/{sessionId} revokes a session minted with POST /api/embed/sessions. The key that minted the session revokes it, the call needs embed:write, and it answers 204. The revoked token is refused from then on.

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

A session created by a handle exchange has no id your server ever sees, so there is nothing to revoke per session on the iframe path. The control point there is the key: removing embed:write from it severs every token it minted and every handle still waiting to be exchanged, and the key’s other traffic keeps flowing.

Verify webhooks

When the analyst finalizes inside the frame, spread.finalized reaches your backend as a webhook. The SDK’s receiver verifies the SpreadSpace-Signature header, drops duplicates, reads the snapshot the event names, and hands back the status and body to answer with. It reads that snapshot with spreads:read, which the Embed preset does not hold, so build it from a client that uses the Backend key in SPREADSPACE_API_KEY.

The signature covers the exact bytes received. Give the receiver the raw request body, never a parsed and re-serialized one. In Express, express.raw({ type: 'application/json' }) on the route makes req.body those bytes; Express does not set a raw body on the request by itself.

const backend = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
const receiver = backend.webhooks.receiver({
secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
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);
},
});
// express.raw leaves req.body as the bytes that were signed.
app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
const receipt = await receiver.handle({ rawBody: req.body, headers: req.headers });
res.status(receipt.status).send(receipt.body);
});

To verify without the receiver, call verifyAndParseWebhook (TypeScript), verify_and_parse_webhook (Python) or WebhooksResource.VerifyAndParse (C#) with the same raw body, the header value and the endpoint’s signing secret. Each raises a signature error when verification fails. The Webhooks page has the signature scheme, the receiver’s options and secret rotation.

Errors

Status and typeWhen
400 embed_scope_not_allowedThe mint named a scope that is not mintable for an embed session.
400 embed_scope_exceeds_keyThe mint named a write scope the minting key does not hold.
400 invalid_requestA lifetime is out of range, surface is not recognized, external_user_id is blank, or display_name came without external_user_id.
402 billing_requiredThe workspace has no active subscription.
403 insufficient_scopeThe key does not hold embed:write.
403 embed_not_in_planThe workspace’s plan does not include the embedded review UI.
404The loan does not exist in the key’s workspace.

Each SDK raises a typed error that carries the wire type and the request id. Match on the class, then read the type.

import { InvalidRequestError, PermissionError } from '@spreadspace/sdk';
try {
await client.embed.iframeUrls.create({
loan_id: '6b1d9e2f4a7c3b5d8e0f2a1c',
surface: 'spreading',
scopes: ['documents:read', 'extractions:read', 'spreads:read', 'documents:write'],
});
} catch (err) {
if (err instanceof InvalidRequestError && err.type === 'embed_scope_exceeds_key') {
// The key does not hold documents:write.
} else if (err instanceof PermissionError) {
// err.type is insufficient_scope or embed_not_in_plan.
}
}

The full error vocabulary is on the Errors page.