Hello! I’m the developer of AITuber OnAir, a web app for running AI-powered VTuber streams:
We’ve just added a brand-new feature: Twitch chat integration. From now on, AITuber OnAir supports both YouTube Live and Twitch.
In this post I’ll share what I learned while building it—how to fetch comments from Twitch (with EventSub WebSockets) and, for completeness, how to fetch comments from YouTube Live as well.
I hope fellow AITuber developers find it useful!
1. Fetching YouTube Live chat messages
Overall flow
When you want to pull live chat from a YouTube stream, the request sequence looks like this:
[Live Video ID]
│ videos?part=liveStreamingDetails
▼
[activeLiveChatId]
│ liveChat/messages?part=snippet,authorDetails → nextPageToken
▼
[Chat Messages]
You hit two endpoints:
-
Live Video ID – grab it from the URL’s
v=
parameter, etc. -
activeLiveChatId – returned inside the
liveStreamingDetails
object of thevideos
endpoint. -
liveChat/messages – loop with
nextPageToken
to paginate.¹ ²
Getting your YouTube API key & Live ID
You need both before you can fetch comments. I explain how to obtain them in this note (Japanese):
Enter the required settings for YouTube Live
Sample code (YouTube Live)
// youtube-chat.ts
import 'dotenv/config';
import fetch from 'node-fetch';
const API_KEY = process.env.YT_API_KEY!; // Data API v3 key
const VIDEO_ID = process.env.YT_VIDEO_ID!; // Live video ID
/** Return activeLiveChatId if the stream is live and chat is enabled */
async function getLiveChatId(videoId: string) {
const url =
`https://youtube.googleapis.com/youtube/v3/videos` +
`?part=liveStreamingDetails&id=${videoId}&key=${API_KEY}`;
const res = await fetch(url);
const json = await res.json();
return json.items?.[0]?.liveStreamingDetails?.activeLiveChatId ?? null;
}
/** Poll chat forever and dump to console */
async function pollChat(liveChatId: string, pageToken = '') {
const url =
`https://youtube.googleapis.com/youtube/v3/liveChat/messages` +
`?liveChatId=${liveChatId}&part=snippet,authorDetails&key=${API_KEY}` +
(pageToken ? `&pageToken=${pageToken}` : '');
const res = await fetch(url);
const json = await res.json();
// Pretty-print messages
for (const m of json.items ?? []) {
const author = m.authorDetails.displayName;
const text = m.snippet.textMessageDetails?.messageText ?? '';
if (text) console.log(`[YT] ${author}: ${text}`);
}
// Wait 1–2 s and recurse (API guideline: ≥ 1 s)
const waitMs = json.pollingIntervalMillis ?? 1000;
await new Promise(r => setTimeout(r, waitMs));
await pollChat(liveChatId, json.nextPageToken);
}
(async () => {
const id = await getLiveChatId(VIDEO_ID);
if (!id) { console.error('Stream not found or chat disabled'); return; }
await pollChat(id);
})();
Run it
export YT_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxx
export YT_VIDEO_ID=YOUR_LIVE_VIDEO_ID
node youtube-chat.ts
2. Fetching Twitch chat (EventSub WebSocket)
IRC is passé—use EventSub instead
The first docs you’ll find often describe using IRC, but as of 2024 Twitch recommends EventSub’s channel.chat.message
for reading chat.
Add the user:read:chat
(formerly chat:read
) scope to your OAuth token and you can listen over a single WebSocket. Just remember to reconnect yourself when you receive a session_reconnect
event.³ ⁴
https://x.com/shinshin86/status/1924750262528573898
Overall flow (Twitch)
[User Access Token] + [Channel Login]
│ 1) GET /users?login=<channel> (Helix Users API)
▼
[broadcaster_user_id]
│ 2) GET /oauth2/validate (Validate API)
▼
[user_id (token owner)]
│ 3) Connect wss://eventsub.wss.twitch.tv/ws
▼ (session_welcome)
[session_id]
│ 4) POST /eventsub/subscriptions
│ type=channel.chat.message
▼
[WebSocket PUSH] (notification)
│
[Chat Messages]
References:
Helix Users API ・ Validating Tokens
Getting your token & client ID
Register an app in the Twitch Developer Console to obtain a Client ID. Detailed steps (Japanese):
Setup instructions for streaming on Twitch with AITuber OnAir
Use Public client type
AITuber OnAir is a browser-only SPA, so there’s no safe place for a client secret. Twitch recommends treating pure client-side JavaScript apps as Public and using the Implicit Grant Flow or Device Code Flow.³
-
Public client highlights
- No client secret—one is never generated.
- You don’t send a secret when getting or refreshing tokens.
- The Client ID itself is public, so including it in HTML/JS is fine.⁵ (In production we still let users provide their own ID instead.)
- Only Implicit or Device-Code flows are available (Client-Credentials etc. won’t work).⁶
Twitch’s own chart (“Getting OAuth Access Tokens”) compares the four flows suitable for SPAs—worth bookmarking when you decide which to use.
Sample code (Twitch)
import 'dotenv/config';
import fetch from 'node-fetch';
import WebSocket from 'ws';
const TOKEN = process.env.TWITCH_TOKEN!; // user access (user:read:chat)
const CLIENT_ID = process.env.TWITCH_CLIENT_ID!; // app’s Client ID
const CHANNEL = process.env.TWITCH_CHANNEL!; // broadcaster’s login name
(async () => {
/* 1) Get broadcaster_user_id */
const uRes = await fetch(
`https://api.twitch.tv/helix/users?login=${encodeURIComponent(CHANNEL)}`,
{ headers: { Authorization: `Bearer ${TOKEN}`, 'Client-Id': CLIENT_ID } }
);
const broadcasterId = (await uRes.json()).data?.[0]?.id;
if (!broadcasterId) throw new Error('invalid channel');
/* 2) Get own user_id (token owner) */
const vRes = await fetch('https://id.twitch.tv/oauth2/validate', {
headers: { Authorization: `OAuth ${TOKEN}` }
});
const userId = (await vRes.json()).user_id;
/* 3) Connect WebSocket */
const ws = new WebSocket('wss://eventsub.wss.twitch.tv/ws');
ws.on('open', () => console.log('🟢 connected'));
ws.on('close', () => console.log('🔴 closed'));
ws.on('message', async raw => {
const msg = JSON.parse(raw.toString());
const type = msg.metadata?.message_type;
if (type === 'session_welcome') {
// 4) Subscribe immediately
const sessionId = msg.payload.session.id;
await fetch('https://api.twitch.tv/helix/eventsub/subscriptions', {
method : 'POST',
headers: {
Authorization : `Bearer ${TOKEN}`,
'Client-Id' : CLIENT_ID,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type : 'channel.chat.message',
version : '1',
condition : { broadcaster_user_id: broadcasterId, user_id: userId },
transport: { method: 'websocket', session_id: sessionId }
})
});
return;
}
if (type === 'notification'
&& msg.payload.subscription.type === 'channel.chat.message') {
const txt = msg.payload.event.message.text;
const usr = msg.payload.event.chatter_user_name;
console.log(`[TW] ${usr}: ${txt}`);
}
if (type === 'session_reconnect') {
// 5) Switch to the new URL
ws.removeAllListeners(); // drop old listeners
const newWs = new WebSocket(msg.payload.session.reconnect_url);
newWs.on('message', ws.listeners('message')[0]!);
}
});
})();
Run it
export TWITCH_TOKEN=oauth_xxxxxxxxxxxxxxxxxxxx
export TWITCH_CLIENT_ID=yyyyyyyyyyyyyyyyyyyyy
export TWITCH_CHANNEL=mychannel
node twitch-chat.ts
Wrap-up
-
YouTube: two API calls (
videos
➜liveChat/messages
) + periodic polling. -
Twitch: a single EventSub WebSocket—subscribe to
channel.chat.message
and receive pushes. - Both are under 100 lines of script for real-time chat capture.
From here you can add buffering, banned-word filters, AI summarization, and more to build your own custom AITuber workflow.
Hope this helps—happy streaming!
¹ LiveChatMessages: list | YouTube Live Streaming API
² LiveChatMessages | YouTube Live Streaming API
³ EventSub – Twitch Developers
⁴ Handling WebSocket Events – Twitch Developers
⁵ Registering Your App
⁶ Getting OAuth Access Tokens
Top comments (0)