> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contracts.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Eighteen event types, HMAC-SHA256 signing with a five-minute tolerance, at-least-once delivery, deduped on the event id.

<Warning>
  **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](/glossary) maps them.
</Warning>

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.

```http Delivery theme={null}
POST https://your-app.example.com/hooks/contracts
Contracts-Signature: t=1756032000,v1=5f3c...
Content-Type: application/json

{
  "id": "evt_19",
  "type": "letter.received",
  "sequence": 19,
  "created": "2026-08-24T09:12:00Z",
  "endpoint": "whe_2",
  "data": {
    "contract": "con_1",
    "letter": "let_2",
    "asks": [],
    "relationship": "rel_4",
    "origin": "api",
    "words_hash": "sha256:be71...",
    "state": "negotiating"
  }
}
```

| Field      | What it is                                                                                                                                        |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`       | The event id. **Dedupe on this, and only on this.**                                                                                               |
| `type`     | One of the eighteen strings below.                                                                                                                |
| `sequence` | The contract's log sequence. An ordering hint, never a drop rule. **Absent entirely on the two org-stream events**, which sit in no contract log. |
| `created`  | When the event happened, not when this attempt was sent.                                                                                          |
| `endpoint` | The endpoint this delivery went to, so one handler can serve several.                                                                             |
| `data`     | The payload core for that `type`.                                                                                                                 |

## Eighteen event types in nine families

Here are the literal strings, so you can write a `switch` straight off this page:

```
letter.received · letter.imported · reply.sent · contract.agreed · contract.cancelled ·
contract.executed · contract.parsed · words.changed · signature.created · signature.held ·
signature.bound · signature.void · signature.withdrawn · ask.withdrawn ·
party.changed · import.completed · import.failed · ingress.detected
```

| Event                 | Fires when                                               | Payload core                                                                                                                                                 |
| --------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `letter.received`     | An inbound round lands on your contract                  | `contract`, `letter`, `asks[]`, `relationship`, `origin`, `words_hash`, `state`                                                                              |
| `letter.imported`     | Prior correspondence was seeded onto a contract          | `contract`, `letter`, `authored_at`, `author`, `asset`. Always `asks: []`                                                                                    |
| `reply.sent`          | Your side committed a round                              | `contract`, `letter`, `landed[]`, `re_asked[]`, `asks_opened[]`, `origin`, `words_hash`, `state`                                                             |
| `contract.agreed`     | Every block unanimous; the sign block wakes              | `contract`, `words_hash`, `required_signers[]`                                                                                                               |
| `contract.cancelled`  | Somebody killed the deal                                 | `contract`, `reason`, `cancelled_by`, `origin`                                                                                                               |
| `contract.executed`   | The last required signature bound                        | `contract`, `version`, `certificate_url`, `origin`                                                                                                           |
| `contract.parsed`     | An import finished and a contract exists                 | `contract`, `facts_inferred[]`, `origin`                                                                                                                     |
| `words.changed`       | The canonical text minted a new hash                     | `contract`, `old_hash`, `new_hash`, `signatures_voided[]`                                                                                                    |
| `signature.created`   | A person pressed                                         | `signature`, `contract`, `person`, `state`, `version`, `intent`, `mark`                                                                                      |
| `signature.held`      | A new ask opened under a bound signature                 | `signature`, `contract`, `state`, `words_hash`                                                                                                               |
| `signature.bound`     | The last ask closed and a held signature went live again | `signature`, `contract`, `state`, `words_hash`                                                                                                               |
| `signature.void`      | The words moved under it                                 | `signature`, `contract`, `state`, `bound_hash`, `new_hash`                                                                                                   |
| `signature.withdrawn` | The signer took it back                                  | `signature`, `contract`, `state`, `withdrawn_by`                                                                                                             |
| `ask.withdrawn`       | A sent proposal was pulled                               | `contract`, `ask`, `block`, `withdrawn_by`                                                                                                                   |
| `party.changed`       | Somebody was invited or removed                          | `contract`, `party`, `change` (`invited` · `removed`), `required_signers[]`, `open_asks[]`                                                                   |
| `import.completed`    | One import in a batch finished parsing                   | `import`, `batch`, `asset`, `contract`, `blocks`, `unverified_count`, `unverified[]`, `fields`, `signature_blocks`, `outside_playbook[]`, `facts_inferred[]` |
| `import.failed`       | A parse produced nothing                                 | `import`, `batch`, `asset`, `reason`. Mints **no contract**                                                                                                  |
| `ingress.detected`    | A forwarded mail thread looks like a contract            | `ingress`, `subject`, `from`, `received_at`, `attachments[]`, `confidence`                                                                                   |

<Warning>
  **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.
</Warning>

<Note>
  **`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`.
</Note>

<Warning>
  **`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`](/api/signing) whenever
  you need it, and it mints a fresh link every time.
</Warning>

<Note>
  **`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](#the-org-stream) below for the three ways they differ from the other sixteen.
</Note>

## 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.**

<Warning>
  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.
</Warning>

## The one every integrator must handle

<Warning>
  **`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.
</Warning>

```json words.changed theme={null}
{
  "id": "evt_88",
  "type": "words.changed",
  "sequence": 14,
  "created": "2026-08-24T11:02:00Z",
  "endpoint": "whe_2",
  "data": {
    "contract": "con_1",
    "old_hash": "sha256:6b02...",
    "new_hash": "sha256:9f2c...",
    "signatures_voided": ["sig_2"]
  }
}
```

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:

```
Contracts-Signature: t=1756033320,v1=5f8c1e...
```

HMAC-SHA256 over `{timestamp}.{raw_body}`, keyed with your endpoint secret, with a **five-minute
tolerance**.

<Warning>
  **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.
</Warning>

```python verify.py theme={null}
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secrets: list[str], tolerance: int = 300) -> bool:
    timestamp, provided = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            provided.append(value)      # every v1, in a list, never a dict

    if timestamp is None or not provided:
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False

    signed = f"{timestamp}.".encode() + raw_body
    for secret in secrets:
        expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, v) for v in provided):
            return True
    return False
```

**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.

<Warning>
  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.
</Warning>

## Delivery, retries and dedupe

Delivery is at-least-once. You will see the same event twice.

<Warning>
  **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.
</Warning>

* **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:

```bash theme={null}
curl "$API/contracts/con_1/events?starting_after=18&limit=50" \
  -H "Authorization: Bearer $CONTRACTS_KEY" \
  -H "Contracts-Acting-Person: $ACTING"
```

```json 200 OK theme={null}
{
  "data": [
    { "id": "evt_19", "sequence": 19, "type": "words.changed",
      "created": "2026-08-24T09:12:00Z",
      "data": {
        "contract": "con_1",
        "old_hash": "sha256:6b02...",
        "new_hash": "sha256:9f2c...",
        "signatures_voided": ["sig_2"]
      } }
  ],
  "has_more": false,
  "next_cursor": null
}
```

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`](/api/contracts#list-contracts):

```bash theme={null}
curl "$API/contracts?updated_since=2026-08-24T07:00:00Z&state=negotiating"
```

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`:

```bash theme={null}
curl "$API/events?starting_after=evt_31&limit=50" \
  -H "Authorization: Bearer $CONTRACTS_KEY" \
  -H "Contracts-Acting-Person: $ACTING"
```

```json 200 OK theme={null}
{
  "data": [
    { "id": "evt_32", "type": "ingress.detected",
      "created": "2026-08-24T08:02:00Z",
      "data": {
        "ingress": "ing_3",
        "subject": "Fernbrook NDA v3",
        "from": "priya@fernbrook.com",
        "received_at": "2026-08-24T08:02:00Z",
        "attachments": [{ "filename": "Fernbrook_NDA_v3.docx", "bytes": 41203 }],
        "confidence": 0.91
      } }
  ],
  "has_more": false,
  "next_cursor": null
}
```

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`.

<Note>
  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`.
</Note>

## 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.

<ResponseField name="POST /webhooks/endpoints" type="endpoint">
  Scopes: `webhooks:manage`
</ResponseField>

```json Request theme={null}
{
  "url": "https://your-app.example.com/hooks/contracts",
  "events": ["contract.executed", "letter.received"],
  "description": "ATS sync"
}
```

```json 201 Created theme={null}
{
  "id": "whe_2",
  "url": "https://your-app.example.com/hooks/contracts",
  "events": ["contract.executed", "letter.received"],
  "state": "active",
  "acting_person": "per_9",
  "created": "2026-08-24T09:00:00Z",
  "secret": "whsec_9f2c...",
  "verification": {
    "state": "verified",
    "nonce_expires": "2026-08-24T09:05:00Z",
    "reason": null
  }
}
```

Pass `["*"]` for every event.

<Warning>
  **`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.
</Warning>

### 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:

```json Verification ping theme={null}
{
  "type": "endpoint.verification",
  "endpoint": "whe_2",
  "nonce": "non_4f8c1e22b90d",
  "created": "2026-08-24T09:00:00Z"
}
```

Answer `2xx` with the nonce in the body, and nothing else is required:

```json Your answer theme={null}
{ "nonce": "non_4f8c1e22b90d" }
```

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.

<Warning>
  **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.
</Warning>

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.

<ResponseField name="POST /webhooks/endpoints/{id}/verify" type="endpoint">
  Scopes: `webhooks:manage` · retries the handshake with a fresh nonce
</ResponseField>

```json 200 OK theme={null}
{
  "id": "whe_2",
  "state": "active",
  "verification": { "state": "verified", "nonce_expires": "2026-08-24T09:20:00Z", "reason": null }
}
```

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`.

<ResponseField name="POST /webhooks/endpoints/{id}/test" type="endpoint">
  Scopes: `webhooks:manage` · sends one delivery on demand
</ResponseField>

```json 200 OK theme={null}
{ "delivered": true, "status": 200 }
```

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.

<ResponseField name="GET /webhooks/endpoints" type="endpoint">
  Scopes: `webhooks:manage` · returns `{ "data": [ ... ], "has_more": false }`
</ResponseField>

<ResponseField name="GET /webhooks/endpoints/{id}" type="endpoint">
  Scopes: `webhooks:manage` · the endpoint with no secret, plus `last_delivery` and
  `consecutive_failures`
</ResponseField>

<ResponseField name="DELETE /webhooks/endpoints/{id}" type="endpoint">
  Scopes: `webhooks:manage` · returns `204`
</ResponseField>

<ResponseField name="POST /webhooks/endpoints/{id}/rotate" type="endpoint">
  Scopes: `webhooks:manage`
</ResponseField>

```json 200 OK theme={null}
{ "secret": "whsec_be71...", "old_secret_valid_until": "2026-08-25T11:00:00Z" }
```

<ResponseField name="POST /webhooks/endpoints/{id}/replay" type="endpoint">
  Scopes: `webhooks:manage`
</ResponseField>

```json Request theme={null}
{ "event_ids": ["evt_19", "evt_20"] }
```

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**.

<Note>
  **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.
</Note>

A `sk_test_` key registers endpoints in the sandbox org, where letters never leave the building.

## Not events, and why

| Not an event            | Why                                                                                                                                        |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `room.created`          | A lie about what happened. A relationship is derived on first Send, and `relationship_created` on the Send response is the only trace.     |
| `clause.viewed`         | Reading is not a product event.                                                                                                            |
| `presence.*`            | Live collaboration is explicitly out of scope at v1.                                                                                       |
| `decision.staged`       | **A staged decision is private to your side and must not leave it.**                                                                       |
| `endpoint.verification` | A control delivery, not an event. It proves you own the URL. It carries no `sequence`, folds into nothing, and no subscription reaches it. |
| `endpoint.test`         | The same: a control delivery you asked for, so you can exercise a handler before a contract moves.                                         |

<Note>
  `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.
</Note>
