Webhooks
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:
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.
- Parse
tandv1out of the header. - Recompute the HMAC over
{t}.{raw body}with your secret. - Compare with a constant-time comparison, never
==on the hex strings. - Reject a
tmore 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:
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.
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:
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.
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.
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.
document.failed
document.processed
embed.session.created
embed.session.revoked
export.cancelled
export.failed
export.succeeded
extraction.mapped
extraction.ready
extraction.updated
job.completed
loan.classified
memo.finalized
memo.reopened
memo_render.cancelled
memo_render.failed
memo_render.succeeded
spread.finalized
spread.reopened
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.
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.