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 above:
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. 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.
document.failed
document.processed
extraction.ready
extraction.updated
job.completed
loan.classified
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. Backoff
doubles from one second: 2^(n-1) seconds before attempt n.
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.