Embed integration guide
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:
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.
The response carries signed_url, handle_id, expires_at, surface,
loan_id, borrower_id and scopes:
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.
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.