Skip to navigation

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:

import { SpreadSpace, verifyAndParseWebhook, WebhookSignatureError } from '@spreadspace/sdk';
const client = new SpreadSpace(); // reads SPREADSPACE_API_KEY
try {
const event = verifyAndParseWebhook(rawBody, signatureHeader, 'whsec_...');
if (event.type === 'spread.finalized') {
// Read the snapshot this event names, by id. Not "the latest final",
// which can be a newer one under a retry or a replay.
const snapshot = await client.loans.attributes.retrieve(
event.data.loan_id, event.data.snapshot_id);
}
} catch (err) {
if (err instanceof WebhookSignatureError) {
// reject with 400
}
}

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.

const receiver = client.webhooks.receiver({
secret: [process.env.SPREADSPACE_WEBHOOK_SECRET!, process.env.SPREADSPACE_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
onSpreadFinalized: async ({ event, attributes }) => {
// attributes = the snapshot this event names, read for you by id (GET /api/loans/{loan_id}/attributes/{snapshot_id})
await db.saveFinalizedFigures(event.data.loan_id, attributes);
},
onSpreadReopened: async ({ event }) => {
// the analyst is revising again: the snapshot stands, but mark your copy stale
await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
},
});
// 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);
});
// Hono: const r = await receiver.handle({ rawBody: await c.req.text(), headers: c.req.raw.headers });
// 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:

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.
{
"id": "evt_4b8c1d2e3f405162738495a6",
"type": "spread.finalized",
"created": 1700000000,
"tenant_id": "org_01HQ3M4N5P6Q7R8S9T0V1W2X",
"livemode": true,
"data": {
"loan_id": "9f2c41ab7e6d40128b35c7d1",
"borrower_id": "3ac81be40d5f47a2b9e6108c",
"spread_board_id": "b7d2e91f4a6c48159c03fe27",
"snapshot_id": "5e1a9c73b8d24f60a91d2b48",
"machine_snapshot_id": "c40b16f89e2d47a3850cf19b",
"version": 7,
"finalized_at": "2026-08-17T18:42:11.284016+00:00"
}
}

A field with no value is present as null, and every key is always present, so a receiver can bind the shape once. The one exception is message on document.failed: it is present only when there is a sentence you can show the uploader, so bind it as optional.

Events

There are seventeen event types. 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.

The export events and the memo-render events come in matching triples: export.succeeded, export.failed and export.cancelled for an export, and memo_render.succeeded, memo_render.failed and memo_render.cancelled for a credit memo render. They are terminal, so exactly one of the three fires per export or per render, and each carries identifiers and the timestamp it reached that state. On the failed pair, reason carries the operation’s error code. None of the six carries a download link: fetch the export or the rendered PDF yourself when one arrives.

The two credit memo events are a pair in the same way as the spread events. memo.finalized says an analyst finalized the memo and its final PDF is ready to pull from GET /api/loans/{loan_id}/memos/{memo_id}/pdf; memo.reopened says that same PDF is still on record but the analyst is editing the memo again. Mark your copy stale, and expect a new memo.finalized when they finish. Both carry the memo, loan, borrower and render ids, and neither carries the memo itself. On memo.reopened, render_id is null when the memo has no final PDF to name. Both also carry spread_snapshots, the spread figures the memo was approved on: one entry per entity its figure exhibits cite, each the final attribute snapshot’s snapshot_id, its version and the entity (key and name), and an empty list when the memo has none. They are pointers, not figures: read the snapshot by id. See Credit memos.

extraction.mapped fires once for every mapping of a profit and loss statement or a balance sheet onto the chart of accounts: the statement’s first mapping, and each one made again after its extraction, the workspace’s chart of accounts, caption rules or export map, or the mapping logic changed, or the statement was corrected in the workspace. reason says which: first, extraction, chart, logic or correction. statement says which statement was mapped, profit_and_loss or balance_sheet, and so which read returns the mapping. It is a pointer in the same way as the spread events: the document, its borrower and loan, the mapping_id, status and mapping_state, and the versions the mapping was made under, never a figure. Read the mapping from GET /api/borrowers/{borrower_id}/extractions/{doc_id}/profit-loss-mapping for a profit and loss statement, or from GET /api/borrowers/{borrower_id}/extractions/{doc_id}/balance-sheet-mapping for a balance sheet, and let it replace the copy you hold. It is sent to a workspace whose plan includes statement mapping. See Statement mapping.

On document.failed, reason is unextractable when the document could not be extracted. When there is a sentence you can show the uploader, message carries it, for example for a bank statement that did not include all of its pages.

A document resolved as a duplicate carries its duplicate resolution in reason instead, for example content_hash_duplicate, exact_duplicate or subset_superseded. A duplicate resolution is not an extraction error; the content is already on the loan under the original document. Any other reason is a short code or message.

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. message, when present, is a sentence you can show the uploader.
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.
embed.session.createdFires once when a signed iframe URL is exchanged for an embed session. session_id is the id DELETE /api/embed/sessions/{session_id} takes and handle_id is the handle your server recorded at the mint. The payload carries ids and times, never a token.
embed.session.revokedFires once when an embed session is revoked, by its own DELETE or through its handle’s. The token stops working at once. handle_id is null for a session minted directly.
export.cancelledFires once when an export reaches cancelled, either straight from queued or at the document boundary where the worker acknowledged the request.
export.failedFires once when an export ends in failure and no bundle will be produced. reason is the export’s error code.
export.succeededFires once when an export’s bundle is ready to download. Fetch the export to get a fresh download link; the event never carries one.
extraction.mappedFires once for every mapping of a profit and loss statement or a balance sheet onto the chart of accounts: the statement’s first mapping, and each one made again after its extraction, the workspace’s chart of accounts, caption rules or export map, or the mapping logic changed, or the statement was corrected in the workspace. The payload carries ids and versions, not figures. Fetch the mapping from the read statement names: GET /api/borrowers/{borrower_id}/extractions/{doc_id}/profit-loss-mapping for a profit and loss statement, GET /api/borrowers/{borrower_id}/extractions/{doc_id}/balance-sheet-mapping for a balance sheet. Sent to workspaces whose plan includes the Statement mapping feature.
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 document package (job), after every document in the package has been classified.
memo.finalizedFires once per finalize: an analyst finished a credit memo, it was locked, and its final PDF was made. The payload carries ids, not the memo. Pull the PDF from GET /api/loans/{loan_id}/memos/{memo_id}/pdf.
memo.reopenedFires once per reopen: an analyst reopened a finalized credit memo to edit it. The final PDF it points at is kept and unchanged, but the memo is under revision until the next memo.finalized, so mark your copy stale. The payload carries ids, not the memo.
memo_render.cancelledFires once when a queued credit memo render is cancelled before it starts.
memo_render.failedFires once when a credit memo’s PDF render ends in failure and no PDF was produced. reason is the render’s error code.
memo_render.succeededFires once when a credit memo’s PDF render finishes. Pull the PDF through the memo’s own download endpoint; the event never carries a link.
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/{loan_id}/attributes/{snapshot_id}.
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.
reasonstringunextractable when the document could not be extracted, whatever the cause; message then carries a sentence you can show the uploader when there is one.
messagestring, nullableA sentence you can show the uploader, present only when the failure has one, for example a bank statement that did not include all of its pages.
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.
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.

embed.session.created

FieldTypeDescription
session_idstringThe embed session the exchange minted: the id DELETE /api/embed/sessions/{session_id} takes.
handle_idstringThe iframe handle your server recorded at the mint.
loan_idstringThe loan the session is bound to.
borrower_idstringThe borrower that owns the loan.
external_user_idstring, nullableThe external_user_id the mint named; null when it named none.
expires_atstring (date-time)When the session’s token stops working on its own.
created_atstring (date-time)Server-side wall-clock time of the exchange.

embed.session.revoked

FieldTypeDescription
session_idstringThe embed session that was revoked.
handle_idstring, nullableThe iframe handle the session was exchanged from; null for a session minted directly.
loan_idstringThe loan the session was bound to.
borrower_idstringThe borrower that owns the loan.
external_user_idstring, nullableThe external_user_id the mint named; null when it named none.
revoked_atstring (date-time)Server-side wall-clock time of the revoke.

export.cancelled

FieldTypeDescription
export_idstringThe export that was cancelled.
borrower_idstringThe borrower the requested documents belong to.
loan_idstringThe loan the requested documents sit on.
formatstringThe bundle format the export was created with.
cancelled_atstring (date-time)Server-side wall-clock time the export reached cancelled.

export.failed

FieldTypeDescription
export_idstringThe export that failed.
borrower_idstringThe borrower the requested documents belong to.
loan_idstringThe loan the requested documents sit on.
formatstringThe bundle format the export was created with.
reasonstringThe export’s error code.
failed_atstring (date-time)Server-side wall-clock time the export failed.

export.succeeded

FieldTypeDescription
export_idstringThe export that finished.
borrower_idstringThe borrower the exported documents belong to.
loan_idstringThe loan the exported documents sit on.
formatstringThe bundle format the export was created with.
document_countintegerHow many documents landed in the bundle.
succeeded_atstring (date-time)Server-side wall-clock time the export succeeded.

extraction.mapped

FieldTypeDescription
document_idstringThe document that was mapped.
statementstringThe statement that was mapped: profit_and_loss, whose mapping the profit and loss mapping read returns, or balance_sheet, whose mapping the balance sheet mapping read returns.
borrower_idstringThe borrower the document belongs to.
loan_idstring, nullableThe loan the document belongs to.
extraction_versionintegerThe revision of the stored extraction the mapping was made from.
mapping_idstringThe mapping this event announces, the mapping_id the mapping read returns.
statusstringready when the statement was mapped, or unmappable when it was read and could not be mapped.
mapping_statestring, nullablemapped when the statement’s lines are filed on the chart of accounts, or unfooted when they are returned as printed because they do not walk to the statement’s bottom line.
engine_versionstringThe version of the mapping logic that made the mapping.
chart_versionstring, nullableThe version of the platform chart of accounts the mapping’s accounts come from.
chart_document_versionintegerThe version of the workspace’s chart of accounts the mapping was made under, the version that GET /api/chart-of-accounts returns.
chart_rules_versionintegerThe version of the workspace’s caption rules the mapping was made under, the rules_version that GET /api/chart-of-accounts returns.
reasonstringWhy the mapping was made: first for the document’s first mapping, otherwise what changed since its previous one.
mapped_atstring (date-time)When the mapping was made, the created_at the mapping read returns.

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.
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: complete (the enrichment is already in what you can fetch now), not_applicable (no enrichment applies to this document, so it is final), or timed_out (enrichment was expected but did not land within the grace window, so you are reading the un-enriched extraction and an extraction.updated follows if it lands later).
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.

memo.finalized

FieldTypeDescription
memo_idstringThe memo that was finalized.
loan_idstringThe loan the memo is saved on.
borrower_idstringThe borrower that owns the loan.
render_idstringThe render that made the final PDF, named by the render id this API serves everywhere else.
finalized_atstring (date-time)When the memo was finalized.
spread_snapshotsarrayThe spread figures the memo was approved on: one entry per entity its figure exhibits cite, naming the final attribute snapshot, its version and the entity.

memo.reopened

FieldTypeDescription
memo_idstringThe memo that was reopened.
loan_idstringThe loan the memo is saved on.
borrower_idstringThe borrower that owns the loan.
render_idstring, nullableThe render behind the final PDF that was in force when the memo was reopened.
reopened_atstring (date-time)Server-side wall-clock time of the reopen.
spread_snapshotsarrayThe spread figures the memo was approved on when it was reopened, the same entries its memo.finalized carried.

memo_render.cancelled

FieldTypeDescription
render_idstringThe render that was cancelled.
memo_idstringThe saved memo the render was for.
loan_idstringThe loan the memo sits on.
cancelled_atstring (date-time)Server-side wall-clock time the render reached cancelled.

memo_render.failed

FieldTypeDescription
render_idstringThe render that failed.
memo_idstringThe saved memo the render was for.
loan_idstringThe loan the memo sits on.
reasonstringThe render’s error code.
failed_atstring (date-time)Server-side wall-clock time the render failed.

memo_render.succeeded

FieldTypeDescription
render_idstringThe render that finished.
memo_idstringThe saved memo that was rendered.
loan_idstringThe loan the memo sits on.
succeeded_atstring (date-time)Server-side wall-clock time the render succeeded.

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. Retries fall at fixed offsets from the moment the delivery was queued: 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, then 1, 2, 4, 8, 16 and 24 hours. A 429 or 503 that carries Retry-After is retried no sooner than it asks; a Retry-After past the horizon ends the delivery. A delivery that runs out of attempts is dead; replay it once your endpoint is back.

Your responseWhat happens
Any 2xxDelivered. No further attempts.
Any 3xxRetried. Redirects are not followed (Destinations).
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.

What each event means is fixed even though delivery order 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. The export and render events are terminal in the same way: one of export.succeeded, export.failed or export.cancelled per export, one of the three memo_render.* events per render, and each of them names the resource to read rather than carrying its file.

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, a 4xx that is not 429, an attempt that came due while the endpoint was paused, or the endpoint deleted. 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/{delivery_id}/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

An upload through an ss_test_ key runs in the sandbox, and the four document events it raises (document.processed, extraction.ready, loan.classified, job.completed) are delivered to your live workspace: every endpoint registered there and subscribed to those types receives them, each with livemode: false and a tenant_id naming the live workspace. Register that endpoint from the dashboard (Settings → Live API, the Webhooks tab) or with a live key. There is no test-only endpoint: an endpoint receives the live and the sandbox deliveries for every type it subscribes to, so branch on livemode to route sandbox deliveries to a staging handler. The envelope, the signature and the retry behaviour are identical either way.

A registration made with a test key lands in the sandbox workspace, not the live one. The test key’s list read shows only those endpoints, and they receive only what is raised inside the sandbox workspace. Uploads raise nothing there, so a test-key registration never receives the upload events above. It does receive the events of work the test key starts in the sandbox itself: an export or a memo render started with the test key raises its terminal export.* or memo_render.* event there, with livemode: false, and a spread finalized or reopened in the sandbox raises spread.finalized or spread.reopened there.

Replay works in whichever workspace holds the endpoint. To replay a sandbox upload event, list the live endpoint’s deliveries with a live key or from the dashboard, then replay the delivery as often as you need, with your dedupe record cleared between runs.