Twitch and YouTube live chat in one overlay for your stream.
Live chat from Twitch and YouTube, merged into one stream of JSON events, for programs that want to react to chat: games, bots, sound boards, custom overlays.
ws://127.0.0.1:7878/api/v1/ws (WebSocket, port
configurable)api = true in [server], or
--api). Even when it’s on, a web site only gets in once the streamer
allows it (see Security).Try it without writing code (the dev shell includes websocat):
cargo run -p chat-engine --example run -- --demo --api
websocat ws://127.0.0.1:7878/api/v1/ws
| query | effect |
|---|---|
| (none) | live events only, from the moment you connect |
?history=true |
first the most recent messages (default: up to 20), then live |
History is off by default on purpose: a game that reacts to !jump must not
replay old commands every time it reconnects. Use ?history=true if you
display chat.
The server pings idle connections every 30 seconds (WebSocket libraries
answer automatically) and sends a Close frame with code 1001 when it shuts
down or the API is switched off (the reason says which). While it’s
switched off, connecting fails with HTTP 403 Forbidden. Reconnect when the
connection drops: nothing is lost while you’re connected, but events that
happen while you’re disconnected are not queued.
Every event is a JSON object with a type field.
messageA chat message or a platform event (donation, sub, raid, …).
{
"type": "message",
"id": "LCC.ExampleMessageId0001",
"platform": "youtube",
"author": {
"id": "UCxxxxxxxxxxxxxxxxxxxxxx",
"name": "Viewer42",
"color": null,
"badges": ["member"],
"avatar_url": "https://yt3.ggpht.com/…"
},
"text": ":_hypeWave:",
"emotes": [{ "code": ":_hypeWave:", "url": "https://yt3.ggpht.com/…" }],
"timestamp": "2026-09-25T18:40:44.516Z",
"kind": { "type": "emote_only" }
}
| field | meaning |
|---|---|
id |
platform message id; delete events refer to it |
platform |
"twitch", "youtube" (later "rplay") |
author |
id (platform user id), name, color (#rrggbb, Twitch only, else null), badges (e.g. moderator, subscriber, member, vip), avatar_url (YouTube only, else null) |
text |
what the user typed; may be empty (e.g. a raid). Event descriptions are in kind |
emotes |
emotes used in text, each listed once; in text they appear exactly as code |
timestamp |
when it was sent, RFC 3339 in UTC |
kind |
what kind of message, see below |
kind.type and its extra fields:
kind.type |
fields | examples |
|---|---|---|
text |
normal chat | |
emote_only |
only emotes (and spaces) | |
donation |
amount, tier |
Twitch bits, YouTube Super Chat: "100 bits", "€5.00" |
special |
image_url, amount, info, tier (each may be null) |
YouTube Super Sticker (info = sticker description), YouTube gift |
membership |
info, months |
new sub/member, resub, milestone; months = how long they’ve been a member (resub, milestone), null for a new one |
gift |
count |
gifted subs (Twitch) or memberships (YouTube) |
notice |
info |
raid, announcement, watch streak, new chatter, … (Twitch events without a kind of their own) |
amount is a display string as the platform formats it (currency and all);
it’s meant for showing, not for arithmetic.
tier says how big the amount is on the platform’s own scale, from 1
(the smallest) up, or null where the platform has none (YouTube jewel
gifts). Compare tiers only within one platform:
| platform | tiers |
|---|---|
youtube |
YouTube’s Super Chat/Sticker tier, as YouTube reports it, whatever the currency: 1 blue, 2 cyan, 3 teal, 4 yellow, 5 orange, 6 magenta, 7 red (its colour in YouTube’s chat) |
twitch |
the cheer tier of the bits: 1 (1–99), 2 (100–999), 3 (1,000–4,999), 4 (5,000–9,999), 5 (10,000 and more) |
delete, clear_user, clear_allModeration: remove messages you already showed.
{ "type": "delete", "platform": "twitch", "message_id": "7c4e9a61-…" }
{ "type": "clear_user", "platform": "youtube", "user_id": "UCxxxxxxxx" }
{ "type": "clear_all", "platform": "twitch" }
delete: one message, matched by platform + id.clear_user: a user was banned or timed out; remove every message whose
platform + author.id match.clear_all: remove every message of that platform.Always match on the platform too: ids of different platforms are unrelated.
lagged{ "type": "lagged", "missed": 12 }
Your client read too slowly and fell behind the server’s buffer (256
events); missed events were dropped for you. Live events continue right
after it. Only happens if your program blocks while handling events.
types and fields you don’t know. New event types, kind types
and fields may be added within v1./api/v2/....The server only listens on 127.0.0.1, so other machines can’t connect.
On the same machine, the API accepts:
Origin header);python -m http.server (http://localhost:<any port>);https://game.example.Browsers let any page open a WebSocket to localhost, but they always send
the page’s Origin, and pages can’t fake it. Without a check, any website
open in the streamer’s browser could read their chat, including
members-only chat, and learn from it which channel is theirs. So when a web
site connects for the first time, the app asks the streamer:
Let https://game.example read your chat? [Allow] [Deny]
api_origins in config.toml); the site can
be removed again under ⚙ Settings → Connection, which also disconnects
it.403 with a message saying why.api_origins
get in.A page opened straight from disk in a desktop browser is refused without
asking: browsers send Origin: null for it, and so can any website (from a
sandboxed frame), so the question couldn’t say who is asking. Open it
through OBS, or serve it from localhost.
The overlay and the API also refuse any request whose Host isn’t
127.0.0.1, localhost or [::1]. That stops “DNS rebinding”, where a
site points its own domain at 127.0.0.1 to read the server’s answers.
Chat is untrusted input. Every text field (text, author.name,
amounts, descriptions, emote codes) is exactly what someone typed or sent,
unescaped. If you show it in a web page or an OBS browser source, insert it
as text (element.textContent = …), never as HTML (innerHTML,
insertAdjacentHTML, template strings put into the page). An overlay that
did exactly that let chatters run code on streamers’ PCs through OBS
(CVE-2024-7971):
OBS runs its browser sources without Chromium’s sandbox. The same goes for
URLs (url, avatar_url): use them as image sources only.
function connect() {
const ws = new WebSocket("ws://127.0.0.1:7878/api/v1/ws");
ws.onmessage = (frame) => {
const event = JSON.parse(frame.data);
switch (event.type) {
case "message":
console.log(`[${event.platform}] ${event.author.name}: ${event.text}`);
if (event.kind.type === "donation") console.log(` donated ${event.kind.amount}!`);
break;
case "delete":
console.log(`remove message ${event.message_id}`);
break;
case "lagged":
console.warn(`missed ${event.missed} events`);
break;
// other types: ignore
}
};
ws.onclose = () => setTimeout(connect, 2000); // reconnect
}
connect();
pip install websockets)import asyncio, json
import websockets
async def main():
# Iterating over connect() reconnects automatically when the connection drops.
async for ws in websockets.connect("ws://127.0.0.1:7878/api/v1/ws"):
try:
async for frame in ws:
event = json.loads(frame)
if event["type"] == "message" and event["text"].startswith("!jump"):
print(f"{event['author']['name']} wants to jump!")
except websockets.ConnectionClosed:
continue
asyncio.run(main())