Agent-assisted integration
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
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.
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.
1 --- 2 name: spreadspace-integration 3 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. 4 --- 5 6 # SpreadSpace integration 7 8 ## What you are building 9 10 An analyst presses **Finalize** inside the embedded SpreadSpace spread. SpreadSpace 11 freezes the figures as a `final` snapshot and fires `spread.finalized`. The event 12 is a pointer, not the numbers. Your receiver verifies the signature over the raw 13 bytes, reads the snapshot the event names **by id**, and upserts it against your 14 own loan or deal record. Your deal screen then renders those frozen figures from 15 your own table, with no vendor call at read time. When the analyst reopens the 16 spread, `spread.reopened` arrives naming the `final` that was current, and you 17 mark 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 39 One row per snapshot. `snapshot_id` is the idempotency key; put a UNIQUE index on 40 it, 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 57 Store `payload` and `manifest` **whole**. Bind by key name, never by position. 58 JSON key order on the wire is not stable. Every attribute and ratio key is always 59 present; `null` means "no figure for that period", not "missing". Money values are 60 strings with exactly two decimals and ratios are strings with at most four, so no 61 float can round them in transit. Parse them with a decimal type, never `Number`. 62 See 63 [Conventions](https://docs.spreadspace.app/api/conventions). 64 65 ## Steps 66 67 ### 1. Register the endpoint, idempotently 68 69 List first, PATCH if your URL is already there, create only if it is not. A second 70 registration of the same URL makes a **second endpoint** with its own secret, and 71 deliveries from the endpoint you forgot about will fail verification forever. 72 73 The subscription is whatever the endpoint was created or last updated with. An 74 endpoint registered before an event existed never receives it until you PATCH. 75 76 The `whsec_…` signing secret comes back exactly once, on create and on rotate. 77 Put it in your secret store. Never log it, never put it in an error message. 78 79 ```ts 80 import { SpreadSpace } from '@spreadspace/sdk'; 81 82 declare const secrets: { put(name: string, value: string): Promise<void> }; 83 84 const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! }); 85 86 const ENDPOINT_URL = 'https://example.com/webhooks/spreadspace'; 87 const EVENTS = ['spread.finalized', 'spread.reopened']; 88 89 export 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 108 Python: `client.webhooks.list()` / `.create(...)` / `.update(endpoint_id, ...)`. 109 C#: `client.Webhooks.List()` / `CreateAsync(...)` / `UpdateAsync(...)`. 110 111 ### 2. The receiver 112 113 Build the receiver **once per process**, not per request: the default dedupe store 114 lives on it, so a receiver constructed inside the request handler can never drop a 115 duplicate. If your secret lives in a database rather than the environment, hoist a 116 module-level dedupe store and hand it to every receiver you build. 117 118 Feed it the **raw request body bytes** and the request headers. A framework that 119 JSON-parses the body first breaks verification permanently, because the signature 120 covers the exact bytes on the wire. Then return the receipt's status and body 121 verbatim: 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 130 Each attempt gets five seconds. Answer fast; if you have heavy work to do, durably 131 record the event first, acknowledge, and do the rest out of band. 132 133 ```ts 134 import { SpreadSpace } from '@spreadspace/sdk'; 135 import type { AttributeSnapshot } from '@spreadspace/sdk'; 136 137 declare const db: { 138 upsertSnapshot(loanId: string, snapshot: AttributeSnapshot): Promise<void>; 139 markReopened(snapshotId: string, reopenedAt: string): Promise<void>; 140 }; 141 142 const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! }); 143 144 // Once per process. 145 export 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 157 The route itself is four lines and framework-shaped. Raw bytes in, receipt out: 158 159 ```ts continues 160 export 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 169 Getting 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 173 Python: `client.webhooks.receiver(secret=..., on_spread_finalized=..., on_spread_reopened=...)` 174 then `receiver.handle(raw_body, request.headers)`. C#: 175 `client.Webhooks.CreateReceiver(new WebhookReceiverOptions { Secret = …, OnSpreadFinalized = … })` 176 then `await receiver.HandleAsync(rawBody, headers)`. 177 178 ### 3. Persist by id 179 180 Inside `onSpreadFinalized` the receiver has already read the snapshot the event 181 names (by id, not the loan's latest) and handed it to you as `attributes`. That 182 matters: delivery is at-least-once and unordered, so a retry of an older finalize 183 arriving after a reopen-and-refinalize would otherwise give you newer figures under 184 an older event. 185 186 Upsert on `snapshot_id`. Join `event.data.loan_id` to your deal. 187 188 ```ts 189 import type { AttributeSnapshot, WebhookEventOfType } from '@spreadspace/sdk'; 190 191 declare const sql: { upsertSnapshot(row: Record<string, unknown>): Promise<void> }; 192 193 export 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 217 snapshot 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 220 the newest `final` row and show "Under revision" while its `reopened_at` is 221 set. 222 223 The handler is the one-liner in step 2: `UPDATE … SET reopened_at = 224 event.data.reopened_at WHERE snapshot_id = event.data.snapshot_id`. Updating zero 225 rows is fine and not an error. It means that finalize predates your subscription. 226 227 ### 5. Show it 228 229 Read your own table at render time. Never call SpreadSpace to render a deal 230 screen. 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 245 import type { AttributeSnapshot } from '@spreadspace/sdk'; 246 247 export interface FigureRow { 248 key: string; 249 label: string; 250 unit: string; 251 basis: string | null; 252 values: (string | null)[]; 253 } 254 255 export 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 271 Loans finalized before you subscribed never fired an event you heard. Walk the 272 versions list, take the newest `final`, read it by id. 273 274 ```ts 275 import { SpreadSpace } from '@spreadspace/sdk'; 276 import type { AttributeSnapshot } from '@spreadspace/sdk'; 277 278 const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! }); 279 280 export 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 289 The versions list is at most 200 rows and is not paginated, so the client-side 290 filter 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 307 import { SpreadSpace } from '@spreadspace/sdk'; 308 309 const client = new SpreadSpace({ apiKey: process.env.SPREADSPACE_API_KEY! }); 310 declare const endpointId: string; 311 312 type Delivery = { id: string; event_ref: string; status: string }; 313 314 // `id` is the delivery attempt; `event_ref` is the event id your logs carry. 315 for 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. 320 const rotated = await client.webhooks.rotateSecret<{ signing_secret: string }>(endpointId); 321 ``` 322 323 Python: `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 355 You are done when all six hold: 356 357 1. An analyst finalizes; within a minute your table has exactly one row for that 358 `snapshot_id`, and the deal screen renders the figures. 359 2. On the delivery you captured, `SpreadSpace-Event-Id` equals the body's `id` and 360 the signature verifies against the endpoint's own secret. 361 3. Replaying that delivery answers `200 {"received":true,"duplicate":true}` and 362 your row count does not change. 363 4. A reopen stamps `reopened_at` on that row and the deal screen says "Under 364 revision". 365 5. After a rotate, the next delivery verifies against the new secret only. 366 6. "Finalized by" resolves through your own users table, from 367 `finalized_by_external_user_id`. 368 369 ## Read next 370 371 Every 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.
1 # SpreadSpace integration 2 3 Follow the `spreadspace-integration` skill for anything touching SpreadSpace: 4 receiving `spread.finalized`, persisting the frozen figures, rendering them. 5 6 Standing rules: 7 8 1. Never log the API key or a `whsec_…` signing secret, not in errors and not in traces. 9 2. The webhook receiver reads the **raw** request body bytes. Re-serialized JSON 10 never verifies. 11 3. Dedupe on the event id (`SpreadSpace-Event-Id`, equal to the body's `id`). 12 4. Persist snapshots **whole**, upserted on `snapshot_id`, which is unique. 13 5. 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 16 Docs: <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.finalizedreaches your receiver, and the frozen figures are stored in your own table keyed bysnapshot_id. - Reopen handled:
spread.reopenedmarks 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
The same loop by hand, with the real envelopes from a live run.
Every event, the envelope, delivery, replay and rotation.
The snapshot lifecycle, the payload and the manifest, key by key.
Every attribute and ratio name, its label, unit and basis.
Render the workspace inside your own product so the Finalize click happens there.