Embed integration guide
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:
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.
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.
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.
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.
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.