@blorkfield/twitch-integration
v0.6.1
Published
Twitch EventSub WebSocket client with normalized chat message stream
Readme
@blorkfield/twitch-integration
Manages a Twitch EventSub WebSocket connection and exposes a typed event stream for all major channel and stream events: chat messages, follows, subscriptions, raids, cheers, channel points, hype trains, polls, predictions, shoutouts, stream status, ad breaks, and channel updates.
Installation
pnpm add @blorkfield/twitch-integration
# ws is required in Node.js environments
pnpm add wsPrerequisites: credentials
You need four things before constructing TwitchClient:
| Option | What it is | How to get it |
|---|---|---|
| clientId | Your Twitch app's client ID | Twitch Developer Console → your app |
| accessToken | A user access token for the account | OAuth flow — not an app access token |
| userId | Twitch numeric user ID of the account that owns the token | Call GET /helix/users with the token |
| channelId | Twitch numeric user ID of the broadcaster whose channel you're monitoring | Call GET /helix/users?login=channelname |
The library does not handle OAuth. Obtain the token yourself and pass it in. Use onTokenRefresh to persist refreshed tokens.
Why a user token? Twitch's EventSub WebSocket transport does not accept app access tokens — this is a hard Twitch protocol requirement.
Getting an access token
No tools needed.
Step 1 — Add the redirect URL to your Twitch app
This must be done before the flow will work. Go to dev.twitch.tv/console/apps, open your app, and add exactly this to the OAuth Redirect URLs list:
https://localhostSave the app. If this value isn't there, Twitch will reject the authorization and you'll get a redirect_uri mismatch error.
Step 2 — Open the authorization URL
Paste this into your browser after substituting your clientId:
https://id.twitch.tv/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=https://localhost&response_type=token&scope=user:read:chat+moderator:read:followers+channel:read:subscriptions+bits:read+channel:read:hype_train+channel:read:polls+channel:read:predictions+channel:read:redemptions+channel:read:ads+moderator:read:shoutoutsStep 3 — Grab the token
- Authorize in the browser
- You get redirected to
https://localhost(which fails to load — that's expected) - Your token is in the URL bar:
https://localhost/#access_token=YOUR_TOKEN&... - Copy everything after
access_token=up to the first&
That's your accessToken. You only need scopes for the subscription flags you actually enable — remove any you don't need from the scope list before opening the URL.
OAuth scopes
Each subscription type requires the corresponding scope(s) on your access token. Only enable subscriptions for scopes your token actually has — missing scopes cause individual subscription failures which are emitted as error events.
| subscriptions flag | EventSub types registered | Required scope |
|---|---|---|
| chat | channel.chat.message | user:read:chat |
| follow | channel.follow | moderator:read:followers |
| subscribe | channel.subscribe, channel.subscription.message, channel.subscription.gift, channel.subscription.end | channel:read:subscriptions |
| cheer | channel.cheer | bits:read |
| raid | channel.raid | (none) |
| streamStatus | stream.online, stream.offline | (none) |
| channelUpdate | channel.update | (none) |
| hypeTrain | channel.hype_train.begin, channel.hype_train.progress, channel.hype_train.end | channel:read:hype_train |
| polls | channel.poll.begin, channel.poll.progress, channel.poll.end | channel:read:polls |
| predictions | channel.prediction.begin, channel.prediction.progress, channel.prediction.lock, channel.prediction.end | channel:read:predictions |
| channelPoints | channel.channel_points_custom_reward_redemption.add | channel:read:redemptions |
| adBreak | channel.ad_break.begin | channel:read:ads |
| shoutouts | channel.shoutout.create, channel.shoutout.receive | moderator:read:shoutouts |
APIs called at runtime
On connect()
- Opens a WebSocket to
wss://eventsub.wss.twitch.tv/ws - Receives
session_welcomefrom Twitch containing asession_id POST https://api.twitch.tv/helix/eventsub/subscriptions— one call per enabled subscription, registered in parallel using yourclientId+accessToken
On preloadEmotes() / refreshEmotes()
Four parallel fetches, all unauthenticated:
| Source | Endpoint |
|---|---|
| BTTV global | GET https://api.betterttv.net/3/cached/emotes/global |
| BTTV channel | GET https://api.betterttv.net/3/cached/users/twitch/{channelId} |
| 7TV global | GET https://7tv.io/v3/emote-sets/global |
| 7TV channel | GET https://7tv.io/v3/users/twitch/{channelId} |
On preloadBadges()
Two parallel calls using your credentials:
GET https://api.twitch.tv/helix/chat/badges/globalGET https://api.twitch.tv/helix/chat/badges?broadcaster_id={channelId}
On user lookup
GET https://api.twitch.tv/helix/users(batched, cached for 5 minutes)
Usage
import { TwitchClient } from '@blorkfield/twitch-integration'
const client = new TwitchClient({
channelId: '123456789',
userId: '987654321',
clientId: 'abc123...',
accessToken: 'oauth:...',
subscriptions: {
chat: true,
follow: true,
subscribe: true,
cheer: true,
raid: true,
streamStatus: true,
channelUpdate: true,
hypeTrain: true,
polls: true,
predictions: true,
channelPoints: true,
adBreak: true,
shoutouts: true,
},
onTokenRefresh: (token) => saveTokenSomewhere(token),
})
await client.preloadEmotes() // optional: fetch BTTV + 7TV emote maps
await client.connect() // resolves once connected; subscriptions registered in parallel
// --- Chat ---
client.on('message', (msg) => {
console.log(msg.user.displayName, msg.text)
console.log(msg.emotes) // all resolved emotes in this message
console.log(msg.fragments) // text/emote/cheermote/mention breakdown
})
// --- Follows ---
client.on('follow', async (e) => {
console.log(`${e.user.displayName} followed!`)
// fetch profile picture on demand:
const pfp = await client.getProfilePictureUrl(e.user.id)
})
// --- Subscriptions ---
client.on('subscribe', (e) => {
console.log(`${e.user.displayName} subscribed at tier ${e.tier} (gift: ${e.isGift})`)
})
client.on('subscriptionMessage', (e) => {
// resub with a shared message
console.log(`${e.user.displayName} resubbed for ${e.cumulativeMonths} months: ${e.message.text}`)
})
client.on('subscriptionGift', (e) => {
const who = e.isAnonymous ? 'anonymous' : e.gifter!.displayName
console.log(`${who} gifted ${e.total} tier ${e.tier} subs`)
})
client.on('subscriptionEnd', (e) => {
console.log(`${e.user.displayName}'s sub ended`)
})
// --- Cheers ---
client.on('cheer', (e) => {
const who = e.isAnonymous ? 'anonymous' : e.user!.displayName
console.log(`${who} cheered ${e.bits} bits: ${e.message}`)
})
// --- Raids ---
client.on('raid', (e) => {
console.log(`${e.fromBroadcaster.displayName} raided with ${e.viewerCount} viewers`)
})
// --- Stream status ---
client.on('streamOnline', (e) => {
console.log(`Stream went live at ${e.startedAt}`)
})
client.on('streamOffline', () => {
console.log('Stream ended')
})
// --- Channel update ---
client.on('channelUpdate', (e) => {
console.log(`Title: ${e.title} | Category: ${e.categoryName}`)
})
// --- Hype Train ---
client.on('hypeTrain.begin', (e) => {
console.log(`Hype Train started! Level ${e.level} — ${e.total} / ${e.goal}`)
})
client.on('hypeTrain.progress', (e) => {
console.log(`Hype Train level ${e.level} — ${e.progress} / ${e.goal}`)
})
client.on('hypeTrain.end', (e) => {
console.log(`Hype Train ended at level ${e.level} with ${e.total} total`)
})
// --- Polls ---
client.on('poll.begin', (e) => {
console.log(`Poll: ${e.title}`, e.choices.map(c => c.title))
})
client.on('poll.progress', (e) => {
console.log(e.choices.map(c => `${c.title}: ${c.votes}`))
})
client.on('poll.end', (e) => {
const winner = [...e.choices].sort((a, b) => b.votes - a.votes)[0]
console.log(`Poll ended — winner: ${winner.title} with ${winner.votes} votes`)
})
// --- Predictions ---
client.on('prediction.begin', (e) => {
console.log(`Prediction: ${e.title}`, e.outcomes.map(o => o.title))
})
client.on('prediction.progress', (e) => {
console.log(e.outcomes.map(o => `${o.title}: ${o.channelPoints} pts`))
})
client.on('prediction.lock', (e) => {
console.log(`Prediction locked: ${e.title}`)
})
client.on('prediction.end', (e) => {
const winner = e.outcomes.find(o => o.id === e.winningOutcomeId)
console.log(`Prediction resolved — winner: ${winner?.title}`)
})
// --- Channel Points ---
client.on('channelPoints', (e) => {
console.log(`${e.user.displayName} redeemed "${e.reward.title}"`, e.userInput ?? '')
})
// --- Ad Breaks ---
client.on('adBreak', (e) => {
console.log(`Ad break started — ${e.durationSeconds}s (auto: ${e.isAutomatic})`)
})
// --- Shoutouts ---
client.on('shoutout.create', (e) => {
console.log(`Gave shoutout to ${e.toBroadcaster.displayName}`)
})
client.on('shoutout.receive', (e) => {
console.log(`Received shoutout from ${e.fromBroadcaster.displayName}`)
})
// --- Connection events ---
client.on('subscription_error', (type, err) => {
// an individual subscription failed — check your token has the required scope
console.error(`Failed to subscribe to ${type}:`, err.message)
})
client.on('auth_error', () => {
// token is invalid or expired — re-authenticate
})
client.on('revoked', (reason) => {
// Twitch revoked a subscription — do not auto-reconnect
console.error('Subscription revoked:', reason)
})
client.on('disconnected', (code, reason) => {
// library auto-reconnects on unexpected disconnects (non-1000 codes)
})
// Later:
client.disconnect()Simulation mode
TwitchSimulator lets you drive the full TwitchClient pipeline without credentials — no Twitch account, no WebSocket connection, no Helix API calls. Useful for UI development, automated tests, demos, and CI.
The simulator works by passing mock WebSocket and fetch implementations directly into TwitchClient via its transport option. No globals are patched — your app's real fetch and WebSocket are untouched, so other libraries in the same page or process are completely unaffected.
pnpm add @blorkfield/twitch-integrationimport { TwitchSimulator } from '@blorkfield/twitch-integration/simulation'
const simulator = new TwitchSimulator()
const client = simulator.client
client.on('message', (msg) => console.log(msg.user.displayName, msg.text))
client.on('follow', (e) => console.log(`${e.user.displayName} followed!`))
client.on('raid', (e) => console.log(`Raid: ${e.viewerCount} viewers`))
await simulator.connect()
// Fire individual events
simulator.fireChat('hello chat!')
simulator.fireFollow()
simulator.fireSubscribe()
simulator.fireResub(12, 'Been here a year!')
simulator.fireGiftSub(5)
simulator.fireCheer(500)
simulator.fireRaid(250)
// Fire with a specific user
import { USER_POOL } from '@blorkfield/twitch-integration/simulation'
simulator.fireChat('hi!', USER_POOL[0])
// Generic fire — useful when action type is dynamic
simulator.fire('cheer', undefined, { bits: 100, message: 'Cheer100' })Scenario runner
Fires events automatically over time:
simulator.run({
duration: 30, // seconds
rate: 3, // events per second
actions: ['chat', 'follow', 'subscribe'],
users: 'random', // or pass SimUser[] to restrict to specific users
onFire: (user, action) => console.log(action, user.displayName),
onComplete: () => console.log('done'),
})
// Stop early
simulator.stop()
console.log(simulator.running) // falseCustom users
import { TwitchSimulator } from '@blorkfield/twitch-integration/simulation'
import type { SimUser } from '@blorkfield/twitch-integration/simulation'
const users: SimUser[] = [
{ id: '100', login: 'alice', displayName: 'Alice', color: '#FF4444' },
{ id: '101', login: 'bob', displayName: 'Bob', color: '#4488FF' },
]
const simulator = new TwitchSimulator({ users })SimulationOptions
| Option | Type | Default | Description |
|---|---|---|---|
| channelId | string | 'mock_channel' | Simulated broadcaster ID |
| channelLogin | string | 'mock_streamer' | Simulated broadcaster login |
| channelName | string | 'MockStreamer' | Simulated broadcaster display name |
| users | SimUser[] | 8 built-in users | User pool for random selection |
ScenarioOptions
| Option | Type | Default | Description |
|---|---|---|---|
| duration | number | — | Run length in seconds |
| rate | number | — | Events per second |
| actions | ActionType[] | all actions | Which event types to fire |
| users | 'random' \| SimUser[] | 'random' | User source for each tick |
| onFire | (user, action) => void | — | Called each time an event fires |
| onComplete | () => void | — | Called when the scenario ends |
Available ActionType values
'chat' 'follow' 'subscribe' 'resub' 'giftsub' 'cheer' 'raid'
Event reference
Connection events
| Event | Arguments | When |
|---|---|---|
| connected | — | All subscription POSTs attempted; WebSocket ready |
| disconnected | code: number, reason: string | WebSocket closed |
| revoked | reason: string | Twitch revoked a subscription |
| auth_error | — | 401 response on any subscription POST |
| subscription_error | type: string, err: Error | Individual subscription POST failed; type is the Twitch EventSub type string (e.g. 'channel.follow') |
| error | err: Error | Parse errors and other unexpected errors |
Channel events
| Event | Arguments | When |
|---|---|---|
| message | msg: NormalizedMessage | Chat message received |
| follow | event: FollowEvent | Someone followed the channel |
| subscribe | event: SubscribeEvent | New subscription (not a resub) |
| subscriptionMessage | event: SubscriptionMessageEvent | Resub with a shared message |
| subscriptionGift | event: SubscriptionGiftEvent | Gifted subscription(s) |
| subscriptionEnd | event: SubscriptionEndEvent | Subscription ended |
| cheer | event: CheerEvent | Bits cheered |
| raid | event: RaidEvent | Incoming raid |
| streamOnline | event: StreamOnlineEvent | Stream went live |
| streamOffline | event: StreamOfflineEvent | Stream went offline |
| channelUpdate | event: ChannelUpdateEvent | Title, category, or language changed |
| hypeTrain.begin | event: HypeTrainBeginEvent | Hype Train started |
| hypeTrain.progress | event: HypeTrainProgressEvent | Hype Train level progress |
| hypeTrain.end | event: HypeTrainEndEvent | Hype Train ended |
| poll.begin | event: PollBeginEvent | Poll created |
| poll.progress | event: PollProgressEvent | Poll votes updated |
| poll.end | event: PollEndEvent | Poll ended |
| prediction.begin | event: PredictionBeginEvent | Prediction created |
| prediction.progress | event: PredictionProgressEvent | Prediction bets updated |
| prediction.lock | event: PredictionLockEvent | Prediction locked (no more bets) |
| prediction.end | event: PredictionEndEvent | Prediction resolved or cancelled |
| channelPoints | event: ChannelPointsEvent | Channel point reward redeemed |
| adBreak | event: AdBreakEvent | Ad break started |
| shoutout.create | event: ShoutoutCreateEvent | Shoutout sent to another channel |
| shoutout.receive | event: ShoutoutReceiveEvent | Received a shoutout from another channel |
Event payload shapes
All event payloads include minimal user references (EventUser) with id, login, and displayName. Use client.getUser(id) or client.getProfilePictureUrl(id) to fetch the full user info including profile picture.
interface EventUser {
id: string
login: string
displayName: string
}Follow
interface FollowEvent {
user: EventUser
followedAt: string // ISO 8601
}Subscribe
interface SubscribeEvent {
user: EventUser
tier: '1000' | '2000' | '3000'
isGift: boolean
}Subscription message (resub)
interface SubscriptionMessageEvent {
user: EventUser
tier: '1000' | '2000' | '3000'
cumulativeMonths: number
streakMonths: number | null // null if user chose not to share streak
durationMonths: number
message: {
text: string
emotes: Array<{ begin: number; end: number; id: string }>
}
}Subscription gift
interface SubscriptionGiftEvent {
gifter: EventUser | null // null if anonymous
isAnonymous: boolean
tier: '1000' | '2000' | '3000'
total: number // subs gifted in this batch
cumulativeTotal: number | null
}Subscription end
interface SubscriptionEndEvent {
user: EventUser
tier: '1000' | '2000' | '3000'
isGift: boolean
}Cheer
interface CheerEvent {
user: EventUser | null // null if anonymous
isAnonymous: boolean
bits: number
message: string
}Raid
interface RaidEvent {
fromBroadcaster: EventUser
viewerCount: number
}Stream online
interface StreamOnlineEvent {
id: string
type: string // 'live'
startedAt: string
}Stream offline
interface StreamOfflineEvent {}Channel update
interface ChannelUpdateEvent {
title: string
language: string
categoryId: string
categoryName: string
contentClassificationLabels: string[]
}Hype Train
interface HypeTrainContribution {
user: EventUser
type: 'bits' | 'subscription'
total: number
}
interface HypeTrainBeginEvent {
id: string
total: number
progress: number
goal: number
topContributions: HypeTrainContribution[]
lastContribution: HypeTrainContribution
level: number
startedAt: string
expiresAt: string
}
interface HypeTrainProgressEvent {
id: string
level: number
total: number
progress: number
goal: number
topContributions: HypeTrainContribution[]
lastContribution: HypeTrainContribution
startedAt: string
expiresAt: string
}
interface HypeTrainEndEvent {
id: string
level: number
total: number
topContributions: HypeTrainContribution[]
endedAt: string
cooldownEndsAt: string
}Poll
interface PollChoice {
id: string
title: string
bitsVotes: number
channelPointsVotes: number
votes: number
}
interface PollBeginEvent {
id: string
title: string
choices: PollChoice[]
bitsVoting: { isEnabled: boolean; amountPerVote: number }
channelPointsVoting: { isEnabled: boolean; amountPerVote: number }
startedAt: string
endsAt: string
}
// PollProgressEvent has the same shape as PollBeginEvent (votes update in place)
interface PollEndEvent {
id: string
title: string
choices: PollChoice[]
status: 'completed' | 'archived' | 'terminated'
startedAt: string
endedAt: string
}Prediction
interface PredictionPredictor {
user: EventUser
channelPointsWon: number | null
channelPointsUsed: number
}
interface PredictionOutcome {
id: string
title: string
color: 'blue' | 'pink'
users: number
channelPoints: number
topPredictors: PredictionPredictor[]
}
interface PredictionBeginEvent {
id: string
title: string
outcomes: PredictionOutcome[]
startedAt: string
locksAt: string
}
// PredictionProgressEvent has the same shape as PredictionBeginEvent
interface PredictionLockEvent {
id: string
title: string
outcomes: PredictionOutcome[]
startedAt: string
lockedAt: string
}
interface PredictionEndEvent {
id: string
title: string
outcomes: PredictionOutcome[]
winningOutcomeId: string | null
status: 'resolved' | 'canceled'
startedAt: string
endedAt: string
}Channel Points redemption
interface ChannelPointsEvent {
id: string
user: EventUser
reward: {
id: string
title: string
cost: number
prompt: string
}
userInput: string | null
status: 'unfulfilled' | 'fulfilled' | 'canceled'
redeemedAt: string
}Ad break
interface AdBreakEvent {
durationSeconds: number
startedAt: string
isAutomatic: boolean
}Shoutout
interface ShoutoutCreateEvent {
toBroadcaster: EventUser
viewerCount: number
startedAt: string
}
interface ShoutoutReceiveEvent {
fromBroadcaster: EventUser
viewerCount: number
startedAt: string
}NormalizedMessage shape
interface NormalizedMessage {
id: string
text: string // full raw message text
user: ChatUser
fragments: MessageFragment[] // per-token breakdown
emotes: ResolvedEmote[] // deduplicated list of all emotes in message
timestamp: string // RFC3339
cheer?: { bits: number }
reply?: {
parentMessageId: string
parentUserLogin: string
parentUserDisplayName: string
}
channelPointsRewardId?: string
}
type MessageFragment =
| { type: 'text'; text: string }
| { type: 'emote'; text: string; emote: ResolvedEmote }
| { type: 'cheermote'; text: string; bits: number; tier: number }
| { type: 'mention'; text: string; userId: string; userLogin: string }
interface ResolvedEmote {
id: string
name: string
source: 'twitch' | 'bttv' | '7tv'
animated: boolean
imageUrl1x: string
imageUrl2x?: string
imageUrl3x?: string
}Emote resolution
Third-party emote name collisions are resolved in priority order:
- 7TV channel
- BTTV channel
- 7TV global
- BTTV global
- Twitch (authoritative for native emotes — resolved from fragment data, not name lookup)
User lookup
TwitchClient exposes a cached user lookup backed by the Helix API. Use this to fetch profile pictures for users in events.
// Full user info
const user: UserInfo | null = await client.getUser('123456789')
// { id, login, displayName, profileImageUrl, broadcasterType, description, createdAt }
// Batch lookup
const users: Map<string, UserInfo | null> = await client.getUsers(['123456789', '987654321'])
// Profile picture shorthand (same cache, same TTL)
const url: string | null = await client.getProfilePictureUrl('123456789')
const urls: Map<string, string | null> = await client.getProfilePictureUrls(['123456789', '987654321'])Results are cached for 5 minutes. broadcasterType is 'partner', 'affiliate', or ''.
Example: profile picture in a follow event
client.on('follow', async (e) => {
const pfp = await client.getProfilePictureUrl(e.user.id)
showFollowAlert({ name: e.user.displayName, avatar: pfp })
})Badges
Preloading
await client.preloadBadges()Fetches global and channel badge sets from Helix in two parallel calls.
Auto-resolution on messages
If badges are preloaded, each Badge in msg.user.badges will have a resolved field populated automatically:
client.on('message', (msg) => {
for (const badge of msg.user.badges) {
console.log(badge.setId, badge.id) // e.g. 'subscriber', '6'
console.log(badge.resolved?.title) // e.g. 'Subscriber'
console.log(badge.resolved?.imageUrl2x) // CDN image URL
}
})Manual resolution
const badge: ResolvedBadge | undefined = client.resolveBadge('subscriber', '6')
// { title, imageUrl1x, imageUrl2x, imageUrl4x }Lifecycle notes
- Reconnect: handled automatically on unexpected disconnects with a 2s backoff
session_reconnect: library connects to the new URL, waits forsession_welcome, then closes the old connection — subscriptions carry over automatically, no re-POST- Keepalive: Twitch sends keepalives; if one doesn't arrive within
keepalive_timeout_seconds + 0.5s, the library reconnects - Subscription failures: individual subscription POST failures emit
errorevents but do not disconnect or preventconnectedfrom firing — the connection stays open for whichever subscriptions succeeded - Max 3 active WebSocket connections per Twitch user account —
disconnect()cleanly closes before reconnecting elsewhere
Development
Two browser testbeds are included.
Connect testbed (default)
Connects to a real Twitch channel using your own credentials.
pnpm dev # build + open connect testbed on http://localhost:5175
pnpm dev connect # sameSimulation testbed
No credentials needed. Uses the package's own TwitchSimulator API to drive the full TwitchClient pipeline with fake events.
pnpm dev simulation # build + open simulation testbed on http://localhost:5176Two panels (Controls + Chat Simulation):
- Controls — select users, configure a timed run (duration, rate, action types, random or specific users), or manually fire individual events
- Chat Simulation — scrolling chat log showing the normalized events as they flow through the client
Docker
The Docker image serves the connect testbed on port 5175.
docker compose up --build
# → http://localhost:5175Build
pnpm build # tsup → dist/ (ESM + CJS + .d.ts)
pnpm typecheck # tsc --noEmit