> This page is for Embed.

> 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.

# Mint from your server

> Mint iframe handles and embed sessions from your backend with the SpreadSpace SDKs or a direct API call, revoke a session, and verify webhook signatures.

Your server mints the credential that authorizes an embed, and the API key
that mints it never leaves your server. The SpreadSpace SDKs for TypeScript,
Python and C# carry both mints, the session revoke and the webhook receiver.
A backend in any other language calls the same endpoints directly, shown here
as cURL.

Every mint on this page reads the embed-minting key, the key that holds
`embed:write`, from `SPREADSPACE_EMBED_API_KEY`. The Backend key from the
[Quick start](/get-started/quick-start) stays in `SPREADSPACE_API_KEY`, and
the receiver under [Verify webhooks](#verify-webhooks) is the one sample on
this page that uses it.

Server-side use only. An API key must never appear in browser code: the
browser receives a `signed_url` and nothing else.

## Install

**`TypeScript`**

```bash title="TypeScript"
npm i @spreadspace/sdk@^3.0.0
```

**`Python`**

```bash title="Python"
pip install "spreadspace>=3.0.0,<4"
```

**`C#`**

```bash title="C#"
dotnet add package SpreadSpace --version 3.0.0
```

## Construct the client

**`TypeScript`**

```ts title="TypeScript"
import { SpreadSpace } from '@spreadspace/sdk';

const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_EMBED_API_KEY! });
```

**`Python`**

```python title="Python"
import os

from spreadspace import SpreadSpace

client = SpreadSpace(api_key=os.environ["SPREADSPACE_EMBED_API_KEY"])
```

**`C#`**

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

using var client = new SpreadSpaceClient(Environment.GetEnvironmentVariable("SPREADSPACE_EMBED_API_KEY"));
```

**`cURL`**

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

Pass the embed key explicitly. A client that is given no key reads
`SPREADSPACE_API_KEY`. A key from the Backend preset does not hold
`embed:write`, so a mint with it answers `403 insufficient_scope`. In
TypeScript and C#, an unset `SPREADSPACE_EMBED_API_KEY` is that case.

## Mint an iframe handle

`POST /api/embed/iframe-urls` mints a single-use handle for one loan and one
surface and returns it inside a `signed_url`. Return that URL to your page:
`<SpreadSpaceEmbed>` from [`@spreadspace/react`](/embed/react) loads it, and
so does a plain iframe `src`. The handle is exchanged for the session token
inside the frame, so the token never reaches your page. The `loan_id` is the
loan intake provisioned for the application, as
[Board loans from your system](/get-started/embed-walkthrough#board-loans-from-your-system)
shows, or one you created.

| Field                        | Required | Meaning                                                                           |
| ---------------------------- | -------- | --------------------------------------------------------------------------------- |
| `loan_id`                    | Yes      | The loan the session is locked to.                                                |
| `surface`                    | Yes      | `spreading` or `documents`. Any other value is rejected.                          |
| `scopes`                     |          | The scopes the session may use. Name them on every mint. See [Scopes](#scopes).   |
| `expires_in_seconds`         |          | How long the handle stays exchangeable. Default 60, at most 300.                  |
| `session_expires_in_seconds` |          | How long the session token lives after the exchange. Default 3600, at most 86400. |
| `external_user_id`           |          | Your own stable id for the viewing user. See [Per-user state](#per-user-state).   |
| `display_name`               |          | A label for that id. Requires `external_user_id`.                                 |

**`TypeScript`**

```ts title="TypeScript"
const handle = await client.embed.iframeUrls.create({
  loan_id: '6b1d9e2f4a7c3b5d8e0f2a1c',
  surface: 'spreading',
  scopes: ['documents:read', 'extractions:read', 'spreads:read'],
  external_user_id: 'usr_1234', // your app's id for the viewing user
});

// handle.signed_url -> return it to the page unchanged.
// handle.handle_id  -> record it in your own audit log.
```

**`Python`**

```python title="Python"
handle = client.embed.sessions.create_iframe_url(
    loan_id="6b1d9e2f4a7c3b5d8e0f2a1c",
    surface="spreading",
    scopes=["documents:read", "extractions:read", "spreads:read"],
    external_user_id="usr_1234",  # your app's id for the viewing user
)

# handle["signed_url"] -> return it to the page unchanged.
# handle["handle_id"]  -> record it in your own audit log.
```

**`C#`**

```csharp title="C#"
var handle = await client.Embed.Sessions.CreateIframeUrlAsync(
    "6b1d9e2f4a7c3b5d8e0f2a1c",
    "spreading",
    scopes: new[] { "documents:read", "extractions:read", "spreads:read" },
    externalUserId: "usr_1234"); // your app's id for the viewing user

// handle.SignedUrl -> return it to the page unchanged.
// handle.HandleId  -> record it in your own audit log.
```

**`cURL`**

```bash title="cURL"
curl -s -X POST "$API/api/embed/iframe-urls" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"loan_id":"6b1d9e2f4a7c3b5d8e0f2a1c","surface":"spreading","scopes":["documents:read","extractions:read","spreads:read"],"external_user_id":"usr_1234"}'
```

The response:

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

| Field          | Meaning                                                                                                     |
| -------------- | ----------------------------------------------------------------------------------------------------------- |
| `signed_url`   | The URL the frame loads. Hand it to the page unchanged; it may carry more query parameters than shown here. |
| `handle_id`    | The handle the URL carries, returned separately so you can record it without parsing the URL.               |
| `expires_at`   | When the handle stops being exchangeable. It is not when the session ends.                                  |
| `surface`      | The surface the handle was minted for.                                                                      |
| `loan_id`      | The loan the session is locked to.                                                                          |
| `borrower_id`  | The borrower that loan belongs to.                                                                          |
| `scopes`       | The scopes the session will hold.                                                                           |
| `display_name` | The label as stored. Present only when the mint carried one.                                                |

Each call mints a fresh handle, and a handle is exchanged once: a second load
of the same URL fails inside the frame. Mint at the moment the page asks, and
never cache or reuse a `signed_url`.

## Mint an embed session

`POST /api/embed/sessions` returns the token itself, `embed_token`, a
loan-scoped bearer for a client of your own that calls the API without the
iframe. The token is returned once, and no endpoint retrieves it again. The
request takes `loan_id` (required), `scopes`, `expires_in_seconds` (default
3600, at most 86400), `external_user_id` and `display_name`.

**`TypeScript`**

```ts title="TypeScript"
const session = await client.embed.sessions.create({
  loan_id: '6b1d9e2f4a7c3b5d8e0f2a1c',
  scopes: ['documents:read', 'extractions:read', 'spreads:read'],
  external_user_id: 'usr_1234',
});

// session.embed_token is the bearer, scoped to this one loan.
// session.session_id is the id a revoke takes.
```

**`Python`**

```python title="Python"
session = client.embed.sessions.create(
    loan_id="6b1d9e2f4a7c3b5d8e0f2a1c",
    scopes=["documents:read", "extractions:read", "spreads:read"],
    external_user_id="usr_1234",
)

# session["embed_token"] is the bearer, scoped to this one loan.
# session["session_id"] is the id a revoke takes.
```

**`C#`**

```csharp title="C#"
var session = await client.Embed.Sessions.CreateAsync(
    "6b1d9e2f4a7c3b5d8e0f2a1c",
    scopes: new[] { "documents:read", "extractions:read", "spreads:read" },
    externalUserId: "usr_1234");

// session.EmbedToken is the bearer, scoped to this one loan.
// session.SessionId is the id a revoke takes.
```

**`cURL`**

```bash title="cURL"
curl -s -X POST "$API/api/embed/sessions" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"loan_id":"6b1d9e2f4a7c3b5d8e0f2a1c","scopes":["documents:read","extractions:read","spreads:read"],"external_user_id":"usr_1234"}'
```

The response carries `embed_token`, `session_id`, `expires_at` (when the token
expires), `loan_id`, `borrower_id`, `scopes` and `persistence`, plus
`external_user_id` and `display_name` when the mint carried them.

## 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 is granted only
when the request names it and only if the minting 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`.

A workspace without an active plan embeds Document Review alone: its test key's
mint succeeds, but the session, the handle and the exchanged token carry
`documents:read` and `extractions:read` whatever the mint names, and the
response's `scopes` lists those two. The `spreading` surface and every write
need a plan on the workspace.

> **Note**
>
> **What the sandbox includes.** Every API operation on sample data at no
> charge, and an embed on a test key that opens Document Review. The
> [sandbox finalize trigger](/api/sample-documents#finalizing-the-sample-spread)
> finalizes and reopens the sample spread with the real `spread.finalized` and
> `spread.reopened` deliveries, so the finalize a plan's frame would produce,
> the trigger produces on a test key.

| Write scope         | What it adds inside the frame                                        |
| ------------------- | -------------------------------------------------------------------- |
| `spreads:write`     | Saving, finalizing and reopening spreads.                            |
| `extractions:write` | The analyst's extraction edits, and everything `spreads:write` adds. |
| `extractions:edit`  | Editing an extracted figure or caption in the row editor.            |
| `documents:write`   | Uploading documents.                                                 |

To give an analyst write access, name the writes beside the reads. This body
mints a session that may finalize spreads and upload documents, from a key
that holds both writes, as the dashboard's **Embed** preset does:

```json
{
  "loan_id": "6b1d9e2f4a7c3b5d8e0f2a1c",
  "surface": "spreading",
  "scopes": ["documents:read", "extractions:read", "spreads:read", "spreads:write", "documents:write"],
  "external_user_id": "usr_1234"
}
```

In the SDKs the same list goes in `scopes`. A session with none of the writes
renders read-only and offers no Finalize. The token never carries
`embed:write`, so a leaked token cannot mint further sessions or widen its own
scope, and every scope it holds stays bound to the one loan.

## Lifetimes

| Clock                        | Set by                                          | Default | Maximum |
| ---------------------------- | ----------------------------------------------- | ------- | ------- |
| Handle                       | `expires_in_seconds` on the handle mint         | 60      | 300     |
| Token from a handle exchange | `session_expires_in_seconds` on the handle mint | 3600    | 86400   |
| Token from a session mint    | `expires_in_seconds` on the session mint        | 3600    | 86400   |

A value above its maximum is rejected with `400 invalid_request`. The handle
dies at its first exchange, and the token's lifetime runs from that exchange.
When the token nears expiry, or the API rejects it, the frame asks the host
page for a fresh handle. `<SpreadSpaceEmbed>` answers by calling your mint
endpoint again, so your server sees one more mint. The
[Embed integration guide](/get-started/embed-walkthrough) walks through the
refresh.

## Per-user state

Both mints accept an optional `external_user_id`: an opaque, stable identifier
for the end user you are embedding for, up to 256 characters after trimming.
Use a durable id from your own system rather than an email address, which can
change. Derive it from your backend's own session, never from the browser
request, so one user cannot open another's saved state. Supply it and the
embed keeps that user's state on the server, keyed to your organization and
the id you send, so ids only need to be unique within your own system. Saved
boards and view settings, and the memo template library, follow the user
across sessions and devices. So does the SpreadSheet tab: the first open of a
user's SpreadSheet derives it and keeps the result for that user, and their
next open loads it instead of deriving it again. Without `external_user_id`
each session starts from defaults, saves nothing, and every open derives
afresh.

The session response echoes `external_user_id` back, trimmed. On the iframe
path the id is stored on the handle and copied onto the session at exchange,
so both routes yield the same identity-carrying session. Send the same id for
the same person on every mint. A blank `external_user_id` is rejected.

`display_name` is optional, up to 120 characters. It is the name the workspace
shows for that user on the spreads they save and finalize, on their comments
and in live presence. It is echoed back, never written to a log and never
placed in the token. It requires `external_user_id` in the same request, and
sending one alone is rejected.

Every session response also carries `persistence`, which says which mode you
got: `user` when the mint included an `external_user_id`, `session` when it
did not. A `session` value means the embed renders and works normally but
saves nothing for the viewer. Assert on it in your integration tests, so a
dropped `external_user_id` fails your build instead of quietly discarding a
day of your analyst's work. The handle response carries no `persistence`; to
assert it, mint a session.

**`TypeScript`**

```ts title="TypeScript"
if (session.persistence !== 'user') {
  throw new Error('embed session will not persist end-user state');
}
```

**`Python`**

```python title="Python"
if session["persistence"] != "user":
    raise RuntimeError("embed session will not persist end-user state")
```

**`C#`**

```csharp title="C#"
if (session.Persistence != "user")
    throw new InvalidOperationException("embed session will not persist end-user state");
```

## Revoke a session

`DELETE /api/embed/sessions/{session_id}` revokes a session minted with
`POST /api/embed/sessions`. The key that minted the session revokes it, the
call needs `embed:write`, and it answers `204`. The revoked token is refused
from then on.

**`TypeScript`**

```ts title="TypeScript"
await client.embed.sessions.revoke(session.session_id);
```

**`Python`**

```python title="Python"
client.embed.sessions.revoke(session["session_id"])
```

**`C#`**

```csharp title="C#"
await client.Embed.Sessions.RevokeAsync(session.SessionId);
```

**`cURL`**

```bash title="cURL"
SESSION_ID="<the session_id from the mint>"
curl -s -X DELETE "$API/api/embed/sessions/$SESSION_ID" -H "$AUTH" -o /dev/null -w "%{http_code}\n"
```

A session created by a handle exchange has no id your server ever sees, so
there is nothing to revoke per session on the iframe path. The control point
there 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.

## Verify webhooks

When the analyst finalizes inside the frame, `spread.finalized` reaches your
backend as a [webhook](/api/webhooks). The SDK's receiver verifies the
`SpreadSpace-Signature` header, drops duplicates, reads the snapshot the event
names, and hands back the status and body to answer with. It reads that
snapshot with `spreads:read`, which the Embed preset does not hold, so build it
from a client that uses the Backend key in `SPREADSPACE_API_KEY`.

The signature covers the exact bytes received. Give the receiver the raw
request body, never a parsed and re-serialized one. In Express,
`express.raw({ type: 'application/json' })` on the route makes `req.body` those
bytes; Express does not set a raw body on the request by itself.

**`TypeScript`**

```ts title="TypeScript"
const backend = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });

const receiver = backend.webhooks.receiver({
  secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
  onSpreadFinalized: async ({ event, attributes }) => {
    await db.saveFinalizedFigures(event.data.loan_id, attributes);
  },
  onSpreadReopened: async ({ event }) => {
    await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
  },
});

// express.raw leaves req.body as the bytes that were signed.
app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
  const receipt = await receiver.handle({ rawBody: req.body, headers: req.headers });
  res.status(receipt.status).send(receipt.body);
});
```

**`Python`**

```python title="Python"
backend = SpreadSpace(api_key=os.environ["SPREADSPACE_API_KEY"])

receiver = backend.webhooks.receiver(
    secret=os.environ["SPREADSPACE_WEBHOOK_SECRET"],
    on_spread_finalized=lambda event, attributes: save_finalized_figures(event.data["loan_id"], attributes),
    on_spread_reopened=lambda event: mark_finalized_figures_stale(event.data["snapshot_id"], event.data["reopened_at"]),
)

from fastapi import Request, Response

# FastAPI: request.body() is the bytes that were signed.
@app.post("/webhooks/spreadspace")
async def spreadspace_webhook(request: Request):
    body = await request.body()
    receipt = receiver.handle(body, request.headers)
    return Response(receipt.body, status_code=receipt.status)
```

**`C#`**

```csharp title="C#"
using var backend = new SpreadSpaceClient(Environment.GetEnvironmentVariable("SPREADSPACE_API_KEY"));

var receiver = backend.Webhooks.CreateReceiver(new WebhookReceiverOptions {
    Secrets = new[] { Environment.GetEnvironmentVariable("SPREADSPACE_WEBHOOK_SECRET")! },
    OnSpreadFinalized = async ctx => await SaveFinalizedFiguresAsync(ctx.Payload.LoanId, ctx.Attributes),
    OnSpreadReopened = async ctx => await MarkFinalizedFiguresStaleAsync(ctx.Payload.SnapshotId, ctx.Payload.ReopenedAt),
});

// ASP.NET minimal API: read the body as the text that was signed.
app.MapPost("/webhooks/spreadspace", async (HttpRequest request, CancellationToken ct) =>
{
    var raw = await new StreamReader(request.Body).ReadToEndAsync(ct);
    var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
    var receipt = await receiver.HandleAsync(raw, headers, ct);
    return Results.Content(receipt.Body, "application/json", statusCode: receipt.StatusCode);
});
```

To verify without the receiver, call `verifyAndParseWebhook` (TypeScript),
`verify_and_parse_webhook` (Python) or `WebhooksResource.VerifyAndParse` (C#)
with the same raw body, the header value and the endpoint's signing secret.
Each raises a signature error when verification fails. The
[Webhooks](/api/webhooks) page has the signature scheme, the receiver's
options and secret rotation.

## Errors

| Status and type               | When                                                                                                                                                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 embed_scope_not_allowed` | The mint named a scope that is not mintable for an embed session.                                                                                                                                                   |
| `400 embed_scope_exceeds_key` | The mint named a write scope the minting key does not hold.                                                                                                                                                         |
| `400 invalid_request`         | A lifetime is out of range, `surface` is not recognized, `external_user_id` is blank, or `display_name` came without `external_user_id`.                                                                            |
| `402 billing_required`        | The workspace has no active subscription.                                                                                                                                                                           |
| `403 insufficient_scope`      | The key does not hold `embed:write`.                                                                                                                                                                                |
| `403 embed_not_in_plan`       | The workspace's plan does not include the embedded review UI. Live keys only: a test key never answers it; without an active plan on the workspace its token carries Document Review alone (see [Scopes](#scopes)). |
| `404`                         | The loan does not exist in the key's workspace.                                                                                                                                                                     |

Each SDK raises a typed error that carries the wire `type` and the request id.
Match on the class, then read the type.

**`TypeScript`**

```ts title="TypeScript"
import { InvalidRequestError, PermissionError } from '@spreadspace/sdk';

try {
  await client.embed.iframeUrls.create({
    loan_id: '6b1d9e2f4a7c3b5d8e0f2a1c',
    surface: 'spreading',
    scopes: ['documents:read', 'extractions:read', 'spreads:read', 'documents:write'],
  });
} catch (err) {
  if (err instanceof InvalidRequestError && err.type === 'embed_scope_exceeds_key') {
    // The key does not hold documents:write.
  } else if (err instanceof PermissionError) {
    // err.type is insufficient_scope or embed_not_in_plan.
  }
}
```

**`Python`**

```python title="Python"
from spreadspace import BadRequestError, PermissionDeniedError

try:
    client.embed.sessions.create_iframe_url(
        loan_id="6b1d9e2f4a7c3b5d8e0f2a1c",
        surface="spreading",
        scopes=["documents:read", "extractions:read", "spreads:read", "documents:write"],
    )
except BadRequestError as e:
    if e.type == "embed_scope_exceeds_key":
        ...  # The key does not hold documents:write.
except PermissionDeniedError as e:
    print(e.type)  # insufficient_scope or embed_not_in_plan
```

**`C#`**

```csharp title="C#"
try
{
    await client.Embed.Sessions.CreateIframeUrlAsync(
        "6b1d9e2f4a7c3b5d8e0f2a1c",
        "spreading",
        scopes: new[] { "documents:read", "extractions:read", "spreads:read", "documents:write" });
}
catch (BadRequestException ex) when (ex.ErrorType == "embed_scope_exceeds_key")
{
    // The key does not hold documents:write.
}
catch (PermissionDeniedException ex)
{
    Console.WriteLine(ex.ErrorType); // insufficient_scope or embed_not_in_plan
}
```

The full error vocabulary is on the [Errors](/api/errors) page.