> This page is for Get Started.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# Embed integration guide

> **Note**
>
> 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](/get-started/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`:

```bash
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](/embed/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](#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`.

**`TypeScript`**

```ts title="TypeScript"
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 });
});
```

**`Python`**

```python title="Python"
import os

import fastapi
from spreadspace import SpreadSpace

client = SpreadSpace(api_key=os.environ["SPREADSPACE_EMBED_API_KEY"])
app = fastapi.FastAPI()

# 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")
async def iframe_url(request: fastapi.Request) -> dict[str, str]:
    body = await request.json()
    handle = client.embed.sessions.create_iframe_url(
        loan_id=body["loanId"],
        surface=body["surface"],
        scopes=["documents:read", "extractions:read", "spreads:read", "spreads:write"],
        external_user_id=request.state.user_id,  # set by your auth middleware
    )
    return {"signed_url": handle["signed_url"]}
```

**`C#`**

```csharp title="C#"
using SpreadSpace;

using var client = new SpreadSpaceClient(Environment.GetEnvironmentVariable("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.MapPost("/spreadspace/iframe-url", async (IframeUrlRequest body, HttpContext http, CancellationToken ct) =>
{
    var handle = await client.Embed.Sessions.CreateIframeUrlAsync(
        body.LoanId, body.Surface,
        scopes: new[] { "documents:read", "extractions:read", "spreads:read", "spreads:write" },
        externalUserId: http.User.Identity?.Name ?? throw new UnauthorizedAccessException("Sign-in required."),
        ct: ct);
    return Results.Ok(new { signed_url = handle.SignedUrl });
});

record IframeUrlRequest(string Surface, string LoanId);
```

**`cURL`**

```bash title="cURL"
API="https://api.spreadspace.app"
AUTH="Authorization: Bearer $SPREADSPACE_EMBED_API_KEY"

curl -s -X POST "$API/api/embed/iframe-urls" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"loan_id\":\"$SPREADSPACE_LOAN_ID\",\"surface\":\"spreading\",\"scopes\":[\"documents:read\",\"extractions:read\",\"spreads:read\",\"spreads:write\"],\"external_user_id\":\"usr_1234\"}" \
  | jq '{signed_url, handle_id, expires_at, scopes}'
```

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

```json
{
  "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](/embed/loader).

```bash
npm i @spreadspace/react
```

**`React`**

```tsx title="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](/embed/react) 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](/get-started/walkthrough): 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](/api/webhooks#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](/api/api-reference/embed/create-intake) and
[Confirm intake](/api/api-reference/embed/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.

**`TypeScript`**

```ts title="TypeScript"
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);
```

**`Python`**

```python title="Python"
import os

from spreadspace import SpreadSpace

client = SpreadSpace(api_key=os.environ["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.
job = client.intake.submit(
    "statement.pdf",
    client_reference="application-123",
    operation_key="application-123-document-1",  # persist before the first attempt
)
print(job.id, job.loan_id, job.borrower_id)  # persist all three

# Mint against the loan intake provisioned, as step 2 does.
handle = client.embed.sessions.create_iframe_url(
    loan_id=job.loan_id,
    surface="spreading",
    scopes=["documents:read", "extractions:read", "spreads:read", "spreads:write"],
    external_user_id="usr_1234",  # the analyst's id from your own session
)
print(handle["signed_url"])
```

**`C#`**

```csharp title="C#"
using SpreadSpace;

using var client = new SpreadSpaceClient(Environment.GetEnvironmentVariable("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.
var job = await client.Intake.SubmitAsync(
    "/path/to/statement.pdf",
    clientReference: "application-123",
    operationKey: "application-123-document-1"); // persist before the first attempt
Console.WriteLine($"{job.Id} {job.LoanId} {job.BorrowerId}"); // persist all three

// Mint against the loan intake provisioned, as step 2 does.
var handle = await client.Embed.Sessions.CreateIframeUrlAsync(
    job.LoanId, "spreading",
    scopes: new[] { "documents:read", "extractions:read", "spreads:read", "spreads:write" },
    externalUserId: "usr_1234"); // the analyst's id from your own session
Console.WriteLine(handle.SignedUrl);
```

**`cURL`**

```bash title="cURL"
API="https://api.spreadspace.app"
AUTH="Authorization: Bearer $SPREADSPACE_EMBED_API_KEY"

# 1. Create the submission: the loan and borrower for application-123, and the upload URL.
CREATED=$(curl -s -X POST "$API/api/intake" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"file_name":"statement.pdf","content_type":"application/pdf","client_reference":"application-123"}')
JOB_ID=$(echo "$CREATED" | jq -r .job_id)
LOAN_ID=$(echo "$CREATED" | jq -r .loan_id)
UPLOAD_URL=$(echo "$CREATED" | jq -r .upload_url)

# 2. PUT the bytes to the upload URL, with the content type from the create and no API headers.
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: application/pdf" --data-binary @statement.pdf

# 3. Confirm: processing starts and the job goes PROCESSING.
curl -s -X POST "$API/api/intake/$JOB_ID/confirm" \
  -H "$AUTH" -H "Content-Type: application/json" -d '{}'

# Mint against the loan intake provisioned, as step 2 does.
curl -s -X POST "$API/api/embed/iframe-urls" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"loan_id\":\"$LOAN_ID\",\"surface\":\"spreading\",\"scopes\":[\"documents:read\",\"extractions:read\",\"spreads:read\",\"spreads:write\"],\"external_user_id\":\"usr_1234\"}" \
  | jq '{signed_url, handle_id, expires_at, scopes}'
```

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](/api/api-reference/borrowers/complete-intake).

## Up next

* [Embed](/embed/overview): the packages, the security model and the
  hand-off, on one page.
* [React embed SDK](/embed/react): every prop, the ref, and the protocol
  module for a hand-rolled frame.
* [Mint from your server](/embed/server): both mints field by field in
  TypeScript, Python, C# and cURL, and the webhook verifier.
* [API integration guide](/get-started/walkthrough): the receiver, the snapshot,
  reopen and replay.