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

# API integration guide

> **Note**
>
> A test-mode key (`ss_test_…`) mints from **Settings → Sandbox API** with the
> same presets as a live key, so it holds the same scopes, and it routes every
> call to the sandbox: seeded loans, and uploads that return premade extractions
> at no charge. This walkthrough runs on a live key because the Finalize click
> happens in your workspace, on a live loan, and the receiver reads the snapshot
> with a key that can see that loan. What a test key does exercise is delivery:
> an upload through it (the [Quick start](/get-started/quick-start) run) fires
> `document.processed`, `extraction.ready`, `loan.classified` and
> `job.completed` at every endpoint on your workspace subscribed to them, each
> with `livemode: false`.

When an analyst selects **Finalize**, SpreadSpace locks the spread, saves an
immutable snapshot, and sends a `spread.finalized` webhook. Your application
retrieves that snapshot by ID and stores its figures. If the analyst reopens
the spread, a `spread.reopened` event lets you mark the saved figures as under
revision. This guide covers both events, snapshot retrieval, and delivery testing.

**What you need:** an activated SpreadSpace workspace with at least one loan
that has documents, a key from **Settings → Live API** holding `spreads:read`,
`webhooks:read` and `webhooks:write` (the **Backend** preset holds all three),
and somewhere your receiver can be reached over `https`. A tunnel
(`cloudflared`, `ngrok`) in front of your laptop is fine.

## 1. Register your endpoint

Register an endpoint for `spread.finalized` and `spread.reopened` so your
application can track approvals and subsequent revisions.

```bash
curl -X POST https://api.spreadspace.app/api/webhooks \
  -H "Authorization: Bearer $SPREADSPACE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://<your-tunnel>/webhooks/spreadspace",
    "description": "LOS finalized-figures receiver",
    "subscribed_events": ["spread.finalized", "spread.reopened"]
  }'
```

The `201` returns `signing_secret` (`whsec_…`) **exactly once**. Store it
now. To change what the endpoint receives later, update it (`PATCH
/api/webhooks/{id}` with `subscribed_events`) rather than registering again: a
second registration of the same URL is a second endpoint with its own secret.

## 2. Run the receiver

The SDK receiver verifies the signature over the raw bytes, drops a redelivery
you have already processed, reads the snapshot the event names by id, and
answers with the status the retry policy expects. You write the two handlers.

**`TypeScript`**

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

const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
const receiver = client.webhooks.receiver({
  secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
  onSpreadFinalized: async ({ event, attributes }) => {
    // attributes: the exact snapshot this event names: snapshot_id, version,
    // status, finalized_at, payload (attributes.v1), manifest.
    await db.saveFinalizedFigures(event.data.loan_id, attributes);
  },
  onSpreadReopened: async ({ event }) => {
    await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
  },
});

const app = express();
app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
  const r = await receiver.handle({ rawBody: req.body, headers: req.headers });
  res.status(r.status).send(r.body);
});
app.listen(4000);
```

**`Python`**

```python title="Python" maxLines=60
import os

import fastapi
from spreadspace import SpreadSpace

client = SpreadSpace(api_key=os.environ["SPREADSPACE_API_KEY"])
receiver = client.webhooks.receiver(
    secret=os.environ["SPREADSPACE_WEBHOOK_SECRET"],
    # attributes: the exact snapshot this event names: snapshot_id, version,
    # status, finalized_at, payload (attributes.v1), manifest.
    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"]),
)

app = fastapi.FastAPI()

@app.post("/webhooks/spreadspace")
async def spreadspace_webhook(request: fastapi.Request) -> fastapi.Response:
    receipt = receiver.handle(await request.body(), request.headers)
    return fastapi.Response(receipt.body, status_code=receipt.status)
```

**`C#`**

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

using var client = new SpreadSpaceClient(Environment.GetEnvironmentVariable("SPREADSPACE_API_KEY"));
var receiver = client.Webhooks.CreateReceiver(new WebhookReceiverOptions {
    Secrets = new[] { Environment.GetEnvironmentVariable("SPREADSPACE_WEBHOOK_SECRET")! },
    // ctx.Attributes: the exact snapshot this event names: snapshot_id, version,
    // status, finalized_at, payload (attributes.v1), manifest.
    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
var app = WebApplication.CreateBuilder(args).Build();
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);
});
app.Run("http://localhost:4000");
```

Build the receiver once per process, not per request. The default dedupe
store lives on it. Hand it the raw bytes: a framework that parses JSON before
your handler sees it has already changed the bytes the signature covers. The
full option list is on
[Receive events with the SDK](/api/webhooks#receive-events-with-the-sdk).

## 3. Finalize a spread

In the workspace (or inside your embed), open a saved spread on a loan whose
board is scoped to a **business** filer (the figures come from the returns),
then choose **Finalize spread** from the board menu. Confirm.

The board locks the moment the server answers: the menu now offers **Reopen
spread**, and replace, delete and a second finalize all refuse with
**`423 spread_locked`** until someone reopens it.

## 4. What arrives

One delivery reaches your endpoint, typically within seconds; allow up to a
minute. This is the body (pretty-printed here; the
signature covers the exact bytes as sent):

```json
{
  "id": "evt_99ddbc3062ed47cb9699219e",
  "type": "spread.finalized",
  "created": 1787022929,
  "tenant_id": "org_…",
  "livemode": true,
  "data": {
    "loan_id": "cbd65db4d13e465a8bf6d489",
    "borrower_id": "f95caba09ad7483ab33b8a82",
    "spread_board_id": "1379da3084e74a8da4a09484",
    "snapshot_id": "9cb9c4bbec4b4d08baea3e83",
    "machine_snapshot_id": "f5be61d160934fddb088e6c6",
    "version": 13,
    "finalized_at": "2026-08-18T03:15:29.403074+00:00"
  }
}
```

The body is a pointer, not the figures. Alongside it:
`SpreadSpace-Event-Id` equal to the body's `id`,
`SpreadSpace-Signature: t=…,v1=…`, and
`User-Agent: SpreadSpace-Webhooks/1`.

Before your handler ran, the receiver read the snapshot the event names:

```
GET /api/loans/cbd65db4d13e465a8bf6d489/attributes/9cb9c4bbec4b4d08baea3e83
```

and handed it to you as `attributes`. It answers the snapshot itself, with no
`{ loan_id, snapshot }` wrapper on the by-id read:

```json maxLines=60
{
  "snapshot_id": "9cb9c4bbec4b4d08baea3e83",
  "version": 13,
  "status": "final",
  "spread_board_id": "1379da3084e74a8da4a09484",
  "created_at": "2026-08-18T03:15:29.403074+00:00",
  "finalized_at": "2026-08-18T03:15:29.403074+00:00",
  "finalized_by": null,
  "finalized_by_external_user_id": "b6b1d719-147c-42ff-86b5-440fb60a00fe",
  "payload": {
    "schema_version": "attributes.v1",
    "periods": ["2021", "2022", "2023", "2024"],
    "attributes": {
      "tax_return_book_ebitda": {
        "label": "EBITDA (book per return)",
        "unit": "money",
        "basis": "book",
        "values": { "2021": null, "2022": "…", "2023": "…", "2024": "…" },
        "documents": { "2021": [], "2022": [ { "document_id": "3ffff742e1f14a63b93b583b", "extraction_version": 1 } ], "…": "…" }
      },
      "…": "…"
    },
    "ratios": { "…": "…" },
    "tables": {},
    "inputs": { "addbacks": [], "dscr_configs": [], "hidden_rows": 0, "custom_visuals": 0, "custom_tables": 0 }
  },
  "manifest": {
    "documents": [ { "document_id": "0df3f4d3846040dab4ab0caf", "document_type": "1065", "fiscal_year": 2024, "period_end": null, "extraction_version": 1 }, "…" ],
    "inputs_summary": { "addbacks": 0, "dscr_configs": 0, "hidden_rows": 0, "custom_visuals": 0, "custom_tables": 0 },
    "entity": { "key": "business:…", "name": "…" },
    "built_at": "2026-08-18T03:15:29Z",
    "builder_version": null
  }
}
```

(Money values are elided above; on the wire each is a string with exactly two
decimals. Object key order on the wire is not the order shown here, so bind by
name, never by position.)

Periods without a value remain present as `null`. For API-key requests,
`finalized_by` is `null`; use `finalized_by_external_user_id` to identify the
analyst through your own user records. This is the `external_user_id` supplied
when the embed session was created. See the
[attribute catalog](/api/attributes-catalog) for supported names, units, and bases.

## 5. Handle a reopened spread

Choose **Reopen spread** from the same menu. The lock clears, the snapshots
stay, and your endpoint receives:

```json
{
  "id": "evt_83fd0c81259b481595830a1b",
  "type": "spread.reopened",
  "created": 1787022811,
  "tenant_id": "org_…",
  "livemode": true,
  "data": {
    "loan_id": "cbd65db4d13e465a8bf6d489",
    "borrower_id": "f95caba09ad7483ab33b8a82",
    "spread_board_id": "1379da3084e74a8da4a09484",
    "snapshot_id": "dafd2de9241d460a95d7fcbe",
    "version": 11,
    "reopened_at": "2026-08-18T03:13:31.076968+00:00"
  }
}
```

`snapshot_id` is the `final` snapshot that was current. It is unchanged and
stays on record. The figures are under revision until the next
`spread.finalized`, which arrives with a higher `version` and a new
`snapshot_id`. Mark your copy stale, and let the next finalize replace it.

## 6. Test delivery and replay

Use `GET /api/webhooks/{id}/deliveries` to find a delivery, then verify replay
and signing-secret rotation:

* **Replay** the finalize delivery. It arrives as a new delivery with the
  **same** event `id`, signed with the endpoint's **current** secret. Your
  receiver answers `200 {"received":true,"duplicate":true}` and your row count
  does not change.
* **Rotate** the secret (`POST /api/webhooks/{id}/rotate`), pass both secrets
  to the receiver for the 24-hour window, and finalize again: the new
  delivery verifies against the new secret only.

Store approved figures by `snapshot_id` and use the spread events to keep
their revision status current.

## Up next

* [Spreads](/api/spreads): the lifecycle, the payload and the manifest, key
  by key.
* [Webhooks](/api/webhooks): every event, the envelope, delivery and replay.
* [Embed integration guide](/get-started/embed-walkthrough): run the
  workspace inside your own product so the Finalize click happens there;
  [Embed](/embed/overview) has the packages and the security model.