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

# Events

> The Server-Sent Events stream: frames, heartbeats, the per-organization cap, and which events reach whom.

**`GET /v1/events` is a live view, not a log. It does not replay.** A client that must not miss a transition should use [webhooks](/platform/webhooks), which retry.

Both transports carry the same event catalogue and apply the same reach rules, so a receiver sees the same set either way. The difference is durability.

## Open a stream

<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

  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 response:
      for line in response.iter_lines():
          if line.startswith(": "):
              continue  # heartbeat
          print(line)
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const response = await fetch("https://api.get-exo.com/v1/events", {
    headers: {
      "X-Exo-API-Key": process.env.EXO_KEY!,
      Accept: "text/event-stream",
    },
  });

  const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    process.stdout.write(value);
  }
  ```
</CodeGroup>

The stream requires the `read` scope. `X-Exo-Subject` is accepted and has no effect: the stream is organization-scoped rather than per-subject.

<Note>
  The browser `EventSource` API cannot set request headers, so it cannot carry an Exo API key. Use `fetch` with a stream reader, or proxy the stream from your own server.
</Note>

## Frame format

Each event is a named SSE frame. The `data:` line carries a JSON envelope with the type repeated inside it, so a consumer can dispatch on either.

```
event: contradiction.detected
id: 1f0c1f3e-6a44-4c1f-9f2b-0d5a7c8e1042
data: {"type": "contradiction.detected", "data": {"count": 2, "subject": "customer_6412"}, "timestamp": "2026-07-14T09:12:03Z"}

```

Every frame ends with a blank line. The `id:` line is present when the event has one, and is for your own bookkeeping.

### Heartbeats

After 15 seconds of idle, Exo sends a comment frame:

```
: ping

```

It exists to stop proxies closing an idle connection. A line beginning with `:` is a comment in the SSE format and carries no data. **Skip these lines rather than trying to parse them**, which is the most common integration bug on this route.

## The catalogue

The same eight event types flow here as over webhooks. See the [webhook catalogue](/platform/webhooks#the-event-catalogue) for the `data` keys on each.

`graph.updated` and the `import.*` and `identity.*` families are published inside the API process. The other six, `job.completed`, `insight.proposed`, `contradiction.detected`, `belief.superseded`, `basin.shifted` and `brain.trained`, originate in a background worker and reach this stream over a database bridge.

<Warning>
  That bridge is fail-soft. If it cannot start, worker-origin events stop reaching the stream while webhooks keep delivering every one of them. A bridge that dies later reconnects with backoff, so the gap is bounded rather than lasting until the next deploy. This is another reason a receiver that must not miss a transition should use a webhook.
</Warning>

## Which events reach whom

Reach differs by origin and by event, and this is the one thing worth knowing about the stream.

* An event published **in the API process** reaches the acting user's own stream. You see your own `graph.updated`, and not another member's.
* An event arriving **over the bridge** is fanned out by what it is about. `insight.proposed` and `brain.trained` describe the organization and reach every human member. `job.completed`, `basin.shifted`, `contradiction.detected` and `belief.superseded` name a subject and reach that subject only, because the routes you read them back from are per-user: another member's job is a 404, identity reads are self or admin, and contradiction and graph reads filter by user.
* One exemption matches the REST surface exactly: when the subject is a **provisioned subject** rather than a human member, the whole team receives the event, because any member key can already read that partition with `X-Exo-Subject`.

The result deliberately under-delivers in one case: an organization admin gets no live view of a peer's events even where a REST read would be allowed.

## Limits

| Limit                               | Value              |
| ----------------------------------- | ------------------ |
| Concurrent streams per organization | 5                  |
| Heartbeat interval                  | 15 seconds of idle |
| `Retry-After` on a refused stream   | 15 seconds         |
| Replay                              | None in v1         |

The sixth concurrent stream for an organization returns 429 [`too_many_streams`](/errors/too_many_streams) with `Retry-After`. Slots are released when a connection drops. If you need delivery to many independent consumers, fan out from one stream inside your own system, or use webhooks.

`Last-Event-ID` is accepted on the request so that resuming clients do not error, and it is not acted on. **v1 does not replay**: a client disconnected across an event does not receive it on reconnect, with or without the header.

## Reconnecting

Treat a dropped stream as a gap, not a pause. When you reconnect:

1. Reopen the stream.
2. Re-read the state you care about through the API rather than assuming nothing changed. `GET /v1/jobs` and `GET /v1/contradictions` are the usual two.

Because there is no replay, a stream is the right transport for a live interface and the wrong one for a system of record.

## Errors

| Status | Code                                                   | When                                          |
| ------ | ------------------------------------------------------ | --------------------------------------------- |
| `401`  | [`authentication_error`](/errors/authentication_error) | The credential is missing or invalid          |
| `403`  | [`permission_denied`](/errors/permission_denied)       | The key lacks the `read` scope                |
| `429`  | [`too_many_streams`](/errors/too_many_streams)         | The organization already holds 5 open streams |
| `503`  | [`service_degraded`](/errors/service_degraded)         | The event bus is unavailable                  |

## Related

<Columns cols={2}>
  <Card title="Webhooks" icon="webhook" href="/platform/webhooks">
    Signed deliveries with retries, for anything that must not be missed.
  </Card>

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