Skip to main content
Draft — ahead of the backend; nothing here is live yet. This surface is written against the design, not against a running server. There is no api.contracts.io to call, no key to issue, and every response below is an illustration rather than a recording. It is published so the shape can be argued with before it is built.It also predates CONTRACT-SCHEMA v0 and still uses the older nouns — ask for Proposal, receipt for Version, relationship for Room. The glossary maps them.
Webhooks and polling are the whole realtime story. There is no presence stream, no cursor feed, and no document socket. A room is not a Figma file.

The delivery envelope

Every delivery is the same object. The payload core nests under data. 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 a switch straight off this page:
The signature event is signature.void, not signature.voided. Every state a signature can reach has its own event: created, held, bound, void, withdrawn. That is five type strings, not one collapsed row, and it means you can hold signature state without polling.
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.
certificate_url on contract.executed is a signed link that lives fifteen minutes. The retry schedule below runs twelve attempts over twenty-four hours and the payload is not reminted between them, so any attempt after the fifth carries a dead link, and so does the same event read back from the catch-up door a day later. Treat the field as a convenience for a delivery you handled promptly. The record itself is a place: fetch GET /contracts/{id}/certificate whenever you need it, and it mints a fresh link every time.
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.
  1. 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.
  2. Deliveries are filtered to contracts where your org holds a party row. No party row, no delivery, whatever events the endpoint subscribed to.
  3. 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.
  4. 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.
  5. 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 404 if your seat is gone.

One round, two names

The log holds a single letter.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.
If you serve both sides of one contract, you receive both names for one round, and a naive handler records two rounds where one happened.The two deliveries carry different event ids, because they are two renderings and each is deduped on its own. Dedupe will not collapse them for you.Both names carry the same letter id. Collapse on that. The same holds for signature events on a contract with an org on each side: one press, two deliveries, one signature id.

The one every integrator must handle

words.changed. It is the API’s version of the app’s “the words changed, sign the updated contract” row, and it is the only event that can invalidate consent you already collected.
words.changed
On receipt: re-fetch the contract, and for every id in signatures_voided[] collect a fresh signature against the new hash. Never migrate consent onto text nobody saw.

Signature verification

Every delivery carries:
HMAC-SHA256 over {timestamp}.{raw_body}, keyed with your endpoint secret, with a five-minute tolerance.
Collect every v1 value. Do not parse the header into a dictionary. During a secret rotation the header carries one v1= per active secret, and a dictionary keeps only the last one, so half your deliveries fail verification for a day and you cannot tell why.
verify.py
Accept if any one 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.
Verify against the raw request body, before any JSON parsing or re-serialization. A round-trip through your JSON library will change the bytes and break the signature.

Delivery, retries and dedupe

Delivery is at-least-once. You will see the same event twice.
Dedupe on the event id, and only on the event id. Never drop an event because its sequence is one you have already seen.An earlier version of this page told you to persist the last sequence per contract and drop anything at or below it. That rule silently loses events and is deleted. Here is what it did: words.changed fails, contract.agreed succeeds, the retry of words.changed arrives behind it and your own rule drops it, signatures_voided[] is never processed, and you report a signed offer whose signature is actually void.
  • 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.
  • sequence is an ordering hint. Several events can share one sequence, because one append to the log can fan out into several deliveries. reply.sent, words.changed and contract.agreed all derive from a single committed round. A retry can also deliver a lower sequence after a higher one. Use sequence to 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 returns state: "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
Payloads are rendered on your seat, exactly as a delivery would be, and there is no retention cap. To find out which contracts moved while you were down, use updated_since on GET /contracts:
Every contract read carries 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
The cursor is an event id, not a sequence, for the same reason the envelope carries no sequence. Rows come back oldest first, which is the only order an id cursor can resume from, and 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 the webhooks: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:manage
Request
201 Created
Pass ["*"] for every event.
secret is shown once, on create and on rotate, and there is no call that hands it back. A secret you can re-read is a secret that leaks through an audit log. Store it when you see it. If you lose it, rotate.

The registration handshake

Registering a URL does not make it trusted. Before the 201 comes back, we send that URL a signed verification ping carrying a nonce, and your handler has to echo the nonce back:
Verification ping
Answer 2xx with the nonce in the body, and nothing else is required:
Your answer
Echo it and the endpoint is 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.
A pending endpoint receives nothing. Not the events it subscribed to, not ["*"], nothing. This is the point of the handshake: a typo in the URL now fails at registration, instead of looking exactly like a working endpoint until someone notices the events never arrived.
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 nonce
200 OK
The call to make once you have fixed the typo, deployed the handler you had not written at registration time, or let a nonce expire. When it fails, 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 demand
200 OK
Sends your endpoint an 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_failures
endpoint
Scopes: webhooks:manage · returns 204
endpoint
Scopes: webhooks:manage
200 OK
endpoint
Scopes: webhooks:manage
Request
Or { "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.
A 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.