Subscribe to donations, followers, and goal completions over Server-Sent Events.
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.
Send your API key in the X-API-Key header, exactly as with every other
endpoint:
curl -N \
-H "X-API-Key: zerocut.your_api_key_here" \
-H "Accept: text/event-stream" \
https://zerocut.gg/api/v1/events/streamAPI 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 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.
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, e.g.
?events=donation.created.
Test alerts fired from your dashboard are not published — every id on this stream resolves over REST.
Each frame's data: line is one JSON object:
{
"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| 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| 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| 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 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.
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).
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.
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).
curl -N \
-H "X-API-Key: zerocut.your_api_key_here" \
-H "Last-Event-ID: 1788093001234-0" \
https://zerocut.gg/api/v1/events/streamDelivery 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 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.
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"]}'{
"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.
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.
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.
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:
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;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:
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.
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.
| 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.
The stream responds with Access-Control-Allow-Origin: * and never uses
cookies, so Access-Control-Allow-Credentials is never set. OPTIONS preflight
is supported.
/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.