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

# Reacting to change

> Stop polling. Open one SSE stream for live UI, or register a webhook for server-side work, and know which events reach which listener.

Exo keeps working after your request returns. Jobs finish, the daemon proposes connections, contradictions surface, a subject's mode of thinking shifts. Two surfaces tell you: `GET /v1/events` for a live stream, and webhooks for anything that has to survive your process restarting.

## Before you start

<Columns cols={3}>
  <Card title="A key with read" icon="key" href="/scopes">
    Streaming needs `read`. Registering webhooks needs `write`.
  </Card>

  <Card title="An https endpoint" icon="globe" href="#registering-a-webhook">
    Webhook URLs must be https and publicly resolvable. Localhost is rejected.
  </Card>

  <Card title="Somewhere to store a secret" icon="lock" href="#verifying-a-delivery">
    The signing secret is shown once, at registration, and never again.
  </Card>
</Columns>

## Pick your surface

|                    | `GET /v1/events`                                         | Webhooks                                    |
| ------------------ | -------------------------------------------------------- | ------------------------------------------- |
| Shape              | Long-lived SSE connection                                | Outbound HTTP POST                          |
| Survives a restart | No                                                       | Yes                                         |
| Replay             | None. `Last-Event-ID` is accepted but not replayed in v1 | Retries on failure                          |
| Limit              | 5 concurrent streams per organization                    | 20 endpoints per organization               |
| Use for            | A dashboard that updates while someone watches           | Reindexing, notifications, anything durable |

Most products want both: the stream for the tab that is open, the webhook for the work that must happen anyway.

## The event catalog

| Event                    | Fires when                                               | Scope        |
| ------------------------ | -------------------------------------------------------- | ------------ |
| `job.completed`          | An ingest, import or train job reaches a terminal status | Person       |
| `insight.proposed`       | The daemon proposes a new connection                     | Organization |
| `contradiction.detected` | Two of the subject's beliefs are found to conflict       | Person       |
| `belief.superseded`      | A belief is replaced by a newer one                      | Person       |
| `basin.shifted`          | The subject's mode of thinking moves                     | Person       |
| `brain.trained`          | A new ExoBrain artifact is published                     | Organization |
| `graph.updated`          | The knowledge graph changed                              | Organization |

The `import.*` and `identity.*` families also flow on the stream.

**Scope decides who hears it.** Organization events reach every listener. Person events reach only that person's stream and only webhook endpoints that person registered, mirroring the API, where another member's job is a 404. Events about a provisioned subject reach every listener in the organization, because any key there can already read that subject with `X-Exo-Subject`.

## Streaming with SSE

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -N https://api.get-exo.com/v1/events \
    -H "X-Exo-API-Key: $EXO_KEY" \
    -H "Accept: text/event-stream"
  ```

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

  with httpx.stream(
      "GET",
      "https://api.get-exo.com/v1/events",
      headers={"X-Exo-API-Key": EXO_KEY, "Accept": "text/event-stream"},
      timeout=None,
  ) as r:
      if r.status_code == 429:
          raise RuntimeError("stream cap reached, back off and retry")

      event = None
      for line in r.iter_lines():
          if line.startswith(":"):
              continue                       # heartbeat, arrives every 15 seconds
          if line.startswith("event:"):
              event = line.split(":", 1)[1].strip()
          elif line.startswith("data:"):
              handle(event, json.loads(line.split(":", 1)[1]))
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const r = await fetch("https://api.get-exo.com/v1/events", {
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      Accept: "text/event-stream",
    },
  });
  if (r.status === 429) throw new Error("stream cap reached, back off and retry");

  const reader = r.body!.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += value;
    const frames = buffer.split("\n\n");
    buffer = frames.pop() ?? "";
    for (const frame of frames) {
      if (frame.startsWith(":")) continue;   // heartbeat
      const event = frame.match(/^event:\s*(.+)$/m)?.[1];
      const data = frame.match(/^data:\s*(.+)$/m)?.[1];
      if (event && data) handle(event, JSON.parse(data));
    }
  }
  ```
</CodeGroup>

A `: ping` comment arrives every 15 seconds. Ignore it, but use its absence to detect a dead connection.

<Warning>
  An organization may hold **5 concurrent streams**. The sixth gets `429 too_many_streams` with a `Retry-After` header. Open one stream per browser tab and you will hit this with six tabs, so multiplex through your own backend rather than opening a stream per client. See [too\_many\_streams](/errors/too_many_streams).
</Warning>

`Last-Event-ID` is accepted on the request for compatibility with resuming clients, but **v1 does not replay**. Anything emitted while you were disconnected is gone from the stream. If you cannot miss an event, use a webhook.

## Registering a webhook

<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": ["job.completed", "contradiction.detected", "basin.shifted"]
        }'
  ```

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

  r = httpx.post(
      "https://api.get-exo.com/v1/webhooks",
      headers={"X-Exo-API-Key": EXO_KEY},
      json={
          "url": "https://hooks.example.com/exo",
          "eventTypes": ["job.completed", "contradiction.detected", "basin.shifted"],
      },
  )
  hook = r.json()
  store_secret(hook["id"], hook["secret"])   # only chance to read it
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const r = 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: ["job.completed", "contradiction.detected", "basin.shifted"],
    }),
  });
  const hook = await r.json();
  await storeSecret(hook.id, hook.secret);   // only chance to read it
  ```
</CodeGroup>

```json 201 Created theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "a3f0e857-6c2d-4b91-8a54-1e9d6f3b0c72",
  "url": "https://hooks.example.com/exo",
  "eventTypes": ["job.completed", "contradiction.detected", "basin.shifted"],
  "inactiveEventTypes": [],
  "secret": "whsecc_9d3a7b04e15c8f26...",
  "disabled": false,
  "disabledReason": null,
  "consecutiveFailures": 0,
  "lastDeliveryAt": null,
  "lastSuccessAt": null,
  "createdAt": "2026-07-30T15:01:44Z",
  "updatedAt": null
}
```

<Warning>
  **The signing secret is shown once, in this response, and there is no route that returns it again.** Store it before you close the connection. If you lose it, register a new endpoint. An idempotent replay of the same registration returns the original record with `secret` replaced by a marker, because the plaintext is never persisted, not even for replay.
</Warning>

Subscribe to everything with `"eventTypes": ["*"]`. Up to 20 types per endpoint, up to 20 endpoints per organization.

**Read `inactiveEventTypes` on the response.** It lists types you subscribed to that no emitter publishes yet. The subscription is kept and starts delivering when the emitter ships, and the field exists so you never sit waiting on an event that was never going to arrive. An unknown event type is rejected outright at registration, so a typo fails here rather than silently never firing.

### URL rules

The URL must be https and must resolve to a public address. Private, loopback, link-local, CGNAT, reserved and tunnelled ranges are rejected at registration and checked again at every delivery, and **redirects are never followed**. For local development, use a public tunnel with a stable https hostname and point the endpoint at the final URL, not at something that redirects to it.

## Verifying a delivery

Each delivery carries `X-Exo-Signature`. Recompute `HMAC_SHA256(secret, "<t>.<raw body>")` and compare it to the `v1` value in that header, using the raw bytes of the body, before you parse the JSON. Most web frameworks parse and discard the raw body by default; capture it before parsing, or the signature will never match a body you re-serialized.

The worked Python and TypeScript verification functions, plus a replay-window check, live on [Webhooks](/platform/webhooks#verify-the-signature) rather than here, so there is exactly one copy of that code on the site.

## Payloads are thin on purpose

A delivery tells you what happened and which resource it happened to. It does not carry the resource. Fetch the current state yourself, so you act on what is true now rather than on what was true when the event fired.

```json Illustrative delivery body theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "id": "1f0c1f3e-6a44-4c1f-9f2b-0d5a7c8e1042",
  "type": "contradiction.detected",
  "createdAt": "2026-07-30T15:04:07Z",
  "orgId": "0d5b7c92-3a41-4e88-9b02-6f7c1d4a5e30",
  "data": { "count": 2, "subject": "customer_6412" }
}
```

Every delivery uses this same envelope regardless of event type: `id`, `type`, `createdAt`, `orgId`, and a `data` block whose keys vary by event (the full per-event key list is on [Webhooks](/platform/webhooks#the-event-catalogue)). `data` carries identifiers and small scalars only, never the resource itself.

On `contradiction.detected`, read `GET /v1/contradictions`. On `job.completed`, read `GET /v1/jobs/{id}` for the `result` block. On `basin.shifted`, drop your cached conditioning pack and recompose it.

## Managing endpoints

| Route                         | What it does                                                   |
| ----------------------------- | -------------------------------------------------------------- |
| `GET /v1/webhooks`            | List your endpoints. The secret is never included              |
| `GET /v1/webhooks/{id}`       | Inspect one, including its recent delivery log                 |
| `PATCH /v1/webhooks/{id}`     | Change `url`, `eventTypes` or `disabled`. Every field optional |
| `POST /v1/webhooks/{id}/test` | Send a test event and get the delivery outcome inline          |
| `DELETE /v1/webhooks/{id}`    | Remove the endpoint                                            |

The test route is the fastest way to confirm your signature check works, because it returns the outcome in the response rather than leaving you reading logs:

```json 200 OK theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "webhookId": "a3f0e857-6c2d-4b91-8a54-1e9d6f3b0c72",
  "deliveryId": "dlv_7a10b3",
  "eventType": "job.completed",
  "attempt": 1,
  "ok": true,
  "statusCode": 200,
  "durationMs": 143,
  "responseSnippet": "ok",
  "error": null,
  "attemptedAt": "2026-07-30T15:06:12Z"
}
```

Watch `consecutiveFailures` and `disabledReason` on the endpoint record. A repeatedly failing endpoint gets disabled, and `disabledReason` tells you why.

## When it goes wrong

| What you see                     | Why                                                                                            | What to do                                                                                                                             |
| -------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `429 too_many_streams`           | The organization already has 5 open SSE streams                                                | Honour `Retry-After`. Multiplex through your backend instead of one stream per tab. See [too\_many\_streams](/errors/too_many_streams) |
| `409 webhook_limit_reached`      | 20 endpoints already registered                                                                | Delete an unused endpoint first. See [webhook\_limit\_reached](/errors/webhook_limit_reached)                                          |
| `422` when registering           | The URL is not https, resolves to a private address, or an event type is unknown               | Use a public https URL and check the spelling against the catalog above                                                                |
| Signature never matches          | You hashed a re-serialized body                                                                | Hash the raw bytes, and include the `t` value and the dot                                                                              |
| Subscribed but nothing arrives   | The type is listed in `inactiveEventTypes`, or it is a person event on someone else's endpoint | Check both. Person events only reach endpoints registered by that person                                                               |
| Events missing after a reconnect | SSE does not replay in v1                                                                      | Use a webhook for anything you cannot miss                                                                                             |

## Next

<Columns cols={2}>
  <Card title="Surfacing contradictions" icon="git-compare" href="/guides/contradictions-review">
    Turn `contradiction.detected` into a review step your team actually sees.
  </Card>

  <Card title="Conditioning your own agent" icon="wand" href="/guides/conditioning">
    Use `basin.shifted` to invalidate a cached conditioning pack.
  </Card>
</Columns>
