# Event Stream (SSE)

> Subscribe to donations, followers, and goal completions over Server-Sent Events.

- Canonical page: https://zerocut.gg/docs/api/events-stream
- Markdown version: https://zerocut.gg/docs/api/events-stream.md



# Event Stream (SSE) [#event-stream-sse]

`GET /api/v1/events/stream` pushes your creator events to a long-lived
Server-Sent Events connection. It carries business facts only — a donation was
recorded, a follower was recorded, a goal was reached — scoped to the account
that owns the API key.

This page is written by hand. The stream returns `text/event-stream`, which has
no JSON response schema, so it is **not** part of the generated OpenAPI
document. Everything you need to write a client is below. The companion
endpoint `POST /api/v1/events/stream-token` *is* in the OpenAPI document.

## Authentication [#authentication]

Send your API key in the `X-API-Key` header, exactly as with every other
endpoint:

```bash
curl -N \
  -H "X-API-Key: zerocut.your_api_key_here" \
  -H "Accept: text/event-stream" \
  https://zerocut.gg/api/v1/events/stream
```

API keys are never accepted in the query string. Passing `apiKey`, `api_key`,
`apikey`, `key`, or `token` as a query parameter returns `400` with code
`QUERY_PARAM_CREDENTIAL`, before the key is even hashed. Browsers that need
`EventSource` use the [stream token flow](#eventsource-stream-tokens) instead.

The stream requires the same premium API access as the REST endpoints. Access
is re-checked every 30 seconds while the connection is open; if the key is
disabled, deleted, expires, or the account loses API access, the server sends a
terminal `error` frame and closes.

## Event catalog [#event-catalog]

| `type`             | Meaning                   |
| ------------------ | ------------------------- |
| `donation.created` | A donation was recorded   |
| `follower.created` | A follower was recorded   |
| `goal.reached`     | A donation goal completed |

Every type is sent by default. To receive only donations — or any other
subset — pass the [`events` filter](#filters), e.g.
`?events=donation.created`.

Test alerts fired from your dashboard are **not** published — every id on this
stream resolves over REST.

## Envelope [#envelope]

Each frame's `data:` line is one JSON object:

```json
{
  "id": "1788093001234-0",
  "family": "domain",
  "type": "donation.created",
  "version": 1,
  "createdAt": "2026-08-30T12:30:01.234Z",
  "replayed": false,
  "data": {
    "id": "0f2d6c1e-9b5a-4c7e-8f1a-3d2b5e7c9a04",
    "amount": 1500,
    "currency": "USD",
    "donorName": "Ada",
    "message": "great stream",
    "country": "GB",
    "perk": { "id": "8b1f...", "title": "Shoutout" },
    "perks": [
      {
        "id": "8b1f...",
        "title": "Shoutout",
        "quantity": 1,
        "unitAmount": 1500,
        "lineTotal": 1500,
        "currency": "USD"
      }
    ],
    "createdAt": "2026-08-30T12:30:01.180Z"
  }
}
```

* `id` — the resume cursor for this event. Monotonic for your account.
* `family` — `domain` for catalog events, `control` for the lifecycle frames
  below. **Ignore families you do not recognise**; new ones may be added.
* `version` — envelope version, currently `1`.
* `createdAt` — derived from `id`, so it is identical whether the frame arrived
  live or as a replay. `data.createdAt` is the authoritative business time.
* `replayed` — `true` when the frame came from reconnect replay.

Money is always an integer in the currency's minor unit, and `currency` is
always uppercase, matching the REST API.

### `donation.created` [#donationcreated]

| Field       | Type              | Notes                             |
| ----------- | ----------------- | --------------------------------- |
| `id`        | string (uuid)     | Resolves via `GET /donation/{id}` |
| `amount`    | integer           | Minor units, gross                |
| `currency`  | string            | Three letters, uppercase          |
| `donorName` | string \| null    | Creator-visible display name      |
| `message`   | string \| null    | Donor message                     |
| `country`   | string \| null    | ISO-3166 alpha-2 when known       |
| `perk`      | object \| null    | `{ id, title }`, the primary perk |
| `perks`     | array             | Cart lines, `[]` when none        |
| `createdAt` | string (ISO-8601) | Donation creation time            |

Each `perks[]` entry is `{ id, title, quantity, unitAmount, lineTotal,
currency }`.

### `follower.created` [#followercreated]

| Field         | Type              | Notes                             |
| ------------- | ----------------- | --------------------------------- |
| `id`          | string            | Follower record id                |
| `username`    | string            | Platform handle                   |
| `displayName` | string \| null    | Platform display name             |
| `platform`    | string            | Platform slug                     |
| `origin`      | string            | `manual`, `provider`, or `legacy` |
| `createdAt`   | string (ISO-8601) | Follow time observed by ZeroCut   |

### `goal.reached` [#goalreached]

| Field         | Type              | Notes                             |
| ------------- | ----------------- | --------------------------------- |
| `goalId`      | string            | Goal id                           |
| `description` | string \| null    | Goal description                  |
| `amount`      | integer           | Target, minor units               |
| `totalRaised` | integer           | Raised at completion, minor units |
| `currency`    | string            | Three letters, uppercase          |
| `completedAt` | string (ISO-8601) | Completion time                   |

## Control frames [#control-frames]

Control frames carry `family: "control"` and an `event:` name matching `type`.

| `type`      | Meaning                                                      |
| ----------- | ------------------------------------------------------------ |
| `connected` | Stream is live. `data.baselineCursor` is the start point.    |
| `replayed`  | Replay finished. `data.count`, `data.endCursor`.             |
| `ping`      | Keepalive every 10s. `data.timestamp` (epoch ms).            |
| `reconnect` | The server is rotating this connection. Reconnect now.       |
| `resync`    | Your cursor is unusable. `data.reason`. Reconcile over REST. |
| `error`     | Terminal. `data.code`, `data.message`. A close follows.      |

`connected` is not guaranteed to be the first frame. When a resume fails, the
sequence is `resync` then close, with **no** `connected` and no
`baselineCursor` — do not block waiting for `connected`.

`error` codes: `keyRevoked`, `keyDisabled`, `keyExpired`, `entitlementLost`,
`subscriberUnavailable`, `subscriptionUnavailable`, `streamFailed`.

## Wire framing [#wire-framing]

```
retry: 2000

id: 1788093001234-0
event: donation.created
data: {"id":"1788093001234-0","family":"domain","type":"donation.created",...}

```

The `retry:` hint is sent once, before any frame. Domain frames carry `id:` and
`event:`; control frames carry `event:` only, except `ping`, which may carry an
`id:` checkpoint (see [Filters](#filters)).

## Filters [#filters]

Both filters are optional and evaluated on the server.

| Parameter | Value                        | Cap |
| --------- | ---------------------------- | --- |
| `events`  | Comma-separated public types | 8   |
| `perkIds` | Comma-separated perk uuids   | 10  |

```
/api/v1/events/stream?events=donation.created&perkIds=8b1f...,c33a...
```

`perkIds` narrows donations only — it matches the donation's primary perk or
any cart line — and leaves follower and goal events untouched. For a perk-only
stream, combine both parameters.

An unknown type is `400` `UNKNOWN_EVENT_TYPE`; an empty or over-cap value is
`400` `INVALID_FILTER`.

A filtered-out event still moves your cursor forward: the next `ping` carries
an `id:` checkpoint so a narrow filter cannot strand you in a window you keep
re-scanning. Treat a `ping` with an `id:` exactly like any other id you store.

## Resuming [#resuming]

On reconnect, send the last id you processed. In priority order the server
reads the `Last-Event-ID` request header, then the `lastEventId` query
parameter (browsers only send the header on automatic reconnects).

```bash
curl -N \
  -H "X-API-Key: zerocut.your_api_key_here" \
  -H "Last-Event-ID: 1788093001234-0" \
  https://zerocut.gg/api/v1/events/stream
```

Delivery is **at-least-once**. Duplicates are possible across reconnects — dedupe
on the envelope `id`.

Roughly the last 256 events per account are retained, trimmed to a 15-minute
window. A cursor older than that window, ahead of the stream, or requiring more
than 50 replayed events produces a `resync` frame and an immediate close, with
`data.reason` one of `cursor-trimmed`, `cursor-ahead`, `replay-over-cap`,
`replay-scan-over-cap`, `replay-corrupt`, `live-buffer-overflow`.

A `resync` means there is a gap. Reconcile over `GET /donation/list`,
`GET /follower/list`, and `GET /goal/list`, then reconnect **without** a resume
point. Reconnecting with the same cursor only produces another `resync`.

`0-0` is rejected with `400` `INVALID_CURSOR`. To start from live, send no
resume point at all.

## EventSource (stream tokens) [#eventsource-stream-tokens]

`EventSource` cannot set request headers, so browsers exchange the API key for
a short-lived token first. Mint the token from your server, never from a page
that would have to embed the API key.

```bash
curl -X POST https://zerocut.gg/api/v1/events/stream-token \
  -H "X-API-Key: zerocut.your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"events":["donation.created"]}'
```

```json
{
  "streamToken": "zcst.0123...",
  "expiresAt": "2026-08-30T12:31:01.234Z",
  "expiresIn": 60
}
```

The token is single-use and lives 60 seconds. It authorises *opening* a stream,
not holding one — once connected, the connection's lifetime is governed by the
API key. Send `{}` when you want no filters.

A token is spent the moment a connection opens, and connections rotate roughly
every 4.5 minutes, so **every** `EventSource` client needs a re-mint loop. The
browser's own automatic reconnect replays the spent token, gets a `401`, and —
per the SSE specification — closes the connection permanently without retrying
again. A snippet without the loop below goes silently dark after the first
rotation.

```js
let source;
let lastEventId = null;

async function connect() {
  const streamToken = await mintStreamToken(); // your server, POST /events/stream-token
  const url = new URL("/api/v1/events/stream", location.origin);
  url.searchParams.set("streamToken", streamToken);
  if (lastEventId) url.searchParams.set("lastEventId", lastEventId);

  source = new EventSource(url);

  source.addEventListener("donation.created", (event) => {
    lastEventId = event.lastEventId;
    const frame = JSON.parse(event.data);
    console.log(frame.data.amount, frame.data.currency);
  });

  // Rotation: expected every ~4.5 minutes. Reconnect with a fresh token.
  source.addEventListener("reconnect", () => {
    source.close();
    void connect();
  });

  source.addEventListener("resync", () => {
    source.close();
    lastEventId = null; // Refetch over REST, then resume from live.
    void connect();
  });

  source.onerror = () => {
    source.close();
    setTimeout(connect, 2000); // Back off; the previous token is already spent.
  };
}
```

The browser resends `Last-Event-ID` automatically on reconnect, but a stream
token is single-use — mint a fresh one for every connect, including after a
`reconnect` frame and after any non-200 response. A rejected connect (rate
limit, filter error, concurrency cap) also consumes the token, so always
re-mint rather than retrying the same URL.

Filters bound at mint time win: if the token carries `events` or `perkIds`,
repeating either parameter on the connect URL returns `400` `FILTER_CONFLICT`.

## Streaming fetch example [#streaming-fetch-example]

```js
const response = await fetch("https://zerocut.gg/api/v1/events/stream", {
  headers: {
    "X-API-Key": process.env.ZEROCUT_API_KEY,
    Accept: "text/event-stream",
    ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}),
  },
});

if (!response.ok) throw new Error(await response.text());

const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += value;

  let split;
  while ((split = buffer.indexOf("\n\n")) !== -1) {
    const raw = buffer.slice(0, split);
    buffer = buffer.slice(split + 2);

    let id;
    let data;
    for (const line of raw.split("\n")) {
      if (line.startsWith("id: ")) id = line.slice(4);
      if (line.startsWith("data: ")) data = JSON.parse(line.slice(6));
    }
    if (id) lastEventId = id;
    if (data?.family === "domain") handleEvent(data);
  }
}
```

Reconnect when the loop ends, using the last id you stored.

## Handling events with one switch [#handling-events-with-one-switch]

Every domain frame shares the same envelope and discriminates on `type`, so a
single `switch` covers the whole catalog. Copy these types — they match the
field tables above exactly:

```ts
type PerkLine = {
  id: string;
  title: string;
  quantity: number;
  unitAmount: number;
  lineTotal: number;
  currency: string;
};

type Envelope<Type extends string, Data> = {
  id: string;
  family: "domain";
  type: Type;
  version: 1;
  createdAt: string;
  replayed: boolean;
  data: Data;
};

type DonationCreated = Envelope<
  "donation.created",
  {
    id: string;
    amount: number;
    currency: string;
    donorName: string | null;
    message: string | null;
    country: string | null;
    perk: { id: string; title: string } | null;
    perks: PerkLine[];
    createdAt: string;
  }
>;

type FollowerCreated = Envelope<
  "follower.created",
  {
    id: string;
    username: string;
    displayName: string | null;
    platform: string;
    origin: "manual" | "provider" | "legacy";
    createdAt: string;
  }
>;

type GoalReached = Envelope<
  "goal.reached",
  {
    goalId: string;
    description: string | null;
    amount: number;
    totalRaised: number;
    currency: string;
    completedAt: string;
  }
>;

type ZeroCutEvent = DonationCreated | FollowerCreated | GoalReached;
```

```ts
function handleEvent(frame: ZeroCutEvent) {
  switch (frame.type) {
    case "donation.created":
      console.log(`${frame.data.donorName ?? "Anonymous"} donated`, frame.data.amount);
      break;
    case "follower.created":
      console.log(`${frame.data.username} followed on ${frame.data.platform}`);
      break;
    case "goal.reached":
      console.log(`Goal reached: ${frame.data.totalRaised}/${frame.data.amount}`);
      break;
    default:
      // New types may be added; ignore what you do not recognise.
      break;
  }
}
```

Route into it from either transport. The streaming-fetch loop above already
calls `handleEvent(data)` for `family === "domain"` frames. With
`EventSource`, named `event:` lines mean `onmessage` never fires — attach the
same listener once per type:

```ts
const TYPES = ["donation.created", "follower.created", "goal.reached"] as const;

for (const type of TYPES) {
  source.addEventListener(type, (event) => {
    lastEventId = event.lastEventId;
    handleEvent(JSON.parse(event.data) as ZeroCutEvent);
  });
}
```

If you subscribed with an `events` filter, only the listed types can arrive —
the unused `case` branches are simply never hit, so the same handler works for
a full stream and a donations-only stream alike.

## Channels and scope [#channels-and-scope]

The stream is scoped to the account that owns the key. There is no way to
request a channel: a `channels` query parameter returns `400`
`CHANNEL_PARAM_NOT_ALLOWED` rather than being ignored, and `client_type`
returns `400` `CLIENT_TYPE_NOT_ALLOWED`.

## Limits [#limits]

| Limit                       | Value       |
| --------------------------- | ----------- |
| Connects per IP             | 60 / minute |
| Connects per API key        | 20 / minute |
| Stream-token mints per key  | 30 / minute |
| Concurrent streams per key  | 4           |
| Concurrent streams per user | 16          |
| Concurrent streams per IP   | 64          |

Exceeding a concurrency limit returns `429` with code `TOO_MANY_CONNECTIONS`
and a `Retry-After` header. The per-IP ceiling is shared with the creator's own
dashboard and overlay connections from the same address, so an aggregator
hosting many creators' integrations on one host can reach it before the per-key
and per-user caps.

Connections rotate shortly before 5 minutes with a `reconnect` frame. That is
normal — reconnect immediately with your last id.

A connection that stops reading is disconnected rather than buffered
indefinitely. Process frames promptly and hand slow work to a queue.

Rejections before the stream opens are JSON `{ error, code }` bodies with the
same status codes as the REST API. Once the response is `text/event-stream`,
failures arrive as `error` control frames.

## CORS [#cors]

The stream responds with `Access-Control-Allow-Origin: *` and never uses
cookies, so `Access-Control-Allow-Credentials` is never set. `OPTIONS` preflight
is supported.

## Test mode [#test-mode]

`/api/test/v1/events/stream` and `/api/test/v1/events/stream-token` return
`404`. There is no fixture stream: fabricated frames teach nothing about
ordering, replay, or backpressure. Develop against your own key and a sandbox
donation.
