The delivery envelope
Every delivery is the same object. The payload core nests underdata. It never sits at the top
level, so a handler that reads body.contract reads undefined on every event.
Delivery
Eighteen event types in nine families
Here are the literal strings, so you can write aswitch straight off this page:
origin rides on six payload cores, and only these six: contract.parsed, letter.received,
reply.sent, contract.executed, contract.cancelled and letter.imported. It says which hand held the
pen. It is provenance, never authority, so nothing in permissions, ready-checks or signing may read it.The pairing with contract.parsed is deliberate. contract.parsed is about a contract existing and fires
once per successful parse. import.completed is about a job finishing and fires once per import in a
batch. Counting a 100-file upload means counting import.completed plus import.failed to 100.
Reacting to a new contract means listening to contract.parsed.import.failed and ingress.detected carry no contract, and they are the only two that do not.
They are org-stream events: scoped to your org rather than to a contract. See
The org stream below for the three ways they differ from the other sixteen.Who receives what: the delivery boundary
A webhook fan-out is a read with no person sitting behind it, so the page has to say whose eyes render it. Five rules, and together they are the whole answer.- An endpoint belongs to an org key, and that key names an acting person. The person is fixed when you register the endpoint and stored on it. There is no anonymous delivery.
- Deliveries are filtered to contracts where your org holds a party row. No party row, no delivery, whatever events the endpoint subscribed to.
- Every payload is rendered on the same seat the equivalent GET would use, using the endpoint’s acting person. A webhook can never carry a field that seat’s own read would withhold. This is not a filter written twice. It is the same function.
- No org on your side means no delivery. A guest holds no org and no key, so a guest never receives webhooks. That is the guest contract: one contract, one link, no standing subscription.
- A stream can go quiet mid-negotiation. If the endpoint’s acting person loses access to a contract,
deliveries for that contract stop. Re-read the contract when a stream goes silent. You will get a
404if your seat is gone.
One round, two names
The log holds a singleletter.sent atom. It reaches the sending side as reply.sent (“your side
committed a round”) and the receiving side as letter.received (“an inbound round lands on your
contract”), with different payload cores. The event set is rendered per subscribing side.
The one every integrator must handle
words.changed
signatures_voided[] collect a fresh signature
against the new hash. Never migrate consent onto text nobody saw.
Signature verification
Every delivery carries:{timestamp}.{raw_body}, keyed with your endpoint secret, with a five-minute
tolerance.
verify.py
v1 matches any secret you hold. That is the rotation contract: the overlap runs
for 24 hours from POST /webhooks/endpoints/{id}/rotate, both secrets sign every delivery in that
window, and the old one stops signing when the window closes. Hold both, verify against both, drop the
old one after a day.
Delivery, retries and dedupe
Delivery is at-least-once. You will see the same event twice.- Retries are independent. A failed delivery does not pause the contract’s stream. Later events keep flowing while an earlier one is still being retried.
sequenceis an ordering hint. Several events can share one sequence, because one append to the log can fan out into several deliveries.reply.sent,words.changedandcontract.agreedall derive from a single committed round. A retry can also deliver a lower sequence after a higher one. Usesequenceto order what you hold and to ask for what you are missing. Never use it to drop.- Each delivery attempt is signed fresh at send time. The five-minute tolerance is measured against the attempt, never against the event. Without that rule, an endpoint that was down for eight minutes would reject every later retry as a forgery, forever, because the replayed timestamp sits outside the window.
The retry schedule
Exponential backoff over 24 hours, 12 attempts: roughly 10s, 30s, 1m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 16h, 24h. After 72 consecutive hours of failure across every event, the endpoint is disabled and your org admin gets an email. A disabled endpoint returnsstate: "disabled" on
GET /webhooks/endpoints/{id}, and you revive it by rotating its secret or registering it again.
After the budget: the catch-up door
Once the retry budget is spent, the events are still there. Walk the contract’s own log:200 OK
updated_since on
GET /contracts:
head_sequence, the highest sequence in that contract’s log. Compare it against
the last sequence you processed, then walk /events from there.
The org stream
Sixteen of the eighteen events are about a contract. Two are not:import.failed mints no contract, and
ingress.detected describes a mail thread that is not a contract yet. Those two are org-stream
events, and they differ from the rest in exactly three ways.
They carry no sequence. Not zero, not null, not an org-wide counter. The field is absent from the
envelope, because sequence means a position in one contract’s log and these events sit in no log. A
handler that reads sequence as a number on every event reads undefined on these two.
Their scope is the org, not a party row. The delivery boundary below filters on a party row on a
contract, and there is no contract here to hold one. Every active endpoint in the org receives them.
They are walked back through an org-scoped door. GET /contracts/{id}/events cannot serve them,
because there is no id to put in the path. Use GET /events:
200 OK
next_cursor is
the id to hand back as starting_after.
Everything else is unchanged. Dedupe on the event
id, exactly as you do for the other sixteen, so
walking this door twice costs you nothing and walking it too far is free. The same handler serves both
surfaces here too: a row from GET /events is the same object as a live delivery, minus endpoint.Managing endpoints
All seven calls take thewebhooks:manage scope and all seven require Contracts-Acting-Person. That
header is what fixes the endpoint’s acting person, which is what makes the delivery boundary above
enforceable.
endpoint
Scopes:
webhooks:manageRequest
201 Created
["*"] for every event.
The registration handshake
Registering a URL does not make it trusted. Before the201 comes back, we send that URL a signed
verification ping carrying a nonce, and your handler has to echo the nonce back:
Verification ping
2xx with the nonce in the body, and nothing else is required:
Your answer
active. Anything else and it stays pending: a 2xx with no nonce, a
non-2xx, a URL nothing answers on, or an answer that arrives after the nonce has expired. The window
is five minutes, the same tolerance the signature check uses.
The ping is signed with the endpoint secret, like every other delivery, and it is the one delivery that
reaches you before the 201 that carries that secret. So the first ping is the only one you cannot
verify against a secret you hold. The nonce is what carries the proof: a caller who cannot read the ping
cannot echo the value inside it. Every delivery after that one, ping or event, is signed with a secret
you have and should be verified normally.
endpoint
Scopes:
webhooks:manage · retries the handshake with a fresh nonce200 OK
verification.reason says which of the four ways it failed:
endpoint_unreachable, endpoint_returned_{status}, nonce_not_echoed, or nonce_expired.
endpoint
Scopes:
webhooks:manage · sends one delivery on demand200 OK
endpoint.test delivery so you can exercise a handler without waiting for a real
contract to move. Like the verification ping, it is a control delivery: signed the same way, carrying
no sequence, belonging to none of the eighteen types, and folding into nothing. Subscribing to ["*"]
has no bearing on receiving it. Returns 409 endpoint_pending on an endpoint that has not verified yet.
endpoint
Scopes:
webhooks:manage · returns { "data": [ ... ], "has_more": false }endpoint
Scopes:
webhooks:manage · the endpoint with no secret, plus last_delivery and
consecutive_failuresendpoint
Scopes:
webhooks:manage · returns 204endpoint
Scopes:
webhooks:manage200 OK
endpoint
Scopes:
webhooks:manageRequest
{ "since": "2026-08-24T07:00:00Z" }. Returns 202 { "queued": 2 }.
Replay re-sends deliveries your endpoint already missed, signed fresh at replay time. It is capped at
7 days of history and 1,000 events per call.
Replay is not a substitute for
GET /contracts/{id}/events, which is rendered on your seat and has
no retention cap. Reach for replay when your handler was broken for an hour. Reach for the contract’s
event log when you need to be certain you have everything.sk_test_ key registers endpoints in the sandbox org, where letters never leave the building.
Not events, and why
decision.staged is the important absence. If you need to know your own side staged something, read
GET /contracts/{id}/reply. The counterparty must never be able to infer it, and a webhook is a place
that could leak.