Embed integration guide

Run the workspace inside your product, from mint to finalize.

This page runs the embed loop end to end: your server mints a single-use handle for one loan, your page renders the SpreadSpace workspace from it, the analyst works and finalizes inside the frame, and the figures the analyst approved reach your backend as a snapshot, the same way they do from the workspace. Every request below runs against the sandbox with a test key, so the loan is a seeded one and nothing is billed. A live key runs the same steps against your own loans.

What you need: a sandbox key minted from the Embed preset (it is the one preset that grants embed:write, and it requires an allowed-origins list naming the page that will hold the frame); the loan_id from your Quickstart run, so the loan already holds a Form 1065 Tax Return; a React 18 or 19 app for the render step; and a plan that includes the embedded review UI, which the sandbox shares with your workspace. On any other plan both mint endpoints answer 403.

A test key (ss_test_...) mints against the sandbox, so the frame can only reach seeded loans. To run the loop on a live loan, mint the key from Settings, then Live API, with the same preset. Nothing else on this page changes.

1. Get an embed key

In the dashboard, open Settings, then Sandbox API under API, and press Create sandbox key. In the Create API key dialog, select the Embed preset, add the origin of the page that will hold the frame under Allowed origins (for a local run, http://localhost:3000), and press Create key. The key is shown once, on the API key created screen. Copy it now.

The preset holds embed:write, the scope both mint endpoints require, and the four writes a session may carry: documents:write, extractions:write, extractions:edit and spreads:write. Holding them delegates nothing by itself; a session carries a write only when the mint names it. The allowed origins are the only pages the browser lets frame the surface. Listing borrowers and loans needs borrowers:read and loans:read, which this preset does not hold: the Quickstart’s Backend key found the loan, and its upload put a Form 1065 Tax Return on it. Use that loan. The sandbox holds three seeded borrowers with five loans between them, and a seeded loan holds no documents until you upload one.

Put the key and the loan in your environment:

$export SPREADSPACE_API_KEY="ss_test_..."
$export SPREADSPACE_LOAN_ID="<the loan_id from your Quickstart run>"

2. Mint a handle on your server

Two mints exist. POST /api/embed/iframe-urls returns a signed_url for one loan and one surface. Your page loads it in an iframe, and inside the frame the single-use handle in that URL is exchanged for the session token, which never reaches your page. POST /api/embed/sessions returns the token itself, embed_token, a loan-scoped bearer for a client of your own that does not load the iframe. This guide uses handles. The Node embed SDK page covers both mints field by field.

The endpoint below is the one your page calls in step 3. It takes the loan and the surface from your page, adds the analyst’s id from your own session, names the scopes the session may carry, and returns the signed_url. The request body takes loan_id and surface (both required; the surface is spreading or documents), and optionally scopes, handle_lifetime_seconds (default 60, at most 300), token_lifetime_seconds (default 3600, at most 86400), external_user_id and display_name. The mint needs embed:write on the key and runs on the api rate-limit lane, 200 requests per minute per key.

1import { SpreadSpaceClient } from '@spreadspace/embed';
2
3const client = new SpreadSpaceClient({ apiKey: process.env.SPREADSPACE_API_KEY! });
4
5// Your page calls this with { surface, loanId }. The analyst's id comes from
6// your own session, never from the request body.
7app.post('/spreadspace/iframe-url', express.json(), async (req, res) => {
8 const handle = await client.request<{ signed_url: string; handle_id: string; expires_at: string }>(
9 'POST', '/api/embed/iframe-urls', {
10 body: {
11 loan_id: req.body.loanId,
12 surface: req.body.surface,
13 scopes: ['documents:read', 'extractions:read', 'spreads:read', 'spreads:write'],
14 external_user_id: req.session.userId,
15 },
16 });
17 res.json({ signed_url: handle.signed_url });
18});

The response carries signed_url, handle_id, expires_at, surface, loan_id, borrower_id and scopes:

1{
2 "signed_url": "https://embed.spreadspace.app/embed/spreading/9876543210abcdef98765432?session=ih_4d5e6f708192a3b4c5d6e7f8",
3 "handle_id": "ih_4d5e6f708192a3b4c5d6e7f8",
4 "expires_at": "2026-09-05T16:31:00.791760+00:00",
5 "surface": "spreading",
6 "loan_id": "9876543210abcdef98765432",
7 "borrower_id": "9f8e7d6c5b4a3210a1b2c3d4",
8 "scopes": ["documents:read", "extractions:read", "spreads:read", "spreads:write"]
9}

expires_at is when the handle stops being exchangeable, sixty seconds by default. It is not when the session ends. Mint at the moment the page asks, never ahead of time. Each call mints a new handle, and a handle is exchanged once: a second load of the same URL fails inside the frame. The URL may carry more query parameters than shown here; hand it to the page unchanged. handle_id is the same value the URL carries, returned separately so you can record it without parsing the URL. scopes is what the session will hold.

3. Render the workspace

<SpreadSpaceEmbed> from @spreadspace/react renders the surface in an iframe and wires the host side of the frame protocol. Give it a getHandle callback that calls the endpoint from step 2 and returns the signed_url.

React
1import { SpreadSpaceEmbed, type GetHandle } from '@spreadspace/react';
2
3export function LoanWorkspace({ loanId }: { loanId: string }) {
4 // Calls the endpoint from step 2. It receives { surface, loanId, reason? }
5 // and returns the signed_url unchanged, never a token.
6 const getHandle: GetHandle = async ({ surface, loanId }) => {
7 const res = await fetch('/spreadspace/iframe-url', {
8 method: 'POST',
9 headers: { 'Content-Type': 'application/json' },
10 body: JSON.stringify({ surface, loanId }),
11 });
12 const { signed_url } = await res.json();
13 return signed_url;
14 };
15
16 return (
17 <SpreadSpaceEmbed
18 surface="spreading"
19 loanId={loanId}
20 getHandle={getHandle}
21 theme="dark"
22 onNav={(nav) => {
23 if (nav.action === 'upload') router.push(`/loans/${loanId}/upload`);
24 }}
25 onError={(err) => {
26 if (err.fatal) console.error(err.type, err.message);
27 }}
28 />
29 );
30}

surface picks the tab the frame opens on, spreading or documents. The frame renders every tab the session’s scopes allow, so a session holding only documents:read and extractions:read opens on document review either way. loanId must be the loan the handle was minted for. getHandle receives { surface, loanId, reason }; reason is present on a refresh and absent on the first load. Return the signed_url unchanged: the component loads it verbatim and takes the frame’s origin from it, so nothing on your page names a SpreadSpace host. theme is pushed to the frame on mount and whenever it changes. onNav receives the frame’s navigation intents, upload and open-loan; the frame never navigates your page itself. onError receives frame errors, with fatal set when the frame cannot render. The full prop list and the ref are on the React embed SDK page.

Without React, put the signed_url in an iframe’s src on a page whose origin is on the key’s allowed origins. The frame exchanges the handle and renders on its own. What it cannot do alone is refresh: sixty seconds before the token expires it asks the parent page for a fresh handle over postMessage, waits fifteen seconds, and shows a reconnect error when nothing answers, so the session lasts at most token_lifetime_seconds. The React component is the supported host: besides answering that request, it answers the frame’s start-up handshake, which the frame falls back to when the browser withholds the parent page’s origin. The message envelope and a host-side messenger that answers both are exported from @spreadspace/react/postmessage, described on the React page.

4. Identity and persistence

external_user_id is your own stable id for the analyst, up to 256 characters after trimming; a blank value is rejected rather than ignored. Derive it from your backend’s session, never from the browser request, so one user cannot open another’s saved state. With it, what the analyst arranges is saved on the server, keyed to your workspace and that id, and comes back on their next session on any device: saved spreads, preferences and memo templates. Without it the frame renders and works normally and saves nothing, and it prints a warning in the browser console when the session it exchanged carries no id.

display_name is an optional label for that id, up to 120 characters, and requires external_user_id in the same request. It is reserved: stored, echoed back, not shown anywhere yet.

persistence on a session response says which mode you got: user when the mint carried an external_user_id, session when it did not. The handle response carries neither persistence nor the id; both are on the session the exchange creates inside the frame. To assert persistence in an integration test, mint a session with POST /api/embed/sessions and check the field.

5. Scopes

A session carries documents:read, extractions:read and spreads:read by default, and may carry documents:write, extractions:write, extractions:edit or spreads:write only when the mint names them and the key holds them. Nothing else is mintable: any other 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.

scopes may be omitted only on a key that holds only read scopes apart from embed:write, intake:write and sandbox:reset: the session then inherits the key’s displayable reads, documents:read, extractions:read, spreads:read. A key holding any other scope, a write, an export or render scope, or a wildcard, as the Embed preset does, must name scopes explicitly, or the mint fails with 400 embed_scope_not_allowed. That is why every mint on this page names its scopes.

To finalize, the session needs spreads:write (or the wider extractions:write); without it the frame is read-only and offers no Finalize. documents:write adds upload inside the frame. The token never carries embed:write, so a leaked token cannot mint further sessions or widen its own scope.

6. Expiry, refresh and revoke

Three clocks run. The handle lives handle_lifetime_seconds (default 60, at most 300) and dies at its first exchange. The token lives token_lifetime_seconds (default 3600, at most 86400) from the exchange. Sixty seconds before it expires, and again whenever the API rejects it, the frame asks the host for a fresh handle. <SpreadSpaceEmbed> answers by calling getHandle again with a reason of expiring, expired or unauthorized, takes the handle out of the signed_url you return, and the frame exchanges it for a new token with the same loan, scopes and identity. Your server sees that as one more call to the endpoint in step 2. A refresh that fails shows the analyst a reconnect error and reaches onError with fatal: true; reload() on the component’s ref mints a fresh handle and reloads the frame.

A session minted with POST /api/embed/sessions is revoked with DELETE /api/embed/sessions/{sessionId}, which needs embed:write on the key that minted it and answers 204. The token stops working at once. A session created by a handle exchange has no id your server ever sees, so the control point for those 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.

7. Finalize inside the frame

The analyst opens a saved spread on the loan and chooses Finalize spread from the board menu, exactly as in the workspace. The board locks, and spread.finalized fires for the loan, naming the snapshot to read. From here the loop is the API integration guide: register an endpoint for spread.finalized and spread.reopened (step 1), run the receiver (step 2), read the snapshot the event names by id (step 4), hear the reopen (step 5), and prove replay and rotation (step 6).

Two things are specific to the embed. The snapshot’s finalized_by_external_user_id is the external_user_id you minted the handle with, so your loan page names the analyst from your own user table. And the finalize is raised in the workspace the loan lives in: a spread finalized on a sandbox loan raises spread.finalized in the sandbox workspace, which only an endpoint registered with a test key hears, while a live loan’s finalize reaches the endpoints in your live workspace. Register the endpoint with the same mode you mint with. The upload events a sandbox run raises take the other route, to your live workspace’s endpoints, as the Testing section of the Webhooks page explains.

Up next

  • Embed: the packages, the security model and the hand-off, on one page.
  • React embed SDK: every prop, the ref, and the protocol module for a hand-rolled frame.
  • Node embed SDK: both mints field by field, and the webhook verifier.
  • API integration guide: the receiver, the snapshot, reopen and replay.