Plain JavaScript loader

@spreadspace/embed embeds a SpreadSpace surface in any web page through a secure, token-isolated <iframe>, with no framework and no build step when you load the script file. mount() renders the surface and wires the host side of the embed protocol: back navigation, an Exit control, theme sync, and token refresh that never exposes a token to your page. It draws the back arrow and the Exit control in your page, over the frame, so a host without React gets the full workspace experience. @spreadspace/react is a thin layer over this loader, so both packages behave the same way.

Install

As a module:

npm i @spreadspace/embed

Or as a script file, which defines a SpreadSpaceEmbed global with the same members as the module:

<script src="https://unpkg.com/@spreadspace/embed@1/dist/spreadspace-embed.iife.js"></script>

Quick start

Call mount() with the element to render into and a getHandle callback. getHandle calls your backend, which mints a single-use embed handle server-side and returns the signed_url from the response. The loader loads that URL as the iframe source. The callback runs on the initial load and again on every token refresh, so your page never holds a long-lived credential.

import { mount } from '@spreadspace/embed';
const embed = mount('#spreadspace', {
surface: 'spreading',
loanId: '9f8b7c6d5e4a3b2c1d0e4f5a',
// getHandle calls YOUR backend, which mints a handle server-side and returns
// the `signed_url`. It receives { surface, loanId, reason? } and returns a
// full embed URL, never a token.
getHandle: async ({ surface, loanId }) => {
const res = await fetch('/my-backend/spreadspace/iframe-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ surface, loanId }),
});
const { signed_url } = await res.json();
return signed_url;
},
chrome: { exit: { href: '/applications/42' } },
});

From a script tag, the same call runs against the SpreadSpaceEmbed global:

<div id="spreadspace" style="height: 100vh"></div>
<script src="https://unpkg.com/@spreadspace/embed@1/dist/spreadspace-embed.iife.js"></script>
<script>
SpreadSpaceEmbed.mount('#spreadspace', {
surface: 'spreading',
loanId: '9f8b7c6d5e4a3b2c1d0e4f5a',
getHandle: async ({ surface, loanId }) => {
const res = await fetch('/my-backend/spreadspace/iframe-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ surface, loanId }),
});
const { signed_url } = await res.json();
return signed_url;
},
chrome: { exit: { href: '/applications/42' } },
});
</script>

The element you mount into is the frame’s box. The default heightMode is 'fill': the iframe takes the element’s full height and the frame scrolls its own content, so give the element a real height, such as 100vh or a flex track. The loader sets position: relative on the element when it has no positioning of its own, so the controls can sit over the frame.

Your backend endpoint mints the handle and returns its signed_url. It reads the embed-minting key, the one that holds embed:write, from SPREADSPACE_EMBED_API_KEY. In TypeScript, with @spreadspace/sdk:

import { SpreadSpace } from '@spreadspace/sdk';
const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });
// The page sends { surface, loanId }. loan_id is the name on the SpreadSpace wire.
app.post('/my-backend/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'],
external_user_id: req.session.userId,
});
res.json({ signed_url: handle.signed_url });
});

Mint from your server has the same mint in Python, C# and cURL, and shows how to name write scopes so an analyst can finalize or upload.

external_user_id keeps the viewing user’s boards, view settings, and memo templates across sessions. Derive it from your backend’s own session rather than the browser request, so one user cannot open another’s saved state. The Mint from your server page covers the field’s contract.

Options

OptionTypeNotes
surface'spreading' | 'documents'Which surface to render.
loanIdstringLoan the surface is scoped to. Must match the loan the handle was minted for.
getHandle(ctx) => Promise<string> | stringMints a fresh single-use handle. Called for the initial load and every refresh; ctx.reason is set on a refresh.
chromefalse | { back?, exit? }The controls drawn over the frame. Defaults to { back: true }. See Chrome.
theme'light' | 'dark' | 'system''system' (default) follows the page’s prefers-color-scheme and tracks changes. Pushed to the frame when it loads and again when it reports ready.
heightMode'grow' | 'fill'fill (default) makes the iframe height: 100% of its element and the frame scrolls internally; grow sizes the iframe to the frame’s reported content height, so your page scrolls.
initialHeightstring | numbergrow only: the height before the frame’s first report. Defaults to '480px'.
embedOriginstringOverride the embed app origin. Only needed when getHandle returns a bare handle id instead of the signed_url.
styleNoncestringNonce copied onto the injected <style> element, for a style-src policy that admits nonced styles.
onReady(payload) => voidFired once the frame shell has mounted.
onError(payload) => voidThe mint failed or the frame reported an error; fatal distinguishes severity.
onUiState(payload) => voidFrame chrome state: modalOpen (a blocking overlay is up in the frame) and canNavigateBack. Sent once after ready and on every change. The loader’s own controls already honor it; use this for any further host chrome.
onNav(payload) => voidCalled when the frame posts a nav message. The hosted surfaces post none: upload runs inside the frame when the session holds documents:write.
onDiagnostic(message, detail?) => voidDropped or rejected protocol messages.
title / className / sandboxIframe element passthroughs. title defaults to 'SpreadSpace'.

Chrome

The loader draws two controls over the frame’s top rail, in the seats the frame reserves for them: a round back arrow at the left edge, which walks the frame’s own navigation back one step, and an Exit control at the right edge, the one control that leaves SpreadSpace. Both follow the theme.

  • chrome: false draws nothing.
  • chrome.back (default true) draws the back arrow.
  • chrome.exit is drawn only when you give it somewhere to go. href renders a link, onExit renders a button, and both together render a link that also calls onExit. label replaces the visible text Exit; keep it to one short word, because the frame reserves a fixed seat for it.

The back arrow is disabled until the frame reports ready, while the frame reports a modal open, and whenever the frame reports nowhere to go back to. Exit is disabled while the frame reports a modal open. A disabled control is dimmed, inert and marked aria-disabled="true".

The controls are sized in your page’s rem, and the frame reserves matching insets on its rail, so they never overlap the frame’s own controls.

Instance

mount() returns an instance:

MemberNotes
iframeThe underlying <iframe> element.
status'minting', 'loading', 'ready' or 'error'.
uiThe frame’s last { modalOpen, canNavigateBack } report.
navigateBack()Walks the frame’s own navigation back one step. The frame does nothing when it has nowhere to go.
reload()Mints a fresh handle and reloads the frame, for example after the user re-authenticates.
setTheme(theme)Applies a theme to the controls and pushes it to the frame.
destroy()Removes the iframe and the controls, and drops every listener, timer and pending refresh.

Refresh

When the frame’s token nears expiry or is rejected, the frame asks your page for a fresh handle. The loader calls getHandle again with a reason of expiring or unauthorized and answers the frame with the handle from the URL you return; the frame is not reloaded. Return a fresh signed_url on every call, because a handle is single-use.

The security model

Your page never holds a SpreadSpace token. You give the loader a getHandle function that calls your backend, which mints a short-lived, single-use handle and returns a signed_url. The loader loads that URL as the iframe source. Inside the iframe, which is same-origin to SpreadSpace, the handle is exchanged for the real embed token, which stays inside the iframe. When the token nears expiry or is rejected, the iframe asks your page over postMessage for a fresh handle, the loader calls getHandle again, and the iframe re-exchanges it. A handle is worthless off SpreadSpace’s origin and expires in seconds; the token never crosses the iframe boundary into your page.

The minted token holds the scopes the mint names and is locked to the single loan_id the handle was minted for. Read scopes are granted by mint authority; documents:write, extractions:write, extractions:edit and spreads:write are granted only when the mint names them and only if the minting key holds them. Inspect the returned scopes if your integration depends on it.

Scopes

The API key your backend uses to mint handles needs the embed:write scope. Issue a key from the dashboard at Settings → Live API (or Settings → Sandbox API for a test key) and grant it embed:write. The samples read that key from SPREADSPACE_EMBED_API_KEY. The browser-facing token minted from that handle never carries embed:write, so a leaked browser token cannot mint further tokens or widen its own scope.

Advanced: the protocol module

The typed message envelope and both messengers are exported, also as @spreadspace/embed/postmessage, for integrators who want to drive a hand-rolled iframe:

import { createHostMessenger, type NavPayload } from '@spreadspace/embed/postmessage';

createHostMessenger verifies the frame origin, pins inbound messages to your iframe’s contentWindow, correlates each token-refresh request and response by id, and replies with a handle. The response payload is type-branded so that including a token field is a compile error.