Agent-assisted integration

Hand this page to your coding agent: one Finalize click ends as frozen figures in your database and on your deal screen.

Hand it to your agent

1

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

2

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.

3

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.

4

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. Every page here has a .md twin, and the index is at /llms.txt.

SKILL.md

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

SKILL.md
1---
2name: spreadspace-integration
3description: Use when integrating SpreadSpace: receiving spread.finalized, verifying the webhook, and getting the finalized figures into our own database and onto our deal screen.
4---
5
6# SpreadSpace integration
7
8## What you are building
9
10An analyst presses **Finalize** inside the embedded SpreadSpace spread. SpreadSpace
11freezes the figures as a `final` snapshot and fires `spread.finalized`. The event
12is a pointer, not the numbers. Your receiver verifies the signature over the raw
13bytes, reads the snapshot the event names **by id**, and upserts it against your
14own loan or deal record. Your deal screen then renders those frozen figures from
15your own table, with no vendor call at read time. When the analyst reopens the
16spread, `spread.reopened` arrives naming the `final` that was current, and you
17mark that copy "under revision" until the next finalize lands.
18
19## Before you start
20
21- **An API key** from **Settings → Live API** with the `webhooks:read`,
22 `webhooks:write` and `spreads:read` scopes, used against your own workspace
23 (a trial workspace's seeded loans qualify). Put it in `SPREADSPACE_API_KEY`.
24 A test-mode key (`ss_test_…`) reaches the sandbox, where extractions are
25 canned. That is right for exercising the plumbing, but a finalize there has
26 no figures worth freezing.
27- **A public `https` URL** for the receiver. Private and internal addresses are
28 refused at registration and re-checked at connect time, so in development put a
29 tunnel (`cloudflared`, `ngrok`) in front of your machine and register the
30 tunnel's URL.
31- **The SDK:** `npm i @spreadspace/sdk`, or `pip install spreadspace`, or
32 `dotnet add package SpreadSpace`.
33- **The loan mapping.** You already know which SpreadSpace `loan_id` belongs to
34 which of your deals, because you created the loan. Store its id on your deal
35 record and join on it. Do not try to match by borrower name.
36
37## The table
38
39One row per snapshot. `snapshot_id` is the idempotency key; put a UNIQUE index on
40it, because an in-memory dedupe set is a cache and this index is the durable floor.
41
42| Column | Type | Notes |
43|---|---|---|
44| `snapshot_id` | text, **unique** | The idempotency key. Upsert on it. |
45| `loan_id` | text | The SpreadSpace loan. Your join to your own deal. |
46| `borrower_id` | text | As delivered. |
47| `spread_board_id` | text, nullable | The saved spread the figures were taken from. |
48| `version` | integer | Per-loan version, counting up across every status. |
49| `status` | text | `final` for everything you persist here. |
50| `finalized_at` | timestamptz | When the analyst finalized. |
51| `finalized_by_external_user_id` | text, nullable | Your own user id, when the finalize happened inside your embed. |
52| `reopened_at` | timestamptz, nullable | Set by `spread.reopened`. Null while the figures are current. |
53| `payload` | json | The whole `attributes.v1` payload, stored as delivered. |
54| `manifest` | json | The whole manifest, stored as delivered. |
55| `received_at` | timestamptz | Your own clock. |
56
57Store `payload` and `manifest` **whole**. Bind by key name, never by position.
58JSON key order on the wire is not stable. Every attribute and ratio key is always
59present; `null` means "no figure for that period", not "missing". Money values are
60strings with exactly two decimals and ratios are strings with at most four, so no
61float can round them in transit. Parse them with a decimal type, never `Number`.
62See
63[Conventions](https://docs.spreadspace.app/api/conventions).
64
65## Steps
66
67### 1. Register the endpoint, idempotently
68
69List first, PATCH if your URL is already there, create only if it is not. A second
70registration of the same URL makes a **second endpoint** with its own secret, and
71deliveries from the endpoint you forgot about will fail verification forever.
72
73The subscription is whatever the endpoint was created or last updated with. An
74endpoint registered before an event existed never receives it until you PATCH.
75
76The `whsec_…` signing secret comes back exactly once, on create and on rotate.
77Put it in your secret store. Never log it, never put it in an error message.
78
79```ts
80import { SpreadSpace } from '@spreadspace/sdk';
81
82declare const secrets: { put(name: string, value: string): Promise<void> };
83
84const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
85
86const ENDPOINT_URL = 'https://example.com/webhooks/spreadspace';
87const EVENTS = ['spread.finalized', 'spread.reopened'];
88
89export async function ensureEndpoint(): Promise<string> {
90 for await (const endpoint of client.webhooks.list<{ id: string; url: string }>()) {
91 if (endpoint.url !== ENDPOINT_URL) continue;
92 // Already registered: change the subscription in place, keep the secret.
93 await client.webhooks.update(endpoint.id, { subscribed_events: EVENTS });
94 return endpoint.id;
95 }
96
97 const created = await client.webhooks.create<{ id: string; signing_secret: string }>({
98 url: ENDPOINT_URL,
99 description: 'Finalized-figures receiver',
100 subscribed_events: EVENTS,
101 });
102 // Returned once. Store it; do not log it.
103 await secrets.put('spreadspace_webhook_secret', created.signing_secret);
104 return created.id;
105}
106```
107
108Python: `client.webhooks.list()` / `.create(...)` / `.update(endpoint_id, ...)`.
109C#: `client.Webhooks.List()` / `CreateAsync(...)` / `UpdateAsync(...)`.
110
111### 2. The receiver
112
113Build the receiver **once per process**, not per request: the default dedupe store
114lives on it, so a receiver constructed inside the request handler can never drop a
115duplicate. If your secret lives in a database rather than the environment, hoist a
116module-level dedupe store and hand it to every receiver you build.
117
118Feed it the **raw request body bytes** and the request headers. A framework that
119JSON-parses the body first breaks verification permanently, because the signature
120covers the exact bytes on the wire. Then return the receipt's status and body
121verbatim:
122
123| Receipt | Meaning |
124|---|---|
125| `200 {"received":true}` | Handled. |
126| `200 {"received":true,"duplicate":true}` | An event id you already processed. Ordinary, not an error. |
127| `400 {"error":"invalid_signature"}` | Bad, missing or stale signature. No handler ran. |
128| `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. |
129
130Each attempt gets five seconds. Answer fast; if you have heavy work to do, durably
131record the event first, acknowledge, and do the rest out of band.
132
133```ts
134import { SpreadSpace } from '@spreadspace/sdk';
135import type { AttributeSnapshot } from '@spreadspace/sdk';
136
137declare const db: {
138 upsertSnapshot(loanId: string, snapshot: AttributeSnapshot): Promise<void>;
139 markReopened(snapshotId: string, reopenedAt: string): Promise<void>;
140};
141
142const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
143
144// Once per process.
145export const receiver = client.webhooks.receiver({
146 secret: process.env.SPREADSPACE_WEBHOOK_SECRET!,
147 onSpreadFinalized: async ({ event, attributes }) => {
148 if (!attributes) return;
149 await db.upsertSnapshot(event.data.loan_id, attributes);
150 },
151 onSpreadReopened: async ({ event }) => {
152 await db.markReopened(event.data.snapshot_id, event.data.reopened_at);
153 },
154});
155```
156
157The route itself is four lines and framework-shaped. Raw bytes in, receipt out:
158
159```ts continues
160export async function handleWebhook(rawBody: Uint8Array, headers: Headers): Promise<Response> {
161 const receipt = await receiver.handle({ rawBody, headers });
162 return new Response(receipt.body, {
163 status: receipt.status,
164 headers: { 'content-type': 'application/json' },
165 });
166}
167```
168
169Getting the raw bytes: `express.raw({ type: 'application/json' })` then `req.body`;
170`await c.req.text()` in Hono; `await req.text()` in a Next.js route handler; a
171`parseAs: 'string'` content-type parser in Fastify.
172
173Python: `client.webhooks.receiver(secret=..., on_spread_finalized=..., on_spread_reopened=...)`
174then `receiver.handle(raw_body, request.headers)`. C#:
175`client.Webhooks.CreateReceiver(new WebhookReceiverOptions { Secret = …, OnSpreadFinalized = … })`
176then `await receiver.HandleAsync(rawBody, headers)`.
177
178### 3. Persist by id
179
180Inside `onSpreadFinalized` the receiver has already read the snapshot the event
181names (by id, not the loan's latest) and handed it to you as `attributes`. That
182matters: delivery is at-least-once and unordered, so a retry of an older finalize
183arriving after a reopen-and-refinalize would otherwise give you newer figures under
184an older event.
185
186Upsert on `snapshot_id`. Join `event.data.loan_id` to your deal.
187
188```ts
189import type { AttributeSnapshot, WebhookEventOfType } from '@spreadspace/sdk';
190
191declare const sql: { upsertSnapshot(row: Record<string, unknown>): Promise<void> };
192
193export function snapshotRow(
194 event: WebhookEventOfType<'spread.finalized'>,
195 snapshot: AttributeSnapshot,
196): Promise<void> {
197 return sql.upsertSnapshot({
198 snapshot_id: snapshot.snapshot_id, // ON CONFLICT target
199 loan_id: event.data.loan_id,
200 borrower_id: event.data.borrower_id,
201 spread_board_id: snapshot.spread_board_id ?? null,
202 version: snapshot.version,
203 status: snapshot.status,
204 finalized_at: snapshot.finalized_at ?? null,
205 finalized_by_external_user_id: snapshot.finalized_by_external_user_id ?? null,
206 reopened_at: null,
207 payload: snapshot.payload, // whole, as delivered
208 manifest: snapshot.manifest,
209 received_at: new Date().toISOString(),
210 });
211}
212```
213
214### 4. Reopen
215
216`spread.reopened` carries the `snapshot_id` of the `final` that was current. That
217snapshot is unchanged and stays on record. It is under revision. Stamp
218`reopened_at` on that row. The next `spread.finalized` arrives with a higher
219`version` and a new `snapshot_id`. Either clear the flag when it lands, or render
220the newest `final` row and show "Under revision" while its `reopened_at` is
221set.
222
223The handler is the one-liner in step 2: `UPDATE … SET reopened_at =
224event.data.reopened_at WHERE snapshot_id = event.data.snapshot_id`. Updating zero
225rows is fine and not an error. It means that finalize predates your subscription.
226
227### 5. Show it
228
229Read your own table at render time. Never call SpreadSpace to render a deal
230screen. That is the whole point of freezing the figures.
231
232- **"Finalized by"** is `finalized_by_external_user_id` joined to **your** users
233 table. SpreadSpace never sends a person's name to a service account; it hands
234 back the id your embed session named, because that id is yours.
235- **Values are strings.** Render them as given, or parse with a decimal type.
236- **Group by `payload.periods`**: the fiscal-year labels or as-of dates the payload
237 declares, in the order it declares them. A period with no figure is present and
238 `null`, so absence renders as absence instead of a ragged grid.
239- **`basis`** on an attribute says tax or book; on a ratio it says where the
240 operands came from. Render it verbatim and branch on nothing. The full vocabulary
241 is in the
242 [attribute catalog](https://docs.spreadspace.app/api/attributes-catalog).
243
244```ts
245import type { AttributeSnapshot } from '@spreadspace/sdk';
246
247export interface FigureRow {
248 key: string;
249 label: string;
250 unit: string;
251 basis: string | null;
252 values: (string | null)[];
253}
254
255export function figureRows(snapshot: AttributeSnapshot): FigureRow[] {
256 const { periods, attributes, ratios } = snapshot.payload;
257 const entries = [...Object.entries(attributes), ...Object.entries(ratios)];
258 return entries.map(([key, figure]) => ({
259 key,
260 label: figure.label,
261 unit: figure.unit,
262 basis: figure.basis ?? null,
263 // One cell per declared period, in the order the payload declares them.
264 values: periods.map((period) => figure.values[period] ?? null),
265 }));
266}
267```
268
269### 6. Backfill
270
271Loans finalized before you subscribed never fired an event you heard. Walk the
272versions list, take the newest `final`, read it by id.
273
274```ts
275import { SpreadSpace } from '@spreadspace/sdk';
276import type { AttributeSnapshot } from '@spreadspace/sdk';
277
278const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
279
280export async function backfillLoan(loanId: string): Promise<AttributeSnapshot | null> {
281 const { snapshots } = await client.loans.attributes.versions(loanId);
282 // Newest first. The first `final` is the current one.
283 const latestFinal = snapshots.find((snapshot) => snapshot.status === 'final');
284 if (!latestFinal) return null;
285 return client.loans.attributes.retrieve(loanId, latestFinal.snapshot_id);
286}
287```
288
289The versions list is at most 200 rows and is not paginated, so the client-side
290filter is the portable form and works on every SDK release.
291
292### 7. Operate it
293
294- **Replay** a delivery you never acknowledged: list the deliveries, then replay
295 one. It arrives as a **new delivery id** carrying the **same event id**, signed
296 with the endpoint's **current** secret, so a receiver that dedupes properly
297 answers `duplicate:true` and writes nothing. Replay is for deliveries that never
298 landed, not a way to re-run a handler that already succeeded.
299- **Rotate** the secret when you need to. The old secret keeps verifying for a
300 24-hour grace window, so pass **both** to the receiver until the window closes,
301 then drop the old one.
302- **Cadence:** a delivery arrives typically within seconds; allow up to a minute.
303- **A finalize on a board that is already final answers `423 spread_locked`.**
304 Reopen it first. The same goes for replacing or deleting a locked board.
305
306```ts
307import { SpreadSpace } from '@spreadspace/sdk';
308
309const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! });
310declare const endpointId: string;
311
312type Delivery = { id: string; event_ref: string; status: string };
313
314// `id` is the delivery attempt; `event_ref` is the event id your logs carry.
315for await (const d of client.webhooks.deliveries<Delivery>(endpointId)) {
316 if (d.status === 'dead') await client.webhooks.replayDelivery(endpointId, d.id);
317}
318
319// Returned once. Keep the previous secret alongside it for the 24-hour window.
320const rotated = await client.webhooks.rotateSecret<{ signing_secret: string }>(endpointId);
321```
322
323Python: `client.webhooks.deliveries(endpoint_id)`, `replay_delivery(...)`,
324`rotate_secret(...)`. C#: `Deliveries(...)`, `ReplayDeliveryAsync(...)`,
325`RotateSecretAsync(...)`.
326
327## Gotchas
328
329- **Raw body or the signature never verifies.** Re-serialized JSON is different
330 bytes. This is the single most common way an integration fails.
331- **`SpreadSpace-Event-Id` equals the body's `id`.** Dedupe on it. It is stable
332 across every retry and every replay, and shared by all endpoints one event fans
333 out to.
334- **The event is a pointer, not the figures.** `spread.finalized` carries
335 `loan_id`, `borrower_id`, `spread_board_id`, `snapshot_id`,
336 `machine_snapshot_id`, `version` and `finalized_at`, and nothing else. The
337 by-id read is the source of truth. Never reconstruct figures from the event.
338- **A finalize creates two snapshots.** The `final` is what the analyst approved.
339 The `machine` snapshot is built from the documents alone, with no analyst input. Persist
340 the `final`: the event's `snapshot_id`, not its `machine_snapshot_id`.
341- **Keys are never dropped.** A period with no figure is present and `null`. Treat
342 a missing key as a bug in your binding, not as absence.
343- **An API key receives values only.** Source-provenance geometry is stripped at
344 every depth, and `finalized_by` (a person's name) is `null` for a service
345 account by design. Use `finalized_by_external_user_id`.
346- **Do not branch on `builder_version`.** It is informational and may be `null`.
347 The same goes for `basis` strings: render them, do not switch on them.
348- **A second registration of the same URL is a second endpoint**, with its own
349 secret, silently. List and PATCH instead.
350- **`livemode: false`** on every envelope produced by a test-mode (`ss_test_…`)
351 key. Branch on it if you route test deliveries to a staging handler.
352
353## Acceptance
354
355You are done when all six hold:
356
3571. An analyst finalizes; within a minute your table has exactly one row for that
358 `snapshot_id`, and the deal screen renders the figures.
3592. On the delivery you captured, `SpreadSpace-Event-Id` equals the body's `id` and
360 the signature verifies against the endpoint's own secret.
3613. Replaying that delivery answers `200 {"received":true,"duplicate":true}` and
362 your row count does not change.
3634. A reopen stamps `reopened_at` on that row and the deal screen says "Under
364 revision".
3655. After a rotate, the next delivery verifies against the new secret only.
3666. "Finalized by" resolves through your own users table, from
367 `finalized_by_external_user_id`.
368
369## Read next
370
371Every page has a `.md` version, and the index is at
372[https://docs.spreadspace.app/llms.txt](https://docs.spreadspace.app/llms.txt).
373
374- [https://docs.spreadspace.app/api/webhooks.md](https://docs.spreadspace.app/api/webhooks.md): every event, the envelope, delivery, replay, rotation.
375- [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.
376- [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.
377- [https://docs.spreadspace.app/api/conventions.md](https://docs.spreadspace.app/api/conventions.md): money and ratio string formats, dates, ids.
378- [https://docs.spreadspace.app/get-started/walkthrough.md](https://docs.spreadspace.app/get-started/walkthrough.md): the same loop by hand, with real envelopes.
379- 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/`.
380- [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.

CLAUDE.md
1# SpreadSpace integration
2
3Follow the `spreadspace-integration` skill for anything touching SpreadSpace:
4receiving `spread.finalized`, persisting the frozen figures, rendering them.
5
6Standing rules:
7
81. Never log the API key or a `whsec_…` signing secret, not in errors and not in traces.
92. The webhook receiver reads the **raw** request body bytes. Re-serialized JSON
10 never verifies.
113. Dedupe on the event id (`SpreadSpace-Event-Id`, equal to the body's `id`).
124. Persist snapshots **whole**, upserted on `snapshot_id`, which is unique.
135. The by-id snapshot read is the source of truth. Never rebuild figures from the
14 event. The event is a pointer, not the numbers.
15
16Docs: <https://docs.spreadspace.app/llms.txt> is the index, and every page has a
17`.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 pages) and production hardening on your side (secret storage, alerting, your own retries).

Up next