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

# Webhooks

> Register an endpoint, verify the signature, and understand retries, auto-disable and the URL restrictions.

**A webhook delivery is a thin, signed POST: identifiers and small scalars, never content.** Your receiver reads the object back through the API, so it sees exactly what its own credentials allow.

Webhooks are the durable half of [Events](/platform/events). The SSE stream is a live view and does not replay; webhooks retry.

## Register an endpoint

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.get-exo.com/v1/webhooks \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://hooks.example.com/exo",
      "eventTypes": ["contradiction.detected", "belief.superseded"]
    }'
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import httpx

  response = httpx.post(
      "https://api.get-exo.com/v1/webhooks",
      headers={"X-Exo-API-Key": EXO_KEY},
      json={
          "url": "https://hooks.example.com/exo",
          "eventTypes": ["contradiction.detected", "belief.superseded"],
      },
  )
  secret = response.json()["secret"]  # shown once, store it now
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const response = await fetch("https://api.get-exo.com/v1/webhooks", {
    method: "POST",
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://hooks.example.com/exo",
      eventTypes: ["contradiction.detected", "belief.superseded"],
    }),
  });
  const { secret } = await response.json(); // shown once, store it now
  ```
</CodeGroup>

<Warning>
  The signing secret is in this response and nowhere else. No route returns it again, and a replayed `Idempotency-Key` returns the registration with the secret replaced by a marker rather than in the clear. Store it before you close the connection.
</Warning>

Registration requires the `write` scope. Subscribe to `*` to receive the whole catalogue. An unknown event type is rejected here, so a typo fails at registration rather than silently never firing. A subscribed type that no producer publishes on this deployment comes back in `inactiveEventTypes` for the same reason.

## The event catalogue

Every delivery carries the same envelope. `data` holds identifiers and small scalars only: never node content, never a proposal body, never an identity blob.

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "1f0c1f3e-6a44-4c1f-9f2b-0d5a7c8e1042",
  "type": "contradiction.detected",
  "createdAt": "2026-07-14T09:12:03Z",
  "orgId": "8c1f0e42-7b3a-4d90-9a15-2f6c0b7e4d31",
  "data": { "count": 2, "subject": "customer_6412" }
}
```

| Event                    | `data` keys                                                     | Emitted when                                               |
| ------------------------ | --------------------------------------------------------------- | ---------------------------------------------------------- |
| `graph.updated`          | `changeType`, `affectedNodes`, `subject`, `targetId` when known | Any graph mutation, a proposal decision, or a purge        |
| `job.completed`          | `jobId`, `jobType`, `status`, `subject`                         | A job reaches a terminal status, or is cancelled           |
| `insight.proposed`       | `count`, `threshold`                                            | The background cycle proposes new connections              |
| `contradiction.detected` | `count`, `subject`                                              | The background cycle finds new belief conflicts            |
| `belief.superseded`      | `subject`, `nodeId` when known, `supersededBy` when known       | `POST /v1/graph/nodes/{node_id}/supersede`                 |
| `basin.shifted`          | `subject`, `from`, `to`, `confidence`                           | The subject's manifold position moves to a different basin |
| `brain.trained`          | `status`, `trainedAt`, `nodes`, `edges`                         | A brain train is observed as finished                      |
| `webhook.test`           | `message`                                                       | You call `POST /v1/webhooks/{webhook_id}/test`             |

Keys marked "when known" appear only when the producer has the value. Treat `data` as additive and ignore keys you do not recognise.

<Note>
  `brain.trained` is observed rather than emitted at the moment of completion. The train finishes on a separate worker, and the event lands on the next sweep. It is an honest observer, not an instant emit.
</Note>

## Verify the signature

Every delivery carries four headers:

| Header            | Value                                                |
| ----------------- | ---------------------------------------------------- |
| `X-Exo-Event`     | The event type, for example `contradiction.detected` |
| `X-Exo-Delivery`  | A UUID, unique per delivery **attempt**              |
| `X-Exo-Webhook`   | The endpoint id                                      |
| `X-Exo-Signature` | `t=<unix seconds>,v1=<hex hmac_sha256>`              |

The `v1` value is `HMAC_SHA256(secret, "<t>." + raw_body)`, hex encoded.

Two rules decide whether your verification is correct:

* **Sign the raw bytes**, not a re-serialized object. Re-encoding JSON changes key order and whitespace, and the signature will not match.
* **Compare in constant time.** Use your language's timing-safe comparison, never `==`.

<CodeGroup>
  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import hashlib
  import hmac
  import time


  def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      """Verify one Exo webhook delivery. `raw_body` must be the exact bytes."""
      parts = dict(part.split("=", 1) for part in header.split(","))
      timestamp, signature = int(parts["t"]), parts["v1"]

      # Reject deliveries outside the replay window.
      if abs(time.time() - timestamp) > tolerance:
          return False

      expected = hmac.new(
          secret.encode(),
          f"{timestamp}.".encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verify(
    rawBody: Buffer,
    header: string,
    secret: string,
    toleranceSeconds = 300,
  ): boolean {
    const parts = Object.fromEntries(
      header.split(",").map((part) => {
        const index = part.indexOf("=");
        return [part.slice(0, index), part.slice(index + 1)];
      }),
    );
    const timestamp = Number(parts.t);
    const signature = parts.v1;

    // Reject deliveries outside the replay window.
    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

    const expected = createHmac("sha256", secret)
      .update(Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]))
      .digest("hex");

    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(signature, "hex");
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  # There is no cURL equivalent: verification happens in your receiver, not in a
  # request you send. To check your implementation end to end, ask Exo to send a
  # signed test event and inspect what your endpoint received.
  curl -X POST https://api.get-exo.com/v1/webhooks/$WEBHOOK_ID/test \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```
</CodeGroup>

The replay tolerance above is your choice, not a server setting. Five minutes is a reasonable default.

### Test before you rely on it

[`POST /v1/webhooks/{webhook_id}/test`](/api-reference/events/test-webhook) sends a signed `webhook.test` event now and returns the outcome inline, so you can confirm verification works before a real event depends on it.

A test never counts against the endpoint: it is recorded in the delivery log, and it never contributes to the failure streak, never auto-disables the endpoint, and is never retried. If your receiver rejects the event, the call still returns 200, with `ok: false` and the status your endpoint responded with, because the call succeeded even though the delivery did not.

## Retries and auto-disable

| Behaviour                                | Value                                                                |
| ---------------------------------------- | -------------------------------------------------------------------- |
| Attempts per event                       | 3: the first attempt plus 2 retries                                  |
| Retry delays                             | About 30 seconds, then about 120 seconds, each jittered by up to 20% |
| Per-attempt timeout                      | 10 seconds                                                           |
| Consecutive failures before auto-disable | 8                                                                    |
| Endpoints per organization               | 20                                                                   |
| Event types per endpoint                 | 20, or `*`                                                           |

Retries are fire and forget: the emitter is never blocked and never sees the outcome. Jitter exists so that a receiver coming back up is not hit by every endpoint's retry in the same instant.

After 8 consecutive failed attempts an endpoint auto-disables and stops receiving. Inspect what happened with [`GET /v1/webhooks/{webhook_id}`](/api-reference/events/get-webhook), which returns the recent attempts newest first with the response status and a short response snippet, so a broken receiver can be diagnosed without server-side logs.

Re-enable it once the receiver is healthy:

```bash theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
curl -X PATCH https://api.get-exo.com/v1/webhooks/$WEBHOOK_ID \
  -H "X-Exo-API-Key: $EXO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"disabled": false}'
```

Setting `disabled` to false also clears the failure streak. The signing secret is unchanged.

Registering a twenty-first endpoint is 409 [`webhook_limit_reached`](/errors/webhook_limit_reached). Fan-out is serial with a per-endpoint timeout, so an unbounded list would be both a latency problem for you and a way to turn one organization into an outbound request amplifier. Subscribe one endpoint to more event types rather than registering another.

## URL restrictions

Exo validates a webhook URL at registration **and** again at every delivery. This is deliberate: a hostname that resolves to a private address today could resolve somewhere else tomorrow.

* The scheme must be `https`.
* No `user:pass@` userinfo, and the default https port.
* The URL is at most 2048 characters.
* The hostname must resolve to a public address. Private, loopback, link-local, CGNAT, reserved, multicast, unspecified and tunnelled ranges are all rejected, including `10/8`, `172.16/12`, `192.168/16`, `127/8`, `169.254/16`, `100.64/10`, `::1`, `fc00::/7` and `fe80::/10`. The embedded IPv4 address inside a 6to4 or Teredo address is unwrapped and checked as well.
* **Redirects are never followed**, so a `302` to a private address cannot launder the check.

A URL that fails any of these is rejected at registration rather than accepted and silently never delivered.

<Note>
  One limit stated plainly: the resolved address is checked but is not pinned into the connection, so a DNS rebind between the check and the connect is theoretically possible. Closing that needs a transport that dials the verified address directly, which is a follow-up rather than a v1 behaviour.
</Note>

## Who receives what

Registering an endpoint requires `write`, and an endpoint is organization-level configuration, so a subscription is not by itself a grant to read every member's data.

Four events are about a **person** rather than about the organization: `job.completed`, `basin.shifted`, `contradiction.detected` and `belief.superseded`. Delivery of those drops endpoints whose creator could not read that person's data through the API, which matches how the REST surface already behaves. One exemption matches REST exactly: a **provisioned subject** stays visible organization-wide, because any member key can already read that partition by sending `X-Exo-Subject`.

`graph.updated` also carries a `subject` and is deliberately organization-wide. Its reach predates the narrowing, so restricting it is a separate decision rather than a fix, and it is recorded here rather than hidden.

## Rotation

There is no rotation route in v1. To rotate a secret, register a new endpoint and delete the old one. That is a deliberately smaller surface than a half-built rotation flow.

## Errors

| Status | Code                                                     | When                                                                       |
| ------ | -------------------------------------------------------- | -------------------------------------------------------------------------- |
| `403`  | [`permission_denied`](/errors/permission_denied)         | The key lacks `write` for a registration or update                         |
| `404`  | [`webhook_not_found`](/errors/webhook_not_found)         | The endpoint belongs to another organization, or was deleted               |
| `409`  | [`webhook_limit_reached`](/errors/webhook_limit_reached) | The organization already holds 20 endpoints                                |
| `422`  | [`validation_error`](/errors/validation_error)           | The URL failed validation, or an event type is not in the catalogue        |
| `503`  | [`service_degraded`](/errors/service_degraded)           | The signing key or the store is unavailable. Nothing is delivered unsigned |

## Related

<Columns cols={2}>
  <Card title="Events" icon="radio" href="/platform/events">
    The live SSE stream, its heartbeat, and its per-organization cap.
  </Card>

  <Card title="Events and webhooks" icon="layers" href="/api-reference/events">
    Every route in the family, with request and response schemas.
  </Card>
</Columns>
