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

# Agent-assisted integration

## Hand it to your agent

#### Save the skill

Copy the `SKILL.md` block below to
`.claude/skills/spreadspace-integration/SKILL.md` (Claude Code), or paste it
into `AGENTS.md` or your rules file (Cursor, Codex, and similar tools).

#### Add the standing rules

Append the `CLAUDE.md` block to your own `CLAUDE.md` / `AGENTS.md`, so the
rules hold on every session rather than only the one that writes the receiver.

#### Put an API key in your environment

A key from **Settings → Live API** with `webhooks:read`, `webhooks:write`
and `spreads:read`, as `SPREADSPACE_API_KEY`. A test-mode key (`ss_test_…`)
reaches the sandbox, where extractions are canned. That is fine for the
plumbing, but a finalize there has no figures worth freezing.

#### Give it the prompt

```
Integrate SpreadSpace: receive spread.finalized, persist the frozen figures by
snapshot id against our deal, show them on the deal screen, handle
spread.reopened. The key is SPREADSPACE_API_KEY. Follow the
spreadspace-integration skill.
```

Your agent can also read this page directly at
[https://docs.spreadspace.app/get-started/agent-assisted-integration.md](https://docs.spreadspace.app/get-started/agent-assisted-integration.md).
Every page here has a `.md` twin, and the index is at
[/llms.txt](https://docs.spreadspace.app/llms.txt).

### SKILL.md

Save this as `.claude/skills/spreadspace-integration/SKILL.md`, or paste it into your agent's rules file.

````markdown title="SKILL.md"
---
name: spreadspace-integration
description: Use when integrating SpreadSpace: receiving spread.finalized, verifying the webhook, and getting the finalized figures into our own database and onto our deal screen.
---

# SpreadSpace integration

## What you are building

An analyst presses **Finalize** inside the embedded SpreadSpace spread. SpreadSpace
freezes the figures as a `final` snapshot and fires `spread.finalized`. The event
is a pointer, not the numbers. Your receiver verifies the signature over the raw
bytes, reads the snapshot the event names **by id**, and upserts it against your
own loan or deal record. Your deal screen then renders those frozen figures from
your own table, with no vendor call at read time. When the analyst reopens the
spread, `spread.reopened` arrives naming the `final` that was current, and you
mark that copy "under revision" until the next finalize lands.

## Before you start

- **An API key** from **Settings → Live API** with the `webhooks:read`,
  `webhooks:write` and `spreads:read` scopes, used against your own workspace
  (a trial workspace's seeded loans qualify). Put it in `SPREADSPACE_API_KEY`.
  A test-mode key (`ss_test_…`) reaches the sandbox, where extractions are
  canned. That is right for exercising the plumbing, but a finalize there has
  no figures worth freezing.
- **A public `https` URL** for the receiver. Private and internal addresses are
  refused at registration and re-checked at connect time, so in development put a
  tunnel (`cloudflared`, `ngrok`) in front of your machine and register the
  tunnel's URL.
- **The SDK:** `npm i @spreadspace/sdk`, or `pip install spreadspace`, or
  `dotnet add package SpreadSpace`.
- **The loan mapping.** You already know which SpreadSpace `loan_id` belongs to
  which of your deals, because you created the loan. Store its id on your deal
  record and join on it. Do not try to match by borrower name.

## The table

One row per snapshot. `snapshot_id` is the idempotency key; put a UNIQUE index on
it, because an in-memory dedupe set is a cache and this index is the durable floor.

| Column | Type | Notes |
|---|---|---|
| `snapshot_id` | text, **unique** | The idempotency key. Upsert on it. |
| `loan_id` | text | The SpreadSpace loan. Your join to your own deal. |
| `borrower_id` | text | As delivered. |
| `spread_board_id` | text, nullable | The saved spread the figures were taken from. |
| `version` | integer | Per-loan version, counting up across every status. |
| `status` | text | `final` for everything you persist here. |
| `finalized_at` | timestamptz | When the analyst finalized. |
| `finalized_by_external_user_id` | text, nullable | Your own user id, when the finalize happened inside your embed. |
| `reopened_at` | timestamptz, nullable | Set by `spread.reopened`. Null while the figures are current. |
| `payload` | json | The whole `attributes.v1` payload, stored as delivered. |
| `manifest` | json | The whole manifest, stored as delivered. |
| `received_at` | timestamptz | Your own clock. |

Store `payload` and `manifest` **whole**. Bind by key name, never by position.
JSON key order on the wire is not stable. Every attribute and ratio key is always
present; `null` means "no figure for that period", not "missing". Money values are
strings with exactly two decimals and ratios are strings with at most four, so no
float can round them in transit. Parse them with a decimal type, never `Number`.
See
[Conventions](https://docs.spreadspace.app/api/conventions).

## Steps

### 1. Register the endpoint, idempotently

List first, PATCH if your URL is already there, create only if it is not. A second
registration of the same URL makes a **second endpoint** with its own secret, and
deliveries from the endpoint you forgot about will fail verification forever.

The subscription is whatever the endpoint was created or last updated with. An
endpoint registered before an event existed never receives it until you PATCH.

The `whsec_…` signing secret comes back exactly once, on create and on rotate.
Put it in your secret store. Never log it, never put it in an error message.

```ts
import { SpreadSpace } from '@spreadspace/sdk';

declare const secrets: { put(name: string, value: string): Promise<void> };

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

const ENDPOINT_URL = 'https://example.com/webhooks/spreadspace';
const EVENTS = ['spread.finalized', 'spread.reopened'];

export async function ensureEndpoint(): Promise<string> {
  for await (const endpoint of client.webhooks.list<{ id: string; url: string }>()) {
    if (endpoint.url !== ENDPOINT_URL) continue;
    // Already registered: change the subscription in place, keep the secret.
    await client.webhooks.update(endpoint.id, { subscribed_events: EVENTS });
    return endpoint.id;
  }

  const created = await client.webhooks.create<{ id: string; signing_secret: string }>({
    url: ENDPOINT_URL,
    description: 'Finalized-figures receiver',
    subscribed_events: EVENTS,
  });
  // Returned once. Store it; do not log it.
  await secrets.put('spreadspace_webhook_secret', created.signing_secret);
  return created.id;
}
```

Python: `client.webhooks.list()` / `.create(...)` / `.update(endpoint_id, ...)`.
C#: `client.Webhooks.List()` / `CreateAsync(...)` / `UpdateAsync(...)`.

### 2. The receiver

Build the receiver **once per process**, not per request: the default dedupe store
lives on it, so a receiver constructed inside the request handler can never drop a
duplicate. If your secret lives in a database rather than the environment, hoist a
module-level dedupe store and hand it to every receiver you build.

Feed it the **raw request body bytes** and the request headers. A framework that
JSON-parses the body first breaks verification permanently, because the signature
covers the exact bytes on the wire. Then return the receipt's status and body
verbatim:

| Receipt | Meaning |
|---|---|
| `200 {"received":true}` | Handled. |
| `200 {"received":true,"duplicate":true}` | An event id you already processed. Ordinary, not an error. |
| `400 {"error":"invalid_signature"}` | Bad, missing or stale signature. No handler ran. |
| `500 {"error":"handler_failed"}` | Your handler threw. SpreadSpace retries (12 attempts over 24 hours) and the event id stays unrecorded so the retry re-runs it. |

Each attempt gets five seconds. Answer fast; if you have heavy work to do, durably
record the event first, acknowledge, and do the rest out of band.

```ts
import { SpreadSpace } from '@spreadspace/sdk';
import type { AttributeSnapshot } from '@spreadspace/sdk';

declare const db: {
  upsertSnapshot(loanId: string, snapshot: AttributeSnapshot): Promise<void>;
  markReopened(snapshotId: string, reopenedAt: string): Promise<void>;
};

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

// Once per process.
export const receiver = client.webhooks.receiver({
  secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
  onSpreadFinalized: async ({ event, attributes }) => {
    if (!attributes) return;
    await db.upsertSnapshot(event.data.loan_id, attributes);
  },
  onSpreadReopened: async ({ event }) => {
    await db.markReopened(event.data.snapshot_id, event.data.reopened_at);
  },
});
```

The route itself is four lines and framework-shaped. Raw bytes in, receipt out:

```ts continues
export async function handleWebhook(rawBody: Uint8Array, headers: Headers): Promise<Response> {
  const receipt = await receiver.handle({ rawBody, headers });
  return new Response(receipt.body, {
    status: receipt.status,
    headers: { 'content-type': 'application/json' },
  });
}
```

Getting the raw bytes: `express.raw({ type: 'application/json' })` then `req.body`;
`await c.req.text()` in Hono; `await req.text()` in a Next.js route handler; a
`parseAs: 'string'` content-type parser in Fastify.

Python: `client.webhooks.receiver(secret=..., on_spread_finalized=..., on_spread_reopened=...)`
then `receiver.handle(raw_body, request.headers)`. C#:
`client.Webhooks.CreateReceiver(new WebhookReceiverOptions { Secret = …, OnSpreadFinalized = … })`
then `await receiver.HandleAsync(rawBody, headers)`.

### 3. Persist by id

Inside `onSpreadFinalized` the receiver has already read the snapshot the event
names (by id, not the loan's latest) and handed it to you as `attributes`. That
matters: delivery is at-least-once and unordered, so a retry of an older finalize
arriving after a reopen-and-refinalize would otherwise give you newer figures under
an older event.

Upsert on `snapshot_id`. Join `event.data.loan_id` to your deal.

```ts
import type { AttributeSnapshot, WebhookEventOfType } from '@spreadspace/sdk';

declare const sql: { upsertSnapshot(row: Record<string, unknown>): Promise<void> };

export function snapshotRow(
  event: WebhookEventOfType<'spread.finalized'>,
  snapshot: AttributeSnapshot,
): Promise<void> {
  return sql.upsertSnapshot({
    snapshot_id: snapshot.snapshot_id, // ON CONFLICT target
    loan_id: event.data.loan_id,
    borrower_id: event.data.borrower_id,
    spread_board_id: snapshot.spread_board_id ?? null,
    version: snapshot.version,
    status: snapshot.status,
    finalized_at: snapshot.finalized_at ?? null,
    finalized_by_external_user_id: snapshot.finalized_by_external_user_id ?? null,
    reopened_at: null,
    payload: snapshot.payload, // whole, as delivered
    manifest: snapshot.manifest,
    received_at: new Date().toISOString(),
  });
}
```

### 4. Reopen

`spread.reopened` carries the `snapshot_id` of the `final` that was current. That
snapshot is unchanged and stays on record. It is under revision. Stamp
`reopened_at` on that row. The next `spread.finalized` arrives with a higher
`version` and a new `snapshot_id`. Either clear the flag when it lands, or render
the newest `final` row and show "Under revision" while its `reopened_at` is
set.

The handler is the one-liner in step 2: `UPDATE … SET reopened_at =
event.data.reopened_at WHERE snapshot_id = event.data.snapshot_id`. Updating zero
rows is fine and not an error. It means that finalize predates your subscription.

### 5. Show it

Read your own table at render time. Never call SpreadSpace to render a deal
screen. That is the whole point of freezing the figures.

- **"Finalized by"** is `finalized_by_external_user_id` joined to **your** users
  table. SpreadSpace never sends a person's name to a service account; it hands
  back the id your embed session named, because that id is yours.
- **Values are strings.** Render them as given, or parse with a decimal type.
- **Group by `payload.periods`**: the fiscal-year labels or as-of dates the payload
  declares, in the order it declares them. A period with no figure is present and
  `null`, so absence renders as absence instead of a ragged grid.
- **`basis`** on an attribute says tax or book; on a ratio it says where the
  operands came from. Render it verbatim and branch on nothing. The full vocabulary
  is in the
  [attribute catalog](https://docs.spreadspace.app/api/attributes-catalog).

```ts
import type { AttributeSnapshot } from '@spreadspace/sdk';

export interface FigureRow {
  key: string;
  label: string;
  unit: string;
  basis: string | null;
  values: (string | null)[];
}

export function figureRows(snapshot: AttributeSnapshot): FigureRow[] {
  const { periods, attributes, ratios } = snapshot.payload;
  const entries = [...Object.entries(attributes), ...Object.entries(ratios)];
  return entries.map(([key, figure]) => ({
    key,
    label: figure.label,
    unit: figure.unit,
    basis: figure.basis ?? null,
    // One cell per declared period, in the order the payload declares them.
    values: periods.map((period) => figure.values[period] ?? null),
  }));
}
```

### 6. Backfill

Loans finalized before you subscribed never fired an event you heard. Walk the
versions list, take the newest `final`, read it by id.

```ts
import { SpreadSpace } from '@spreadspace/sdk';
import type { AttributeSnapshot } from '@spreadspace/sdk';

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

export async function backfillLoan(loanId: string): Promise<AttributeSnapshot | null> {
  const { snapshots } = await client.loans.attributes.versions(loanId);
  // Newest first. The first `final` is the current one.
  const latestFinal = snapshots.find((snapshot) => snapshot.status === 'final');
  if (!latestFinal) return null;
  return client.loans.attributes.retrieve(loanId, latestFinal.snapshot_id);
}
```

The versions list is at most 200 rows and is not paginated, so the client-side
filter is the portable form and works on every SDK release.

### 7. Operate it

- **Replay** a delivery you never acknowledged: list the deliveries, then replay
  one. It arrives as a **new delivery id** carrying the **same event id**, signed
  with the endpoint's **current** secret, so a receiver that dedupes properly
  answers `duplicate:true` and writes nothing. Replay is for deliveries that never
  landed, not a way to re-run a handler that already succeeded.
- **Rotate** the secret when you need to. The old secret keeps verifying for a
  24-hour grace window, so pass **both** to the receiver until the window closes,
  then drop the old one.
- **Cadence:** a delivery arrives typically within seconds; allow up to a minute.
- **A finalize on a board that is already final answers `423 spread_locked`.**
  Reopen it first. The same goes for replacing or deleting a locked board.

```ts
import { SpreadSpace } from '@spreadspace/sdk';

const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
declare const endpointId: string;

type Delivery = { id: string; event_ref: string; status: string };

// `id` is the delivery attempt; `event_ref` is the event id your logs carry.
for await (const d of client.webhooks.deliveries<Delivery>(endpointId)) {
  if (d.status === 'dead') await client.webhooks.replayDelivery(endpointId, d.id);
}

// Returned once. Keep the previous secret alongside it for the 24-hour window.
const rotated = await client.webhooks.rotateSecret<{ signing_secret: string }>(endpointId);
```

Python: `client.webhooks.deliveries(endpoint_id)`, `replay_delivery(...)`,
`rotate_secret(...)`. C#: `Deliveries(...)`, `ReplayDeliveryAsync(...)`,
`RotateSecretAsync(...)`.

## Gotchas

- **Raw body or the signature never verifies.** Re-serialized JSON is different
  bytes. This is the single most common way an integration fails.
- **`SpreadSpace-Event-Id` equals the body's `id`.** Dedupe on it. It is stable
  across every retry and every replay, and shared by all endpoints one event fans
  out to.
- **The event is a pointer, not the figures.** `spread.finalized` carries
  `loan_id`, `borrower_id`, `spread_board_id`, `snapshot_id`,
  `machine_snapshot_id`, `version` and `finalized_at`, and nothing else. The
  by-id read is the source of truth. Never reconstruct figures from the event.
- **A finalize creates two snapshots.** The `final` is what the analyst approved.
  The `machine` snapshot is built from the documents alone, with no analyst input. Persist
  the `final`: the event's `snapshot_id`, not its `machine_snapshot_id`.
- **Keys are never dropped.** A period with no figure is present and `null`. Treat
  a missing key as a bug in your binding, not as absence.
- **An API key receives values only.** Source-provenance geometry is stripped at
  every depth, and `finalized_by` (a person's name) is `null` for a service
  account by design. Use `finalized_by_external_user_id`.
- **Do not branch on `builder_version`.** It is informational and may be `null`.
  The same goes for `basis` strings: render them, do not switch on them.
- **A second registration of the same URL is a second endpoint**, with its own
  secret, silently. List and PATCH instead.
- **`livemode: false`** on every envelope produced by a test-mode (`ss_test_…`)
  key. Branch on it if you route test deliveries to a staging handler.

## Acceptance

You are done when all six hold:

1. An analyst finalizes; within a minute your table has exactly one row for that
   `snapshot_id`, and the deal screen renders the figures.
2. On the delivery you captured, `SpreadSpace-Event-Id` equals the body's `id` and
   the signature verifies against the endpoint's own secret.
3. Replaying that delivery answers `200 {"received":true,"duplicate":true}` and
   your row count does not change.
4. A reopen stamps `reopened_at` on that row and the deal screen says "Under
   revision".
5. After a rotate, the next delivery verifies against the new secret only.
6. "Finalized by" resolves through your own users table, from
   `finalized_by_external_user_id`.

## Read next

Every page has a `.md` version, and the index is at
[https://docs.spreadspace.app/llms.txt](https://docs.spreadspace.app/llms.txt).

- [https://docs.spreadspace.app/api/webhooks.md](https://docs.spreadspace.app/api/webhooks.md): every event, the envelope, delivery, replay, rotation.
- [https://docs.spreadspace.app/api/spreads.md](https://docs.spreadspace.app/api/spreads.md): the snapshot lifecycle, the payload and the manifest key by key.
- [https://docs.spreadspace.app/api/attributes-catalog.md](https://docs.spreadspace.app/api/attributes-catalog.md): every attribute and ratio name, label, unit and basis.
- [https://docs.spreadspace.app/api/conventions.md](https://docs.spreadspace.app/api/conventions.md): money and ratio string formats, dates, ids.
- [https://docs.spreadspace.app/get-started/walkthrough.md](https://docs.spreadspace.app/get-started/walkthrough.md): the same loop by hand, with real envelopes.
- The client surfaces are [typescript.md](https://docs.spreadspace.app/sdks/typescript.md), [python.md](https://docs.spreadspace.app/sdks/python.md) and [csharp.md](https://docs.spreadspace.app/sdks/csharp.md), under `https://docs.spreadspace.app/sdks/`.
- [https://docs.spreadspace.app/embed/overview.md](https://docs.spreadspace.app/embed/overview.md): render the workspace in your app so the Finalize click happens there.
````

### The standing rules

Add this block to your own `CLAUDE.md` / `AGENTS.md`, so the rules hold on every session, not just the one that builds the receiver.

```markdown title="CLAUDE.md"
# SpreadSpace integration

Follow the `spreadspace-integration` skill for anything touching SpreadSpace:
receiving `spread.finalized`, persisting the frozen figures, rendering them.

Standing rules:

1. Never log the API key or a `whsec_…` signing secret, not in errors and not in traces.
2. The webhook receiver reads the **raw** request body bytes. Re-serialized JSON
   never verifies.
3. Dedupe on the event id (`SpreadSpace-Event-Id`, equal to the body's `id`).
4. Persist snapshots **whole**, upserted on `snapshot_id`, which is unique.
5. The by-id snapshot read is the source of truth. Never rebuild figures from the
   event. The event is a pointer, not the numbers.

Docs: <https://docs.spreadspace.app/llms.txt> is the index, and every page has a
`.md` version (append `.md` to any docs URL) built for agents to read directly.
```

## What you'll have

* **The loop:** an analyst presses Finalize, `spread.finalized` reaches your
  receiver, and the frozen figures are stored in your own table keyed by
  `snapshot_id`.
* **Reopen handled:** `spread.reopened` marks that copy under revision until the
  next finalize arrives with a higher version.
* **"Finalized by" from your own users table**, joined on
  `finalized_by_external_user_id`. No vendor releases a person's name to a
  service account.
* **Replay and rotation handled:** a redelivery is a no-op, and a rotated secret
  verifies through its 24-hour grace window.
* **No vendor call at render time.** Your deal screen reads your database.

Not covered here: rendering the SpreadSpace workspace inside your own app (the
[Embed](/embed/overview) pages) and production hardening on your side (secret
storage, alerting, your own retries).

## Up next

#### [Integration guide](/get-started/walkthrough)

The same loop by hand, with the real envelopes from a live run.

#### [Webhooks](/api/webhooks)

Every event, the envelope, delivery, replay and rotation.

#### [Spreads and attributes](/api/spreads)

The snapshot lifecycle, the payload and the manifest, key by key.

#### [Attribute catalog](/api/attributes-catalog)

Every attribute and ratio name, its label, unit and basis.

#### [Embed](/embed/overview)

Render the workspace inside your own product so the Finalize click happens there.