Skip to main content

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 pathGET /v1/live/events
PermissionAny one of events.chat, events.presence, events.like, events.donation
Success200 (text/event-stream)
When not on air404 (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​

NameRequiredDescription
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"}
EventPermission requiredWhen it arrivesPayload
chatevents.chatA listener or the DJ posted a chat messagechat
presenceevents.presenceA listener joined the broadcastpresence
likeevents.likeA listener sent heartslike
donationevents.donationA listener donateddonation
endnoneThe stream is ending. Nothing follows this frameend

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.

FieldTypeNullableDescription
user.idstringNoWho this is. An encrypted value.
user.nicknamestringYesThe nickname. null, never an empty string.
Identify people by id

id 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"
}
FieldTypeNullableDescription
user.idstringNoWho posted. An encrypted value.
user.nicknamestringYesThe nickname.
isDjbooleanNoWhether this came from the DJ. It sits at the top level, not inside user.
messagestringNoThe chat message as typed.
sentTimestringNoWhen 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"
}
FieldTypeNullableDescription
user.idstringNoWho joined. An encrypted value.
user.nicknamestringYesThe nickname.
typestringNoOnly JOIN arrives today.
fanRanknumberYesTheir rank in this broadcast's donation ranking. null with no donation history.
isManagerbooleanNoWhether the DJ made this listener a manager.
favoriteTemperaturenumberYesAffinity temperature — a running relationship score that carries across broadcasts, so use it to spot regulars.
timestringNoWhen 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"
}
FieldTypeNullableDescription
user.idstringNoWho sent them. An encrypted value.
user.nicknamestringYesThe nickname.
typestringNoWhether it cost money. PAID is an item heart bought with spoons; FREE is everything else.
totalAmountnumberNoHearts added to the broadcast this time — (amount + extraAmount) × combo.
amountnumberNoHearts a single item gives.
extraAmountnumberNoHearts added by a boost item. 0 when there is none.
combonumberYesHow many were stacked. null for free hearts.
timestringNoWhen 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"
}
FieldTypeNullableDescription
user.idstringNoWho donated. An encrypted value.
user.nicknamestringYesThe nickname.
amountnumberNoTotal spoons for this donation — already multiplied out.
messagestringYesA note sent along. Most donations have none.
timestringNoWhen 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" }
FieldTypeNullableDescription
reasonstringNoOne of LIVE_ENDED, TOKEN_EXPIRED, or RECONNECT.
reasonWhat happenedWhat to do
LIVE_ENDEDThe broadcast ended.Do not reconnect. Wait for the next broadcast, then connect.
TOKEN_EXPIREDThe access token expired.Refresh the token and reconnect immediately.
RECONNECTThe 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.

  1. 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.
  2. The DJ opens the listener list on the broadcast screen and selects the bot account.
  3. In the profile, the DJ taps [Appoint as manager]. A broadcast can have up to 3 managers.
  4. 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 aboveThey do not arrive unless the bot is a manager. Same rule as the Spoon app.
The bot's own messages and joinWhat you send does not come back to you, to prevent echo loops. Other bots in the same broadcast are visible.
System eventsBroadcast 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​

HTTPdetailCodeWhat happenedWhat to do
401noneThe token is missing or invalid — revoked, expired, disconnected by the DJ, or the app is suspended.Ask the DJ to authorize again.
403noneNone of the events. permissions is granted.Add the permission and get consent again.
403OAPI_MNGR_0209The bot cannot join the broadcast. The DJ may have blocked it.Retrying does not help. Tell the DJ.
404OAPI_MNGR_0301The DJ is not on air.Back off and connect again.
502OAPI_MNGR_2302Broadcast 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.