Webhooks

A signed JSON event posted to your endpoint the moment work finishes, so your backend never polls.

A webhook endpoint is a URL you own and register once. When a document finishes, a package completes, or an analyst finalizes a spread, SpreadSpace posts a small signed JSON event to it. Events carry identifiers and state, not figures: they tell you what changed and what to fetch.

Register an endpoint

A key holding the webhooks:write scope registers an endpoint with Create webhook (POST /api/webhooks, in the API reference). The body takes the destination url, an optional description, and subscribed_events, the list of types this endpoint wants.

Each entry is an event name from the table below, or *. * subscribes to every type, including ones added later, so a receiver on * must treat a type it does not recognise as opaque and ignore it rather than fail on it.

An endpoint’s subscription is whatever it was created or last updated with. An event type added to the platform later never joins an existing endpoint on its own (only * follows new types), so to change what an endpoint receives, update it with PATCH /api/webhooks/{id} and a new subscribed_events rather than registering again. Registering the same URL a second time makes a second endpoint, with its own secret and its own deliveries, and the two run side by side; the list read shows every endpoint the workspace holds.

The response carries signing_secret, a whsec_… string returned exactly once and never re-emitted. Capture it at creation; if you lose it, rotate for a new one. Afterwards a webhooks:read key can list endpoints and their deliveries, and webhooks:write covers update, rotate, delete and replay.

Verify a signature

Every delivery carries a SpreadSpace-Signature header:

SpreadSpace-Signature: t=1700000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is seconds since the Unix epoch. v1 is the lowercase hex HMAC-SHA256 of the string {t}.{raw body}, keyed with that endpoint’s signing secret. Sign over the exact bytes you received. A re-serialized JSON object will not match.

  1. Parse t and v1 out of the header.
  2. Recompute the HMAC over {t}.{raw body} with your secret.
  3. Compare with a constant-time comparison, never == on the hex strings.
  4. Reject a t more than five minutes from your own clock, in either direction.

Check the HMAC before the freshness window: that way a forged body is refused as a bad signature rather than as a stale one.

Rotation creates a new secret and returns it once, alongside previous_secret_revokes_at, which is 24 hours out. Verify against both secrets during that window and accept a delivery that satisfies either. Each individual delivery is signed with exactly one secret, pinned when it was enqueued, so a retry that began before the rotation keeps signing with the older one until it is done.

The SDKs verify and parse in one call, and raise rather than return a value you might forget to check:

1import { SpreadSpace, verifyAndParseWebhook, WebhookSignatureError } from '@spreadspace/sdk';
2
3const client = new SpreadSpace(); // reads SPREADSPACE_API_KEY
4
5try {
6 const event = verifyAndParseWebhook(rawBody, signatureHeader, 'whsec_...');
7 if (event.type === 'spread.finalized') {
8 // Read the snapshot this event names, by id. Not "the latest final",
9 // which can be a newer one under a retry or a replay.
10 const snapshot = await client.loans.attributes.retrieve(
11 event.data.loan_id, event.data.snapshot_id);
12 }
13} catch (err) {
14 if (err instanceof WebhookSignatureError) {
15 // reject with 400
16 }
17}

Receive events with the SDK

Each SDK builds a receiver that does the whole loop for you: it verifies the signature, drops duplicates, dispatches to a per-event handler, and hands back the status and body to answer with.

1const receiver = client.webhooks.receiver({
2 secret: [process.env.SPREADSPACE_WEBHOOK_SECRET!, process.env.SPREADSPACE_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
3 onSpreadFinalized: async ({ event, attributes }) => {
4 // attributes = the snapshot this event names, read for you by id (GET /api/loans/{loan_id}/attributes/{snapshot_id})
5 await db.saveFinalizedFigures(event.data.loan_id, attributes);
6 },
7 onSpreadReopened: async ({ event }) => {
8 // the analyst is revising again: the snapshot stands, but mark your copy stale
9 await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
10 },
11});
12
13// Express
14app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
15 const r = await receiver.handle({ rawBody: req.body, headers: req.headers });
16 res.status(r.status).send(r.body);
17});
18
19// Hono: const r = await receiver.handle({ rawBody: await c.req.text(), headers: c.req.raw.headers });
20// Next.js: const r = await receiver.handle({ rawBody: await req.text(), headers: req.headers });

Hand the receiver the raw bytes. A framework that parses JSON before your handler sees it has already changed the bytes the signature covers, so verification will fail on a body that was never tampered with.

The receiver answers on your behalf, and each answer means something to the retry schedule above:

SituationStatusEffect
Signature bad or outside the freshness window400Refused; no handler runs.
An event id already seen200Your handler does not run again.
No handler registered for that type200Accepted and dropped.
Your handler throws500SpreadSpace retries, and the id is not recorded as seen.
Your handler returns200The id is recorded as seen only now.

Recording the id only after your handler succeeds is what makes a retry after a crash do the work rather than skip it.

On spread.finalized the receiver reads the snapshot the event names before calling your handler. The read is GET /api/loans/{loan_id}/attributes/{snapshot_id}, by id, never the loan’s latest. The receiver passes that snapshot alongside the event as attributes, so the common case needs no second call. Under a retry or a replay it is still the snapshot that event announced, not whatever the loan holds now.

OptionTypeScriptPythonMeaning
SecretsecretsecretOne secret, or several. Pass the current and the previous secret during the 24-hour rotation window.
FreshnesstoleranceSecondstolerance_secondsHow far t may sit from your clock, either direction. Default 300.
PrefetchfetchAttributesfetch_attributesRead the snapshot the event names on spread.finalized. Default on; set it off to handle the event without the extra read.
Dedupe storededupededupeA has(id) / add(id) pair. The default is a bounded in-memory set that lives on the receiver and covers one process, so build the receiver once per process, not per request. A receiver rebuilt inside each request starts empty and never sees a duplicate. If your signing secret is loaded from storage rather than the environment, load it at startup and rebuild the receiver when you rotate. A deployment running several instances must supply a shared store either way.
Handlerson<EventName>on_*One per event type, plus onUnknown / on_unknown for a type this version of the SDK does not know.

C# takes the same options as properties on WebhookReceiverOptions, and HandleAsync(rawBody, headers, cancellationToken) returns a WebhookReceipt carrying StatusCode and Body.

The envelope

Every event body has the same six fields; only data changes shape.

FieldTypeDescription
idstringevt_ followed by 24 lowercase hex characters. Also sent as the SpreadSpace-Event-Id header.
typestringThe event name, one of the types below.
createdintegerSeconds since the Unix epoch, server-side. Seconds, not milliseconds.
tenant_idstringThe workspace the event belongs to. Reject a delivery whose tenant is not the one you registered for.
livemodebooleantrue for live activity, false for test mode.
dataobjectThe event’s own payload, per the tables below.
1{
2 "id": "evt_4b8c1d2e3f405162738495a6",
3 "type": "spread.finalized",
4 "created": 1700000000,
5 "tenant_id": "org_01HQ3M4N5P6Q7R8S9T0V1W2X",
6 "livemode": true,
7 "data": {
8 "loan_id": "9f2c41ab7e6d40128b35c7d1",
9 "borrower_id": "3ac81be40d5f47a2b9e6108c",
10 "spread_board_id": "b7d2e91f4a6c48159c03fe27",
11 "snapshot_id": "5e1a9c73b8d24f60a91d2b48",
12 "machine_snapshot_id": "c40b16f89e2d47a3850cf19b",
13 "version": 7,
14 "finalized_at": "2026-08-17T18:42:11Z"
15 }
16}

A field with no value is present as null. Keys are never dropped from a payload, so a receiver can bind the shape once.

Events

Eight event types exist today. The tables below are generated from the same contract the API reference renders, so a payload record that gains a field gains a row here.

The two spread events are a pair. spread.finalized says the figures froze and names the snapshot to read; spread.reopened says that same snapshot is still on record but the analyst is revising the spread again. Mark your copy stale, and expect a new spread.finalized with a higher version. Neither carries a figure.

EventWhen it fires
document.failedFires when a document is rejected, hits an unrecoverable extraction error, or is resolved as a duplicate of one already on the deal. reason carries the short code or message.
document.processedFires once per document, after its extraction has been stored. Carries routing identifiers only, so read the extracted figures back through the API by document_id.
extraction.readyFires once per document, the moment its extraction is queryable in the shape you should build on, and the usual cue to pull line items. For a bank statement it is held back until counterparty enrichment lands, or until the grace window elapses without it (enrichment says which).
extraction.updatedFires when a stored extraction is rewritten in place after extraction.ready already fired for that document: what you fetched is stale. Re-fetch and compare extraction_version against previous_extraction_version.
job.completedTerminal event for a document package: fires exactly once, when every document in it has reached a terminal state, success or failure.
loan.classifiedFires once per loan, after every document in the package has been classified.
spread.finalizedFires once per finalize: an analyst finalized a spread, the final and machine attribute snapshots were created, and the board was locked. The payload carries ids, not figures. Fetch the figures from GET /api/loans/{loanId}/attributes/{snapshotId}.
spread.reopenedFires once per reopen: an analyst reopened a finalized spread to edit it. The final snapshot it points at is kept and unchanged, but the figures are under revision until the next spread.finalized, so mark your copy stale. The payload carries ids, not figures.

document.failed

FieldTypeDescription
document_idstringThe extracted document.
job_idstringThe document package (job) the document was uploaded in.
borrower_idstringThe borrower that owns the loan.
loan_idstringThe loan the document belongs to.
reasonstringShort failure code or message, for example extraction_error, a rejection code, or free text.
failed_atstring (date-time)Server-side wall-clock time the failure was recorded.

document.processed

FieldTypeDescription
document_idstringThe extracted document, a 24-character identifier.
job_idstringThe document package (job) the document was uploaded in.
borrower_idstringThe borrower that owns the loan.
loan_idstringThe loan the document belongs to.
document_typestringWhat the document was classified as, for example bank_statement, w2 or 1120s.
classification_confidencenumber, nullableConfidence in document_type, from 0 to 1.
processed_atstring (date-time)Server-side wall-clock time the extraction finished and was persisted.
extraction_idstring, nullableThe extraction record to fetch.
extraction_versioninteger, nullableThe revision of the stored extraction at the moment this event fired, 1 for a freshly ingested document.

extraction.ready

FieldTypeDescription
extraction_idstringThe extraction record to fetch.
document_idstringThe extracted document.
job_idstringThe document package (job) the document was uploaded in.
borrower_idstringThe borrower that owns the loan.
loan_idstringThe loan the document belongs to.
document_typestringWhat the document was classified as, using the same values document.processed reports.
classification_confidencenumber, nullableConfidence in document_type, from 0 to 1.
extraction_versioninteger, nullableThe revision of the stored extraction this event announces, 1 as ingested and incremented on every later rewrite.
enrichmentstringEnrichment state of the extraction this event announces.
ready_atstring (date-time)Server-side wall-clock time the extraction was made queryable.

extraction.updated

FieldTypeDescription
extraction_idstringThe extraction record to fetch.
document_idstringThe extracted document.
job_idstringThe document package (job) the document was uploaded in.
borrower_idstringThe borrower that owns the loan.
loan_idstringThe loan the document belongs to.
document_typestringWhat the document was classified as, using the same values the sibling events report.
extraction_versionintegerThe revision the stored extraction is at now, which is what a fresh fetch returns.
previous_extraction_versionintegerThe revision that was current before this rewrite (the one ready may have announced).
reasonstringWhy the extraction changed: enrichment when an enrichment landed, or rewrite for any other change to the stored payload.
updated_atstring (date-time)Server-side wall-clock time the rewrite was persisted.

job.completed

FieldTypeDescription
job_idstringThe document package (job) that finished.
borrower_idstringThe borrower that owns the loan.
loan_idstringThe loan the package was uploaded against.
document_count_totalintegerTotal documents in the package.
document_count_succeededintegerDocuments that reached a terminal success state.
document_count_failedintegerDocuments that terminated in failure.
completed_atstring (date-time)Server-side wall-clock time the job entered its terminal state.

loan.classified

FieldTypeDescription
loan_idstringThe loan whose documents were classified.
borrower_idstringThe borrower that owns the loan.
job_idstringThe document package (job) this event is for.
document_countintegerNumber of documents classified for this package.
classified_atstring (date-time)Server-side wall-clock time classification completed.

spread.finalized

FieldTypeDescription
loan_idstringThe loan whose spread was finalized.
borrower_idstringThe borrower that owns the loan.
spread_board_idstringThe saved spread board that was finalized.
snapshot_idstringThe final snapshot this finalize created.
machine_snapshot_idstringThe machine snapshot created with it: the same loan built from the documents alone, with no analyst input.
versionintegerThe final snapshot’s per-loan version number.
finalized_atstring (date-time)Server-side wall-clock time of the finalize.

spread.reopened

FieldTypeDescription
loan_idstringThe loan whose spread was reopened.
borrower_idstringThe borrower that owns the loan.
spread_board_idstringThe saved spread board that was reopened.
snapshot_idstringThe final snapshot that was in force when the board was reopened.
versionintegerThat snapshot’s per-loan version number.
reopened_atstring (date-time)Server-side wall-clock time of the reopen.

Delivery

Delivery is at-least-once. The same event can arrive more than once, either as a retry after a slow response on your side or as a duplicate you should absorb. Dedupe on the envelope id, which is also the SpreadSpace-Event-Id header: it is stable across every retry and every replay of that event, and is shared by all endpoints one event fans out to. Record it, and make a second arrival a no-op.

A delivery is queued the moment the event is recorded and sent typically within seconds; allow up to a minute. It is then attempted up to 12 times within a 24-hour horizon, with each attempt allowed 5 seconds to answer. Backoff doubles from one second: 2^(n-1) seconds before attempt n.

Your responseWhat happens
Any 2xxDelivered. No further attempts.
429Retried.
Any other 4xxStopped. No further attempts for that delivery.
5xx, timeout, network errorRetried.

Answer fast and do the work afterwards: acknowledge with a 2xx as soon as you have durably recorded the event, then process it out of band. A handler that finishes its work before answering risks the 5-second cutoff and an avoidable duplicate. The SDK receiver above (Receive events with the SDK) implements the dedupe-and-answer half of this for you.

Delivery order is not guaranteed. Two events emitted in a known order can arrive in the other one, and a retry can arrive after a later event. Reconcile rather than assume: created gives the server-side ordering, and for extractions extraction_version says which revision a payload describes. extraction.updated also carries previous_extraction_version, so you can tell a step forward from a duplicate.

The event set is fixed even though delivery is not: document.processed then extraction.ready are each emitted once per document, extraction.updated only after a ready has already fired for that document, job.completed once per package, and spread.finalized is a pointer. It names the snapshots and never carries the figures.

List deliveries (GET /api/webhooks/{id}/deliveries) shows what happened to each one. A delivery is pending while attempts remain, delivered once a 2xx came back, and dead when it stopped: attempts exhausted, the horizon passed, or a 4xx that is not 429. Filter by status, event_type or loan_id. Every row carries the loan_id its event was about, so “what did you send for this loan” is one call when a single deal’s figures are missing.

Each row carries two identifiers, and they are not the same thing. id is that delivery attempt’s own id, the one to hand to a replay. event_ref is the event id as delivered: the envelope’s id and the SpreadSpace-Event-Id header. Join on event_ref to line this list up against your own receiver logs.

Replay

Replay delivery (POST /api/webhooks/{id}/deliveries/{deliveryId}/replay) schedules a fresh delivery of an event you already have a record of. It creates a new delivery with its own id, carrying the same event id, signed with the endpoint’s current secret.

Because the event id is unchanged, a receiver that deduplicates properly will recognise the replay and ignore it. Replay is therefore for deliveries that never arrived or were never acknowledged. It is not a way to re-run a handler that already succeeded. To reprocess an event on purpose, clear your record of that id first.

Destinations

A destination must be a public https URL. Private and internal addresses are refused when the endpoint is registered, and checked again at connect time, so an address that resolves inward later is refused then too. Redirects are not followed: point the endpoint at the URL that will handle the request.

The rule is the same for a test-mode key as for a live one, so to exercise a receiver running on your own machine, expose it through a tunnel (cloudflared, ngrok) and register the tunnel’s https URL.

Every delivery arrives with User-Agent: SpreadSpace-Webhooks/1.

Testing

Work generated by an ss_test_ key carries livemode: false. Register a separate endpoint for it, or branch on livemode and route test deliveries to a staging handler. The envelope, the signature and the retry behaviour are identical either way.

Replay is the cheapest way to exercise a receiver against a real event: take a delivery from the list and replay it as often as you need, with your dedupe record cleared between runs.