# Event streams

An event stream delivers the same public events as `GET /events` over Server-Sent Events (SSE), in the same order and with the same JSON, so you don't need to poll. Each event stream belongs to the credential that created it and has a durable cursor over the public event log, an expiration time, and a limit of one connection at a time. The cursor points into the public event log; events are not copied per stream.

For the full schema of each operation, see the [EventStreams reference](https://cademi.dev/api/reference/eventstreams.md).

## Permissions and limits

| Permission | Operations |
|---|---|
| `event_streams.read` | `GET /event-streams`, `GET /event-streams/{event_stream_id}`, `GET /event-streams/{event_stream_id}/events` |
| `event_streams.manage` | `POST /event-streams`, `DELETE /event-streams/{event_stream_id}` |

You can have up to 5 `active` event streams per credential and 20 per account. Beyond that, `POST` returns `429 too_many_streams`. The stream filters must fall within the credential's `events.read` scope. An unknown event type returns `422 validation_failed` for `filters.event_types`.

## Create, retrieve, and revoke

```
POST /event-streams
Idempotency-Key: 01J8Z3...
Content-Type: application/json

{"filters": {"event_types": ["product.created", "product.updated"], "resource_types": ["product"]}, "expires_in_hours": 24}
```

```json
{
  "data": {
    "object": "event_stream",
    "id": "str_01J8Z3...",
    "status": "active",
    "filters": {"event_types": ["product.created", "product.updated"], "resource_types": ["product"]},
    "cursor": {"last_event_id": null, "advanced_at": null},
    "connection": {"connected": false, "since": null},
    "expires_at": "2026-09-24T12:00:00+00:00",
    "last_seen_at": null,
    "created_at": "2026-09-23T12:00:00+00:00"
  }
}
```

`expires_in_hours` ranges from 1 to 24 and defaults to 24. `GET /event-streams` lists the credential's event streams, with `cursor` and `limit` working as in any other collection (see [Pagination and filtering](https://cademi.dev/api/pagination-and-filtering.md)). `GET /event-streams/{event_stream_id}` returns a single event stream. `DELETE /event-streams/{event_stream_id}` sets the stream to `revoked` and closes any open connection. An event stream created by another credential returns `404 not_found`, even within the same account. `status` is `active`, `revoked`, or `expired`.

## SSE transport

```
GET /event-streams/str_01J8Z3.../events
Authorization: Bearer ck_live_...
Accept: text/event-stream
Last-Event-ID: evt_01J8Z2...
```

- Authenticate with the `Authorization` header only. Credentials in the query string are not accepted.
- The response is `200` with `Content-Type: text/event-stream`, `Cache-Control: no-cache`, and `X-Accel-Buffering: no`. The body starts with `retry: 3000` and contains one block per event:

```
id: evt_01J8Z3...
event: product.updated
data: {"object":"event","id":"evt_01J8Z3...","type":"product.updated", ...}

: keep-alive
```

- `data` is exactly the JSON returned by `GET /events/{event_id}`. `id` is the event's public ID, which you can send back in `Last-Event-ID`.
- Cursor: without `Last-Event-ID`, the connection resumes from the cursor stored on the event stream. With the header, it resumes after that event, without redelivering what was already sent. If that event is past the 30-day retention period, the API returns `410 cursor_expired`: start over from `GET /events`.
- After each delivered batch, the server advances `cursor.last_event_id` and `last_seen_at`. A `: keep-alive` comment is sent every 15 seconds.
- One connection per event stream: a second concurrent connection receives `409 stream_busy`. If a client disappears without closing its connection, the connection is released after 25 minutes and the next connection is accepted normally. Closing the connection releases it immediately.
- Each connection extends the event stream's expiration to 24 hours from that moment.
- The server closes the connection after 25 minutes, and also whenever the event stream expires, is revoked, is taken over by another connection, or the credential is revoked. Reconnect with `Last-Event-ID` to continue.
- Expect a latency of a few seconds: the server checks for new events every second, and events become visible after the same delay of about 3 seconds that applies to `GET /events`. Delivery is not real-time.
- Audit trail: one entry is recorded when a connection opens. Delivered events are not recorded individually.

| Response | When |
|---|---|
| `409 state_conflict` | The event stream is `revoked` or `expired`. |
| `409 stream_busy` | Another connection is already open. |
| `410 cursor_expired` | `Last-Event-ID` refers to an event that is no longer retained. |
| `404 not_found` | The event stream does not exist or belongs to another credential. |
| `403 permission_denied` | The credential lacks `event_streams.read`. |

Event streams past their expiration time move to `expired` automatically within about a minute, and abandoned connections are released at the same time. The internal events `event_stream.created`, `event_stream.revoked`, and `event_stream.expired` are visible only with `audit.read`.

## Test scenarios in the sandbox

Test scenarios are available only in a sandbox account. Outside it, the API returns `409 sandbox_required`. Each scenario has an ID with the `scn_` prefix. See the [Sandbox guide](https://cademi.dev/api/sandbox.md) and the [Sandbox reference](https://cademi.dev/api/reference/sandbox.md) for details.

- `GET /sandbox/test-scenarios` and `GET /sandbox/test-scenarios/{scenario_id}` (`sandbox.read`) return `{object: "test_scenario", id, name, description, params{}, effects[], version}`. `params` is the schema of the variables the scenario accepts, and `effects` lists what the scenario creates, the events it emits, and the simulated emails.
- `POST /sandbox/test-scenarios/{scenario_id}/runs` (`sandbox.manage`, `Idempotency-Key` required, optional body `{"params": {...}}`) creates a `sandbox.run_scenario` operation (a single item that cannot be retried) and returns `202`. The scenario runs in the sandbox account, every record it creates has `meta.simulated = true`, and the resulting public events (`product.created`, `user.created`, ...) reach webhooks and event streams just as they would in production. The item result contains `{created: {product: 2, user: 3, ...}, events_emitted: n}`.

Repeating the same `Idempotency-Key` returns the same operation without running the scenario again. A sandbox reset (`POST /sandbox/resets`) deletes everything the scenarios created.
