Skip to navigation

API integration guide

Receive finalized spreads and store the approved figures in your application.

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

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.

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);

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.

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):

{
"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:

{
"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 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:

{
"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: the lifecycle, the payload and the manifest, key by key.
  • Webhooks: every event, the envelope, delivery and replay.
  • Embed integration guide: run the workspace inside your own product so the Finalize click happens there; Embed has the packages and the security model.