Skip to navigation

Embed integration guide

Embed the workspace in your application and receive finalized spreads.

This page runs on a test key (ss_test_...), which 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 Embed preset. Nothing else on this page changes.

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 your Quick start run printed, so the loan already holds a Form 1065 Tax Return; the SDK the Quick start installed, for the server samples in TypeScript, Python and C#; 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.

This guide uses two keys. Every mint below reads the Embed-preset key from SPREADSPACE_EMBED_API_KEY; the Backend key from the Quick start stays in SPREADSPACE_API_KEY.

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, intake:write for boarding through intake, documents:read to poll intake jobs, and the four public write scopes a session may carry: documents:write, extractions:write, extractions:edit and spreads:write. Holding the writes 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 Quick start’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 embed key and the loan in your environment. The embed key goes in SPREADSPACE_EMBED_API_KEY; leave the Quick start’s Backend key in SPREADSPACE_API_KEY:

export SPREADSPACE_EMBED_API_KEY="ss_test_..."
export SPREADSPACE_LOAN_ID="<the loan_id your Quick start run printed>"

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 Mint from your server 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 loan is the one your Quick start run printed, in SPREADSPACE_LOAN_ID; in production it is the loan that Board loans from your system provisions for the application, or one you created. The request body takes loan_id and surface (both required; the surface is spreading or documents), scopes (name them on every mint), and optionally 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.

Every sample names the three read scopes plus spreads:write, the write this analyst needs to finalize in step 7. Step 5 has the rule for what a mint may name. The TypeScript, Python and C# samples use the SDK from the Quick start: @spreadspace/sdk, spreadspace and SpreadSpace.

import { SpreadSpace } from '@spreadspace/sdk';
const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });
// Your page calls this with { surface, loanId }. The analyst's id comes from
// your own session, never from the request body.
app.post('/spreadspace/iframe-url', express.json(), async (req, res) => {
const handle = await client.embed.iframeUrls.create({
loan_id: req.body.loanId,
surface: req.body.surface,
scopes: ['documents:read', 'extractions:read', 'spreads:read', 'spreads:write'],
external_user_id: req.session.userId,
});
res.json({ signed_url: handle.signed_url });
});

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

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

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. In Next.js, render it from a client component: the file below starts with 'use client';.

A page without React uses the same protocol through the plain JavaScript loader: mount() from @spreadspace/embed, as a module or a script tag, with the same getHandle callback. The React component is a thin layer over it. See Plain JavaScript loader.

npm i @spreadspace/react
React
'use client';
import { SpreadSpaceEmbed, type GetHandle } from '@spreadspace/react';
export function LoanWorkspace({ loanId }: { loanId: string }) {
// Calls the endpoint from step 2. It receives { surface, loanId, reason? }
// and returns the signed_url unchanged, never a token.
const getHandle: GetHandle = async ({ surface, loanId }) => {
const res = await fetch('/spreadspace/iframe-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ surface, loanId }),
});
const { signed_url } = await res.json();
return signed_url;
};
return (
<SpreadSpaceEmbed
surface="spreading"
loanId={loanId}
getHandle={getHandle}
theme="dark"
onError={(err) => {
if (err.fatal) console.error(err.type, err.message);
}}
/>
);
}

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. 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. The workspace shows it as that user’s name on the spreads they save and finalize, on their comments and in live presence.

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

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, documents:write, extractions:write, extractions:edit or spreads:write, is granted only when the mint names it and only if the key holds it. Other scopes are not part of the public embed surface: a named scope an embed session cannot carry, 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.

The mints in step 2 name spreads:write beside the three reads, and the Embed preset holds it, so that session may finalize. A session needs spreads:write (or the wider extractions:write) to finalize; without it the frame is read-only and offers no Finalize. To let the analyst upload inside the frame as well, add documents:write to the same list. 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 or unauthorized (the type also admits expired), 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/{session_id}, 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), retrieve the snapshot by ID (step 4), handle reopened spreads (step 5), and test 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, where an endpoint registered with a test key receives it. 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.

Board loans from your system

When the application lives in your own system, board it through intake instead of creating the borrower and the loan first. POST /api/intake provisions a borrower and a loan for the submission and returns an upload URL; you PUT the file to that URL, then POST /api/intake/{job_id}/confirm starts processing. The create response carries loan_id and borrower_id, both server-assigned, and that loan_id is the loan you mint the handle against in step 2. Both calls need intake:write on the key, which the Embed preset holds, so the key from step 1 submits the document and mints the handle. The create answers 402 when billing is inactive. On a test key the confirm completes the job with sample data and its response carries sample: true. The reference pages are Create intake and Confirm intake.

Set client_reference to your own application id, up to 128 characters and unique within your workspace. A repeat submission with the same reference resolves to the same loan and borrower instead of creating another pair, so every document for that application lands on one loan; store that loan_id on your application record. Omit the reference and every submission provisions a fresh pair. borrower_name, up to 256 characters, names the provisioned borrower; without it the borrower carries a placeholder name until you complete its profile. The upload takes application/pdf, application/zip, application/x-zip-compressed or application/json, the PUT carries the same Content-Type as the create, and a PDF over 1,500 pages is refused when processing starts.

The SDK helpers run the three legs in one call and return a job handle that carries both ids: client.intake.submit in TypeScript and Python, client.Intake.SubmitAsync in C#. The raw legs are client.intake.create and client.intake.confirm (client.Intake.CreateAsync and ConfirmAsync in C#). The operation key gives the create and the confirm stable idempotency keys; persist it before the first attempt and reuse it only to retry that same document. The cURL tab shows the three legs.

import { SpreadSpace } from '@spreadspace/sdk';
const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });
// One document for application-123. The application id is the client_reference,
// so every later document for it lands on the same loan.
const job = await client.intake.submit('./statement.pdf', {
clientReference: 'application-123',
operationKey: 'application-123-document-1', // persist before the first attempt
});
console.log(job.id, job.loanId, job.borrowerId); // persist all three
// Mint against the loan intake provisioned, as step 2 does.
const handle = await client.embed.iframeUrls.create({
loan_id: job.loanId,
surface: 'spreading',
scopes: ['documents:read', 'extractions:read', 'spreads:read', 'spreads:write'],
external_user_id: 'usr_1234', // the analyst's id from your own session
});
console.log(handle.signed_url);

The job id from the create is what you poll: client.documents.status(jobId) reads GET /api/documents/{job_id}/status, which needs documents:read on the key; poll from a client built on SPREADSPACE_API_KEY, the Backend key from the Quick start, which holds it. The borrower is an intake borrower, carrying is_intake: true and listed apart from your curated borrowers (GET /api/borrowers?intake=true), until you complete its profile: client.borrowers.completeIntake(borrowerId, { name }) calls POST /api/borrowers/{id}/complete-intake, which takes the business name and moves the borrower into your curated list. It needs borrowers:write, which the Backend key holds, so call it from that same client. A borrower already completed answers 409, so treat a 409 on a retry as done. Submissions under the same client_reference keep landing on the same loan after that. The reference page is Complete borrower intake.

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.
  • Mint from your server: both mints field by field in TypeScript, Python, C# and cURL, and the webhook verifier.
  • API integration guide: the receiver, the snapshot, reopen and replay.