Integration guide

From one Finalize click to the frozen figures in your backend, end to end.

Prefer to hand this to a coding agent? Agent-assisted integration carries the same loop as a skill file.

This is the integration in its smallest complete form: an analyst finalizes a spread, SpreadSpace tells your backend, your backend reads the frozen figures by id and stores them, and when the analyst reopens the spread your backend is told that too. Every step below was run against a live workspace as this page was written, so the shapes you see are the shapes you will get.

What you need: a SpreadSpace workspace with at least one loan that has documents (a trial workspace’s seeded loans qualify), a key from Settings → Live API holding spreads:read, webhooks:read and webhooks:write, and somewhere your receiver can be reached over https. A tunnel (cloudflared, ngrok) in front of your laptop is fine.

A test-mode key (ss_test_…) routes to the sandbox, where uploads return premade sample extractions at no charge. That is the right place to exercise the plumbing, but a finalize there freezes sample figures, not yours. Use a live key against your own workspace for this walkthrough.

1. Register your endpoint

One request, once. Subscribe to both spread events. They are a pair.

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

1import { SpreadSpace } from '@spreadspace/sdk';
2import express from 'express';
3
4const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
5const receiver = client.webhooks.receiver({
6 secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
7 onSpreadFinalized: async ({ event, attributes }) => {
8 // attributes: the exact snapshot this event names: snapshot_id, version,
9 // status, finalized_at, payload (attributes.v1), manifest.
10 await db.saveFinalizedFigures(event.data.loan_id, attributes);
11 },
12 onSpreadReopened: async ({ event }) => {
13 await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
14 },
15});
16
17const app = express();
18app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
19 const r = await receiver.handle({ rawBody: req.body, headers: req.headers });
20 res.status(r.status).send(r.body);
21});
22app.listen(4000);

Build the receiver once per process, not per request. The default dedupe store lives on it. Python and C# read the same way; see 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):

1{
2 "id": "evt_99ddbc3062ed47cb9699219e",
3 "type": "spread.finalized",
4 "created": 1787022929,
5 "tenant_id": "org_…",
6 "livemode": true,
7 "data": {
8 "loan_id": "cbd65db4d13e465a8bf6d489",
9 "borrower_id": "f95caba09ad7483ab33b8a82",
10 "spread_board_id": "1379da3084e74a8da4a09484",
11 "snapshot_id": "9cb9c4bbec4b4d08baea3e83",
12 "machine_snapshot_id": "f5be61d160934fddb088e6c6",
13 "version": 13,
14 "finalized_at": "2026-08-18T03:15:29.403074+00:00"
15 }
16}

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:

1{
2 "snapshot_id": "9cb9c4bbec4b4d08baea3e83",
3 "version": 13,
4 "status": "final",
5 "spread_board_id": "1379da3084e74a8da4a09484",
6 "created_at": "2026-08-18T03:15:29.403074+00:00",
7 "finalized_at": "2026-08-18T03:15:29.403074+00:00",
8 "finalized_by": null,
9 "finalized_by_external_user_id": "b6b1d719-147c-42ff-86b5-440fb60a00fe",
10 "payload": {
11 "schema_version": "attributes.v1",
12 "periods": ["2021", "2022", "2023", "2024"],
13 "attributes": {
14 "tax_return_book_ebitda": {
15 "label": "EBITDA (book per return)",
16 "unit": "money",
17 "basis": "book",
18 "values": { "2021": null, "2022": "", "2023": "", "2024": "" },
19 "documents": { "2021": [], "2022": [ { "document_id": "3ffff742e1f14a63b93b583b", "extraction_version": 1 } ], "": "" }
20 },
21 "": ""
22 },
23 "ratios": { "": "" },
24 "inputs": { "addbacks": [], "dscr_configs": [], "hidden_rows": 0, "custom_visuals": 0 }
25 },
26 "manifest": {
27 "documents": [ { "document_id": "0df3f4d3846040dab4ab0caf", "document_type": "1065", "fiscal_year": 2024, "period_end": null, "extraction_version": 1 }, "" ],
28 "inputs_summary": { "addbacks": 0, "dscr_configs": 0, "hidden_rows": 0, "custom_visuals": 0 },
29 "entity": { "key": "business:…", "name": "" },
30 "built_at": "2026-08-18T03:15:29Z",
31 "builder_version": null
32 }
33}

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

Three things to note here. A year the returns do not cover is present as null, never dropped, so absence renders as absence. finalized_by is null for a key-authenticated caller: names of people are returned only to a workspace user or an embed session carrying an external_user_id. And finalized_by_external_user_id is the id you named on the embed session for the analyst who finalized. It is your own user’s identifier handed back, so your deal screen can name the analyst from your own table. The full vocabulary (every attribute and ratio name, its label, unit and basis) is on the attribute catalog.

5. Reopen, and hear it

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

1{
2 "id": "evt_83fd0c81259b481595830a1b",
3 "type": "spread.reopened",
4 "created": 1787022811,
5 "tenant_id": "org_…",
6 "livemode": true,
7 "data": {
8 "loan_id": "cbd65db4d13e465a8bf6d489",
9 "borrower_id": "f95caba09ad7483ab33b8a82",
10 "spread_board_id": "1379da3084e74a8da4a09484",
11 "snapshot_id": "dafd2de9241d460a95d7fcbe",
12 "version": 11,
13 "reopened_at": "2026-08-18T03:13:31.076968+00:00"
14 }
15}

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. Prove the plumbing

Two more moves, both from the deliveries list (GET /api/webhooks/{id}/deliveries, then POST /api/webhooks/{id}/deliveries/{deliveryId}/replay):

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

That is the whole flow. Your system stores the numbers the analyst approved, keyed by snapshot_id. When they are revised, you are told. Nobody re-keys anything.

Up next

  • Spreads: the lifecycle, the payload and the manifest, key by key.
  • Webhooks: every event, the envelope, delivery and replay.
  • Embed: run the workspace inside your own product so the Finalize click happens there.