Skip to main content

Build a bot

Follow this guide to build a bot that receives a DJ's live chat in real time and sends messages under the bot's own name. It runs from getting consent all the way to the bot's first message.

What you need​

  • The Client ID and Client Secret from Register an app
  • The login redirect URL registered on your app
  • The permissions (scopes) registered on your app — this guide uses events.chat and chat.send
  • Node.js 22.6 or later — the examples are TypeScript, so --experimental-strip-types is required

API address​

The examples below write the base URL as $SPOON_BASE_URL. Set it to the address of the region you are integrating with.

export SPOON_BASE_URL="https://kr-openapi.spooncast.net"
RegionBase URL
Koreahttps://kr-openapi.spooncast.net
Japanhttps://jp-openapi.spooncast.net

Use the address of the region your app is registered in. The DJ's account decides the region, so calling another region's address will not find that DJ's broadcast.

Send the DJ's browser to the consent screen and receive the code that comes back. The full rules are in Get user consent.

https://developers.spooncast.net/kr/oauth/authorize
?response_type=code
&client_id={Client ID}
&redirect_uri={login redirect URL}
&scope=events.chat%20chat.send
&state={a random value you generate}

The authorize URL carries the region too. Korea uses /kr, Japan uses /jp.

If the DJ approves, a code is appended to your redirect URL. You must use it within 60 seconds.

2. Exchange the code for a token​

Exchange the code for an access token. This call uses your Client Secret, so make it from your server only.

curl -X POST "$SPOON_BASE_URL/v1/oauth/token" \
-u "{Client ID}:{Client Secret}" \
-d "grant_type=authorization_code" \
-d "code={the code you received}" \
-d "redirect_uri={login redirect URL}"
{
"access_token": "at_3f9c1e7b8d2a4056...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "rt_7b2e5a9c4f10d386...",
"scope": "events.chat chat.send"
}
ValueLifetimeWhat to know
access_token1 hourAttach it to every bot API call as Authorization: Bearer.
refresh_token30 daysUse it to get a new access token.

A long-running bot must implement refresh. When the access token expires, any open event stream ends with it. Refresh well ahead of expiry (an hour before, for example), and persist the result so it survives a restart. If you keep it only in memory, the DJ has to grant consent again every time your process restarts.

Refreshing also replaces the refresh token. If you do not store the new refresh_token from the response, your next refresh fails — see Tokens for the details.

Refresh through the same endpoint with grant_type=refresh_token.

curl -X POST "$SPOON_BASE_URL/v1/oauth/token" \
-u "{Client ID}:{Client Secret}" \
-d "grant_type=refresh_token" \
-d "refresh_token={the refresh_token you received}"

Export the token you received as well.

export SPOON_ACCESS_TOKEN="{access_token}"

3. Connect to the event stream​

Connecting posts a listener notice

The moment you connect, a listener notice is posted to the broadcast under your bot's name. There is nothing to do in your code — see Listener notice.

If the DJ is on air, chat starts flowing immediately. Check it with curl first.

curl -N \
-H "Authorization: Bearer {access_token}" \
-H "Accept: text/event-stream" \
"$SPOON_BASE_URL/v1/live/events"
:heartbeat

id:1788328046114-0
event:chat
data:{"user":{"id":"Ab1Cd2Ef3Gh4Ij5Kl6Mn7O","nickname":"SpoonListener"},"isDj":false,"message":"Hello","sentTime":"2026-09-02T05:47:26.114Z"}

If the DJ is not on air you get a 404. That is a state, not an error — wait until the DJ goes live and connect again.

Here is the same thing in Node. Save it and run it, and chat is printed to the console.

// bot.ts — node --experimental-strip-types bot.ts

interface EventUser {
id: string;
nickname: string | null;
}

interface ChatEvent {
user: EventUser;
isDj: boolean;
message: string;
sentTime: string;
}

const BASE = process.env.SPOON_BASE_URL;
const TOKEN = process.env.SPOON_ACCESS_TOKEN;

const res = await fetch(`${BASE}/v1/live/events`, {
headers: { Authorization: `Bearer ${TOKEN}`, Accept: 'text/event-stream' },
});

if (!res.ok || !res.body) throw new Error(`Could not open the stream: ${res.status}`);

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';

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

// SSE separates events with a blank line; the last chunk is incomplete, so keep it buffered
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 (!data) continue; // frames without data, such as `:heartbeat`

if (event === 'chat') {
const chat = JSON.parse(data) as ChatEvent;
console.log(`${chat.user.nickname ?? 'Anonymous'}: ${chat.message}`);
}
}
}
node --experimental-strip-types bot.ts

The browser EventSource cannot connect. The token goes in the Authorization header, and EventSource cannot set headers. Run the code above on your server and relay the result to the browser.

4. Send a message​

Send chat under the bot account's name. On success you get a 204 with no body.

curl -i -X POST "$SPOON_BASE_URL/v1/live/chat" \
-H "Authorization: Bearer {access_token}" \
-H "Content-Type: application/json" \
-d '{"message":"Hello! Your bot says hi"}'
HTTP/2 204

How to check it worked​

While the DJ is on air, your bot is wired up correctly if all of the following hold.

  1. The stream opens with 200 and a :heartbeat line arrives every 15 seconds.
  2. When a listener types in chat, an event: chat frame arrives right away.
  3. POST /v1/live/chat returns 204, and the message appears in the Spoon app chat under the bot account's name.
  4. Messages your bot sends do not come back on your own stream — that rule prevents echo loops.

Next steps​