Event stream
Receive what happens during a DJ's broadcast in real time over Server-Sent Events (SSE). A single connection carries chat, joins, hearts, and donations, and only the events covered by the granted permissions flow through it.
| Method and path | GET /v1/live/events |
| Permission | Any one of events.chat, events.presence, events.like, events.donation |
| Success | 200 (text/event-stream) |
| When not on air | 404 (OAPI_MNGR_0301) |
export SPOON_BASE_URL="https://kr-openapi.spooncast.net"
export SPOON_ACCESS_TOKEN="{your access token}"
For the Japan region use https://jp-openapi.spooncast.net — see regions.
What actually happens
get a token ─▶ connect ─▶ join the broadcast ─▶ receive events
│ │
└─ 404: not on air └─ end: stream ended
back off and retry back to connecting
The token decides the broadcast
There is no place in the path or the query to name a DJ. One token is one DJ's consent, so which token you use is which broadcast you work with. A bot cannot point at someone else's broadcast.
Connecting joins the bot to the broadcast
This is not a plain subscription. The moment you connect, the bot account enters the room as a listener. Three things follow.
- The bot itself appears in the listener list. A raffle bot can draw itself — filter by
id. The event stream leaves out the bot's own posts, but the list APIs do not. The two follow different rules. - The DJ's controls apply to you. A kick cuts the events off; a chat ban or freeze blocks posting.
- What you receive follows the bot's level in the room. Appointed as a manager, you also get manager-level messages.
The join survives a dropped connection
The bot stays in the room even when the network or a deploy drops the stream. Reconnecting picks up where it left off, and the join record expires on its own after a while.
It ends with an end frame that says why
Nothing follows it. Each reason calls for a different response — reason tells you
whether the broadcast ended, the token expired, or the server closed just this connection.
Connecting posts a listener notice under your bot's name
While connected, a listener notice is posted to the broadcast under your bot account's name. It cannot be turned off and needs nothing from your code — see Listener notice for when and to whom.
Request
GET /v1/live/events
Headers
| Name | Required | Description |
|---|---|---|
Authorization | ✔ | Bearer {access_token}. |
Accept | ✔ | text/event-stream. |
There are no path parameters, query parameters, or body. The token decides which DJ's broadcast this is.
Permissions required: the connection opens if any one of events.chat,
events.presence, events.like, or events.donation is present. With several, those
event types arrive over the same connection. Events you did not get consent for are
filtered out, so the connection is healthy while that event type never arrives. With none
of them you get a 403.
Request example
curl -N \
-H "Authorization: Bearer $SPOON_ACCESS_TOKEN" \
-H "Accept: text/event-stream" \
"$SPOON_BASE_URL/v1/live/events"
Response
The stream opens with 200, followed by SSE frames.
:heartbeat
id:1788328046114-0
event:chat
data:{"user":{"id":"Ab1Cd2Ef3Gh4Ij5Kl6Mn7O","nickname":"SpoonListener"},"isDj":false,"message":"Hello","sentTime":"2026-09-02T05:47:26.114Z"}
id:1788328052169-1
event:presence
data:{"user":{"id":"Pq8Rs9Tu0Vw1Xy2Za3Bc4D","nickname":"RadioFan"},"type":"JOIN","fanRank":1,"isManager":false,"favoriteTemperature":36.5,"time":"2026-09-02T05:47:32.169Z"}
id:1788328061044-0
event:like
data:{"user":{"id":"Pq8Rs9Tu0Vw1Xy2Za3Bc4D","nickname":"RadioFan"},"type":"PAID","totalAmount":30,"amount":10,"extraAmount":5,"combo":2,"time":"2026-09-02T05:47:41.044Z"}
id:1788328070512-0
event:donation
data:{"user":{"id":"Ab1Cd2Ef3Gh4Ij5Kl6Mn7O","nickname":"SpoonListener"},"amount":300,"message":"Cheering for you","time":"2026-09-02T05:47:50.512Z"}
event:end
data:{"reason":"LIVE_ENDED"}
| Event | Permission required | When it arrives | Payload |
|---|---|---|---|
chat | events.chat | A listener or the DJ posted a chat message | chat |
presence | events.presence | A listener joined the broadcast | presence |
like | events.like | A listener sent hearts | like |
donation | events.donation | A listener donated | donation |
end | none | The stream is ending. Nothing follows this frame | end |
id: has the form {epochMillis}-{offset}. It is not used for resumption today, but
it is present from the start so the contract does not break when Last-Event-ID
resumption is enabled later.
Heartbeat
:heartbeat
An SSE comment line arrives every 15 seconds by default. Its only purpose is to keep the connection alive when there is no chat.
A standard EventSource ignores these lines for you. Only if you write your own parser
do you need to skip frames that have no data:.
Receiving events
Save the example below and run it, and all four events print to the console. The code in
each event section slots into these handlers.
// stream.ts — node --experimental-strip-types stream.ts
interface EventUser {
id: string;
nickname: string | null;
}
interface ChatEvent {
user: EventUser;
isDj: boolean;
message: string;
sentTime: string;
}
interface PresenceEvent {
user: EventUser;
type: 'JOIN';
fanRank: number | null;
isManager: boolean;
favoriteTemperature: number | null;
time: string;
}
interface LikeEvent {
user: EventUser;
type: 'FREE' | 'PAID';
totalAmount: number;
amount: number;
extraAmount: number;
combo: number | null;
time: string;
}
interface DonationEvent {
user: EventUser;
amount: number;
message: string | null;
time: string;
}
interface StreamEndEvent {
reason: 'LIVE_ENDED' | 'TOKEN_EXPIRED' | 'RECONNECT';
}
const res = await fetch(`${process.env.SPOON_BASE_URL}/v1/live/events`, {
headers: {
Authorization: `Bearer ${process.env.SPOON_ACCESS_TOKEN}`,
Accept: 'text/event-stream',
},
});
if (!res.ok || !res.body) throw new Error(`Could not open the stream: ${res.status}`);
const handlers: Record<string, (d: any) => void> = {
chat: (d: ChatEvent) => console.log(`${d.user.nickname ?? 'Anonymous'}: ${d.message}`),
presence: (d: PresenceEvent) => console.log(`${d.user.nickname ?? 'Anonymous'} joined`),
like: (d: LikeEvent) => console.log(`${d.totalAmount} hearts (${d.type})`),
donation: (d: DonationEvent) => console.log(`${d.amount} spoons donated`),
end: (d: StreamEndEvent) => console.log(`Stream ended: ${d.reason}`),
};
const reader = res.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) {
const lines = frame.split('\n');
const event = lines.find((l) => l.startsWith('event:'))?.slice(6);
const data = lines.find((l) => l.startsWith('data:'))?.slice(5);
if (!event || !data) continue; // `:heartbeat` has no data
handlers[event]?.(JSON.parse(data));
}
}
node --experimental-strip-types stream.ts
node --experimental-strip-types stream.ts
You cannot connect with a browser EventSource. The token goes in the Authorization
header and EventSource cannot set headers. Run the code above on a server and relay the
result to the browser.
Event payloads
Every event carries the same shape for the person it is about.
| Field | Type | Nullable | Description |
|---|---|---|---|
user.id | string | No | Who this is. An encrypted value. |
user.nickname | string | Yes | The nickname. null, never an empty string. |
idid is this person's unique identifier. The same person always gets the same value, so use
it as the key for attendance, running totals, and rankings. Do not identify people by
nickname — it changes and it repeats.
The value is scoped to your bot and that DJ. It survives a DJ disconnecting and granting consent again.
This id is the same value as id on List listeners and
Read the fan ranking.
chat — chat messages
Receive listener chat and DJ quick messages in real time. Permission: events.chat
A listener posting:
{
"user": { "id": "Ab1Cd2Ef3Gh4Ij5Kl6Mn7O", "nickname": "SpoonListener" },
"isDj": false,
"message": "Hello",
"sentTime": "2026-09-02T05:47:26.114Z"
}
The DJ posting — isDj is true:
{
"user": { "id": "Uv4Wx5Yz6Ab7Cd8Ef9Gh0I", "nickname": "SpoonDJ" },
"isDj": true,
"message": "Send in your stories!",
"sentTime": "2026-09-02T05:47:31.802Z"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
user.id | string | No | Who posted. An encrypted value. |
user.nickname | string | Yes | The nickname. |
isDj | boolean | No | Whether this came from the DJ. It sits at the top level, not inside user. |
message | string | No | The chat message as typed. |
sentTime | string | No | When it was sent. ISO-8601 UTC (Z). |
// stream.ts — wire it up as `chat: onChat` in handlers
const counts = new Map();
const onChat = (d: ChatEvent) => {
if (d.isDj) return; // keep the DJ out of the tally
const n = (counts.get(d.user.id) ?? 0) + 1;
counts.set(d.user.id, n); // key on id, never nickname
console.log(`${d.user.nickname ?? 'Anonymous'}: ${d.message} (#${n})`);
};
Skip isDj and the DJ earns points in their own broadcast. DJs post chat and quick
messages too. Leave them in your attendance or ranking tally and the DJ tops their own
leaderboard. The encrypted id does not tell you who the DJ is and comparing nicknames is
not the answer, so branch on this field.
isDj exists only on chat. It is at the event's top level, not inside user. Other
events do not carry it at all — the source does not provide it, and a field that is always
false is worse than no field.
nickname can be null. It is null, not an empty string. Take it as
d.user.nickname ?? 'Anonymous'.
A message that looks like JSON is still a string. A listener can type {"a":1}. Do not parse it.
A chat-banned listener is blocked from posting at all. So no chat event is ever created
for them — nothing is filtered out, there is nothing to create. The ban holds only within
that broadcast; it does not carry to the next one, where that listener's chat flows again.
Donation messages, staff notices, and other system posts do not arrive. Neither do your own bot's posts.
presence — joins
Receive a listener joining the broadcast. Permission: events.presence
{
"user": { "id": "Pq8Rs9Tu0Vw1Xy2Za3Bc4D", "nickname": "RadioFan" },
"type": "JOIN",
"fanRank": 1,
"isManager": false,
"favoriteTemperature": 36.5,
"time": "2026-09-02T05:47:32.169Z"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
user.id | string | No | Who joined. An encrypted value. |
user.nickname | string | Yes | The nickname. |
type | string | No | Only JOIN arrives today. |
fanRank | number | Yes | Their rank in this broadcast's donation ranking. null with no donation history. |
isManager | boolean | No | Whether the DJ made this listener a manager. |
favoriteTemperature | number | Yes | Affinity temperature — a running relationship score that carries across broadcasts, so use it to spot regulars. |
time | string | No | When they joined. ISO-8601 UTC (Z). |
Rank, manager status, and affinity temperature arrive with the join, so you do not need a separate Read the fan ranking call to greet the top fan or a regular.
// stream.ts — wire it up as `presence: onPresence` in handlers
const greeted = new Set();
const onPresence = (d: PresenceEvent) => {
if (greeted.has(d.user.id)) return; // do not greet a re-join twice
greeted.add(d.user.id);
const name = d.user.nickname ?? 'Anonymous';
if (d.fanRank === 1) console.log(`${name} — our top fan just arrived!`);
else if (d.isManager) console.log(`Welcome back, manager ${name}!`);
else if (d.favoriteTemperature !== null && d.favoriteTemperature >= 36.5)
console.log(`Good to see a regular, ${name}!`);
else console.log(`Welcome, ${name}!`);
};
The DJ must appoint the bot as a manager for this event to flow. Without it nothing
fails — the stream opens with 200 and chat arrives normally, while presence never
arrives at all. The procedure is below, and the
bot's level is fixed at the moment it connects, so a mid-broadcast appointment needs a
reconnect.
You decide the affinity threshold. The server does not define "regular" for you. When it
is null, do not judge.
Leaves do not arrive. type is only JOIN today. If you need to know who is in the room
right now, use List listeners.
The same person leaving and rejoining sends another event. Deduplicate by id, as above.
like — hearts
Receive hearts listeners send. Permission: events.like
{
"user": { "id": "Pq8Rs9Tu0Vw1Xy2Za3Bc4D", "nickname": "RadioFan" },
"type": "PAID",
"totalAmount": 30,
"amount": 10,
"extraAmount": 5,
"combo": 2,
"time": "2026-09-03T02:59:03.249Z"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
user.id | string | No | Who sent them. An encrypted value. |
user.nickname | string | Yes | The nickname. |
type | string | No | Whether it cost money. PAID is an item heart bought with spoons; FREE is everything else. |
totalAmount | number | No | Hearts added to the broadcast this time — (amount + extraAmount) × combo. |
amount | number | No | Hearts a single item gives. |
extraAmount | number | No | Hearts added by a boost item. 0 when there is none. |
combo | number | Yes | How many were stacked. null for free hearts. |
time | string | No | When they were sent. ISO-8601 UTC (Z). |
FREE covers two things — hearts a listener taps, and an item that gives zero hearts and does
not draw down their balance. Tapped hearts have combo null.
FREE hearts still accrue. type tells you how loudly to react — it is not a reason to drop
them from your tally.
// stream.ts — wire it up as `like: onLike` in handlers
let hearts = 0;
const onLike = (d: LikeEvent) => {
hearts += d.totalAmount; // tally totalAmount — amount alone drops combo and boost
if (d.type === 'PAID') {
console.log(`Thanks for the ${d.totalAmount} hearts, ${d.user.nickname ?? 'Anonymous'}!`);
}
// FREE hearts are counted but not answered
};
Reacting to every FREE makes the broadcast noisy. Tapped hearts pour in, while PAID
means the listener spent an item, which carries different weight. Usually
you react to PAID only and just count FREE. If you do react to FREE, add a cooldown.
Tally totalAmount. Adding up amount alone drops the combo and the boost, so you count
fewer hearts than the broadcast did. totalAmount matches what the broadcast server adds.
like.amount is a heart count and donation.amount is spoons. Same name,
different unit — and the arithmetic differs too. For hearts use totalAmount, which is already
(amount + extraAmount) × combo; a donation's amount is already the total, so never multiply it again.
combo is null for free hearts. Feeding it straight into arithmetic throws.
donation — donations
Receive donations from listeners. Permission: events.donation
{
"user": { "id": "Ab1Cd2Ef3Gh4Ij5Kl6Mn7O", "nickname": "SpoonListener" },
"amount": 300,
"message": "Cheering for you",
"time": "2026-09-02T05:47:50.512Z"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
user.id | string | No | Who donated. An encrypted value. |
user.nickname | string | Yes | The nickname. |
amount | number | No | Total spoons for this donation — already multiplied out. |
message | string | Yes | A note sent along. Most donations have none. |
time | string | No | When it was sent. ISO-8601 UTC (Z). |
// stream.ts — wire it up as `donation: onDonation` in handlers
const spoons = new Map();
const onDonation = (d: DonationEvent) => {
const total = (spoons.get(d.user.id) ?? 0) + d.amount;
spoons.set(d.user.id, total);
const note = d.message ?? ''; // null is the common case
console.log(`Thanks for the ${d.amount} spoons, ${d.user.nickname ?? 'Anonymous'}! ${note}`);
};
amount is already the total in spoons. It is not a per-item value, so do not multiply by a
count again. like.amount is a heart count, not spoons — same name, different unit.
message is null more often than not. It is not an empty string, so d.message.trim() throws.
Build a whole-broadcast donation ranking from these events. Read the fan ranking only covers people currently in the room, so anyone who donated and left drops off it.
end — stream ended
The stream says why it ended, then closes. Nothing follows this frame. No permission is needed — a bot with a narrow scope still has to learn why its connection ended.
{ "reason": "LIVE_ENDED" }
| Field | Type | Nullable | Description |
|---|---|---|---|
reason | string | No | One of LIVE_ENDED, TOKEN_EXPIRED, or RECONNECT. |
reason | What happened | What to do |
|---|---|---|
LIVE_ENDED | The broadcast ended. | Do not reconnect. Wait for the next broadcast, then connect. |
TOKEN_EXPIRED | The access token expired. | Refresh the token and reconnect immediately. |
RECONNECT | The server closed just this connection. The broadcast is still running. | Reconnect immediately, with no backoff. Keep using the same token. |
With RECONNECT both the broadcast and the token are fine. There is nothing to refresh,
and every second of backoff is events you miss.
Only a network problem drops the connection without this frame. Back off and reconnect when that happens.
// stream.ts — wire it up as `end: onEnd` in handlers.
// The three reasons need different responses — decide what comes next here and run the
// reconnect loop outside
let next = null;
const onEnd = (d: StreamEndEvent) => {
if (d.reason === 'LIVE_ENDED') next = 'wait'; // ended — back off, then reconnect
else if (d.reason === 'TOKEN_EXPIRED') next = 'refresh'; // refresh the token, then reconnect
else next = 'now'; // RECONNECT — reconnect, no backoff
console.log(`Stream ended: ${d.reason} → ${next}`);
};
How to appoint the bot as a manager
The DJ does this in the Spoon app. A bot cannot request it through the API.
- Get the bot into the broadcast first. When the bot connects to the event stream while the DJ is on air, the bot account joins as a listener. Only someone in the room can be appointed.
- The DJ opens the listener list on the broadcast screen and selects the bot account.
- In the profile, the DJ taps [Appoint as manager]. A broadcast can have up to 3 managers.
- The bot disconnects and reconnects. The bot's level is fixed at the moment it connects. Appointing it mid-broadcast does not affect the current connection; it applies from the next one.
The appointment is what gates the presence event.
What you do not receive
This stream emits only the events documented here. New events on the broadcast server do not start flowing automatically.
| Private messages (DMs) | Messages addressed to one listener. They never arrive. |
| Manager-level and above | They do not arrive unless the bot is a manager. Same rule as the Spoon app. |
| The bot's own messages and join | What you send does not come back to you, to prevent echo loops. Other bots in the same broadcast are visible. |
| System events | Broadcast metadata updates, studio notices, and the like do not arrive. |
What you receive follows the bot's level in the room. Joined as an ordinary member, you get what a listener sees on their screen; once the DJ appoints the bot as a manager, manager-level messages arrive too. Private messages (DMs) never arrive either way.
The connection loop
You do not need to check whether a broadcast is on. The stream endpoint returns 404 before
connecting when the DJ is not on air. One loop is enough.
try to connect
├ 404 (OAPI_MNGR_0301) → not on air. back off and try again
└ 200 → receive events … end{LIVE_ENDED} → back off, then try connecting again
end{RECONNECT} → try connecting again immediately, no backoff
Checking GET /v1/live first costs a second round trip, and if the broadcast ends between the
check and the connect you get the 404 anyway. This loop also works for a bot granted only
events.chat, with no live.read.
Do not retry immediately on every 404. Leave a few seconds between attempts — each retry
runs a token check, and this endpoint has no rate limit in front of it.
Call GET /v1/live when you need broadcast information —
the title, categories, listener count, or isChatFrozen.
Reconnection strategy
The stream lives a long time, but not forever. A deploy restarts the pods and closes every open connection, so reconnecting is mandatory, not optional.
end(LIVE_ENDED) → The broadcast ended. Back off and try connecting again
end(TOKEN_EXPIRED) → Refresh the token and reconnect immediately
end(RECONNECT) → The server closed just the connection. Reconnect immediately, no backoff
Disconnect with no reason → Reconnect with exponential backoff (1s → 2s → 4s → … max 30s) + jitter
404 → Not on air. A state, not an error — back off and connect again
401 → Re-authorization is needed. A human has to step in, so do not retry forever
403 (no detailCode) → A permission is missing. Only re-consent is needed; the integration is alive
403 OAPI_MNGR_0209 → The DJ blocked the bot. Retrying does not help, so stop and tell the DJ
Handle 401 and 403 differently. 401 is only resolved by the DJ authorizing again, while a 403 for a missing permission just needs an extra
permission. Lumping them together tears down a perfectly good integration. And retrying
a 401 forever fails forever while burning through your call quota.
Chat sent while you were disconnected is not delivered after reconnecting.
Last-Event-ID resumption is not open yet.
Common mistakes
Do not try to parse lines starting with :. That is the heartbeat — skip frames with no data:.
Do not assume a fixed number of fractional digits in timestamps. Use an ISO-8601 parser and
avoid format strings like %Y-%m-%dT%H:%M:%S.%fZ.
Do not expect compression. This endpoint does not compress even if you send
Accept-Encoding: gzip — buffering before flushing would stop it being real time.
The stream ending does not mean the broadcast ended. Read reason on the end frame.
Field-level traps (nickname being null, tallying hearts with totalAmount) are covered with
each event in Real-time event permissions.
Errors
| HTTP | detailCode | What happened | What to do |
|---|---|---|---|
401 | none | The token is missing or invalid — revoked, expired, disconnected by the DJ, or the app is suspended. | Ask the DJ to authorize again. |
403 | none | None of the events. permissions is granted. | Add the permission and get consent again. |
403 | OAPI_MNGR_0209 | The bot cannot join the broadcast. The DJ may have blocked it. | Retrying does not help. Tell the DJ. |
404 | OAPI_MNGR_0301 | The DJ is not on air. | Back off and connect again. |
502 | OAPI_MNGR_2302 | Broadcast server error. | Retry after a backoff. |
Errors before the stream opens arrive as SSE frames. This endpoint responds as text/event-stream, so even a failed connection has a body
that starts with data:. Parsing that body directly as JSON throws.
Branch on the HTTP status code, and if you need the body, strip the data: prefix first.
States outside this table can arrive too. A wrong method or Content-Type
(400, 405, 415 — OAPI_MNGR_0103), the call quota (429, no body), and server errors
(500 — OAPI_MNGR_1002) are common to every endpoint — see
Error codes.
Related documents
- Endpoint list — the full endpoint list.
- Read the current broadcast — title, listener count, and
isChatFrozen. - List listeners — check who is in the room, keyed by the same
id. - Send chat — reply to the events you receive.
- Error codes — the full list of error codes.