TikTok LIVE events, over one WebSocket
One socket. 15 event types. Plain JSON.
Connect one socket and receive 15 normalized event types from your TikTok LIVE room: gifts, comments, likes, follows, subs and more, with the viewer's identity and role flags already attached. Read-only: the API streams events out and never writes back to TikTok.
wss://api.bettertok.app/api/v1/developer/streamDeveloper API v1 Free local mode on desktop Hosted stream on Creator, $9.99 a month

What this is
A read-only WebSocket tap on your own TikTok LIVE room. BetterTok already connects to TikTok, decodes the stream and cleans up the messy parts — inconsistent field names, partial gift combos, missing role flags. The Developer API hands you the result so you can build a bot, a game, an overlay or an analytics pipeline without touching TikTok's protocol yourself. One note on gifts: a combo arrives once, when it ends; a combo that stops without an end frame arrives after ~15 s idle, and if the end frame shows up later it adds only the remainder (nothing at all when the combo did not grow) — sum gift_count per viewer and gift.
- One socket, 15 event types. No polling, no webhooks to host.
- Stable JSON. Every event uses the same envelope and snake_case field names.
- Roles included. Follower, subscriber, moderator, fan club and gifter rank arrive on the event — no second lookup.
- Free locally. The desktop app serves the same events on
ws://127.0.0.1:21213with no key — see local mode.
Quick start
- 1
Get a key
Open the Developer API page in your dashboard and create one. Keys look like
btk_live_…and are shown once — store it like a password. The hosted stream needs a Creator plan and a linked TikTok account. - 2
Open the socket
Connect to
wss://api.bettertok.app/api/v1/developer/streamwith your key in theAuthorizationheader. You'll get aconnectedframe back. - 3
Read events
Go live on TikTok. Every event arrives as one JSON frame. Switch on
typeand readdata.
Authentication
Two forms. Both carry the same key.
# Servers, CLIs, anything that can set headers:
Authorization: Bearer btk_live_<your key>
# Browsers — the WebSocket API can't set headers:
wss://api.bettertok.app/api/v1/developer/stream?access_token=btk_live_<your key>{ "type": "error", "code": "UNAUTHORIZED" } and closes. Handle the error frame, not just the close code.Keys can be rotated or revoked from the dashboard. A revoked key kills its live connections within about 30 seconds.
The event envelope
Every event frame has the same outer shape:
{ v, id, correlation_id, user_id, session_id, type, room_id, received_at, normalized_at, data }| field | type | description |
|---|---|---|
| v | number | Contract version. Always 1 today. |
| id | string | Unique id for this event (evt_<uuid>) — safe to de-duplicate on. |
| correlation_id | string | TikTok's own message id when it sent one, otherwise a generated corr_<uuid>. |
| user_id | string | null | The BetterTok account the stream belongs to. |
| session_id | string | null | The live session — same value as room_id, so it changes each time the creator goes live. |
| type | string | Which event this is; one of the types listed below. |
| room_id | string | null | TikTok's live room id. |
| received_at | string | ISO 8601 — when TikTok stamped the event (falls back to when we got it). |
| normalized_at | string | ISO 8601 — when BetterTok normalized and sent it. |
| data | object | The per-type payload; its fields are listed per event below. |
A real gift event:
{
"v": 1,
"id": "evt_9f1c0f7a-6b1e-4c22-9f0e-2a5b1d3e4f60",
"correlation_id": "7412998877665544332",
"user_id": "usr_01HZX9Q2",
"session_id": "7412998877665544000",
"type": "gift",
"room_id": "7412998877665544000",
"received_at": "2026-07-28T18:04:11.220Z",
"normalized_at": "2026-07-28T18:04:11.244Z",
"data": {
"username": "yourhandle",
"viewer_id": "6829471055",
"viewer_username": "someviewer",
"display_name": "Some Viewer",
"profile_picture": "https://p16-sign.tiktokcdn.com/...",
"is_follower": true,
"is_subscriber": false,
"is_moderator": false,
"is_fan_club_member": true,
"is_gifter": true,
"top_gifter_rank": 3,
"gifter_level": 12,
"fan_club_level": 4,
"subscriber_level": 0,
"follow_role": 1,
"gift_id": 5655,
"gift_name": "Rose",
"gift_count": 10,
"gift_value": 1,
"gift_total_value": 10,
"gift_image_url": "https://p19-webcast.tiktokcdn.com/..."
}
}Events (15)
These are all of them. Types are stable; new fields may be added inside data, so ignore keys you don't recognise. Fields marked ? are absent when TikTok didn't send a value.
Identity fields
Sent inside data on every event that has a viewer behind it. Listed once here rather than repeated below.
| field | type | description |
|---|---|---|
| username | string | The creator's @handle — whose stream this came from. |
| viewer_id? | string | TikTok's user id for the viewer. |
| viewer_username? | string | The viewer's @handle. |
| display_name? | string | The viewer's display name; falls back to their @handle. |
| profile_picture? | string | URL of the viewer's avatar. |
| is_follower | boolean | The viewer follows the creator. |
| is_subscriber | boolean | The viewer is a paid subscriber. |
| is_moderator | boolean | The viewer is a moderator of the room. |
| is_fan_club_member | boolean | The viewer is in the fan club (joining is free — not the same as subscribing). |
| is_gifter | boolean | The viewer has sent a gift. |
| top_gifter_rank | number | null | Place on the room's gifter leaderboard, or null if unranked. |
| gifter_level | number | The viewer's gifter level; 0 when unknown. |
| fan_club_level | number | Fan club level; 0 when not a member. |
| subscriber_level | number | Subscriber level; 0 when not a subscriber. |
| follow_role | number | Follow relationship: 0 = stranger, 1 = follows the creator, 2 = mutual friends. |
comment
A viewer posted a chat message.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| comment | string | The message text; empty string if TikTok sent none. |
| emotes? | object[] | Emotes used inside the message. |
gift
A viewer sent a gift. Only completed gifts — a combo streak is sent once, when it ends. A combo that stops without an end frame is sent after ~15 s idle; if the end frame arrives later it adds only the remainder, and nothing at all when the combo did not grow — sum gift_count across events for the same viewer_id + gift_id.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| gift_id? | number | TikTok's id for the gift. |
| gift_name? | string | The gift's display name, e.g. "Rose". |
| gift_count | number | Gifts in this event (the whole combo, or the remainder after an idle flush). |
| gift_value | number | Diamonds for one of them. |
| gift_total_value | number | Diamonds for this event (gift_value x gift_count). |
| gift_image_url? | string | URL of the gift's icon. |
| gift_image_hash? | string | Hash of the gift's animation asset, for caching. |
like
A viewer tapped like. TikTok batches these, so one event covers several taps.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| like_count | number | Likes in this batch. |
| total_likes? | number | Running total for the whole stream. |
follow
A viewer followed the creator.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| total_followers? | number | The creator's follower count including this one. Our own running total — TikTok sends no count on this event — so it is approximate between periodic resyncs. |
join
A viewer entered the live room.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| current_viewers? | number | Viewers in the room, as of the last count TikTok sent. |
superfan
A viewer subscribed to the creator. is_subscriber is always true here.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| sub_month? | number | Months purchased in this transaction — NOT how long they have been subscribed. Use total_months for tenure. |
| total_months? | number | Total months they have been subscribed. |
| subscribe_type? | number | 1 = new subscription, 2 = renewal. |
emote
A viewer sent a subscriber emote / sticker.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| emote_id? | string | TikTok's id for the emote. |
| emote_image_url? | string | URL of the emote image. |
envelope
A Super Fan Box was dropped in the room. This is ONLY the Super Fan Box — plain treasure chests and Portals arrive as treasure_chest and portal.
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| coins? | number | Coins inside the box. |
| count? | number | How many viewers can claim a share. |
| envelope_id? | string | TikTok's id for the box. |
| business_type | number | TikTok's box category. Always 19 for this event. |
treasure_chest
A viewer dropped a diamond treasure chest (TikTok's generic envelope, business_type 1, 2 or 3).
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| coins? | number | Coins inside the chest. |
| count? | number | How many viewers can claim a share. |
| envelope_id? | string | TikTok's id for the chest. |
| business_type | number | TikTok's box category: 1 or 2 = diamond chest, 3 = shell. |
portal
A Portal was opened in the room (TikTok's envelope business_type 4).
data carries all identity fields, plus:
| field | type | description |
|---|---|---|
| coins? | number | Coins inside the portal. |
| count? | number | How many viewers can claim a share. |
| envelope_id? | string | TikTok's id for the portal. |
| business_type | number | TikTok's box category. Always 4 for this event. |
viewer_count
The room's viewer count changed. No viewer identity on this one.
| field | type | description |
|---|---|---|
| username | string | The creator's @handle. |
| viewer_count | number | Viewers watching right now. |
| top_viewers | object[] | Up to 10 top viewers as TikTok ranks them; empty array when none. Each entry: rank, coin_count, viewer_id, viewer_username, display_name, profile_picture. |
shop_purchase
Someone bought from the creator's shop. TikTok does not tell us who, so there is no viewer identity.
| field | type | description |
|---|---|---|
| username | string | The creator's @handle. |
| product_name? | string | Name of the product. |
| product_image? | string | URL of the product image. |
| price? | string | Price as TikTok formatted it, e.g. "$12.99" — a string, not a number. |
| shop_name? | string | Name of the shop. |
stream_start
The creator went live.
| field | type | description |
|---|---|---|
| username | string | The creator's @handle. |
stream_end
The creator's stream ended.
| field | type | description |
|---|---|---|
| username | string | The creator's @handle. |
Control frames
You → server
Optional. If you never send anything you receive every event type.
// Narrow to the types you want. Unknown types are ignored.
{ "type": "subscribe", "events": ["gift", "comment"] }
// No "events" key = all 15 types.
{ "type": "subscribe" }
// Round-trip check. Replies { "type": "pong" }.
{ "type": "ping" }events: [] — or a list where nothing is a valid type — means “deliver nothing”, not “deliver everything”. Omit the key entirely if you want them all.Server → you
// First frame after a successful auth.
{ "type": "connected", "username": "yourhandle", "events": [ ...all 15 types ] }
// Confirms a subscribe, echoing what you'll actually receive.
{ "type": "subscribed", "events": ["gift", "comment"] }
// Reply to your ping.
{ "type": "pong" }
// Something went wrong; the socket closes right after.
{ "type": "error", "code": "UNAUTHORIZED", "message": "Invalid or missing API key." }
// Anything else is an event envelope — see above.
{ "v": 1, "type": "gift", "data": { ... } }Unknown control frames are ignored in both directions, so new frame types won't break an existing client.
Error codes
Errors arrive in-band as { "type": "error", "code", "message" }, then the socket closes.
| code | close | meaning |
|---|---|---|
| UNAUTHORIZED | 1008 | The key is missing, malformed, revoked, or does not exist. Also sent mid-stream if the key is revoked while you are connected. |
| TIER_REQUIRED | 1008 | The key is valid but its owner is not on a Creator plan. Also sent mid-stream if the plan lapses. |
| NO_USERNAME | 1008 | The key's owner has no TikTok account linked yet. |
| RATE_LIMIT | 1008 / 1013 | Too many control frames per second, too many concurrent connections, or the service is at capacity (1013). |
If you exceed the handshake rate the upgrade is refused with an HTTP 429 before any WebSocket exists — there is no error frame in that case.
Reconnect with backoff on close. Don't retry in a tight loop after UNAUTHORIZED or TIER_REQUIRED: those need a human to fix the key or the plan.
Limits
| Concurrent connections | 10 per account |
| API keys | 20 per account |
| Inbound control frames | 30 per second, then disconnect |
| Max frame size | 16 KB |
| Connection attempts | 600 per minute per IP |
| Server keepalive | WebSocket ping every 30s |
| Key / plan re-check | every 30s — revoking a key cuts live sockets within ~30s |
| Slow consumers | events are dropped once your unread buffer passes 4 MB |
Events themselves are never rate limited — only the frames you send. A busy room streams as fast as TikTok delivers.
Local mode — free, no key
The BetterTok desktop app serves live events from your own machine at ws://127.0.0.1:21213. No API key, no plan, no internet round trip — build and test against it for free, then point at the cloud endpoint when you ship.
The frame shape is deliberately TikFinity-compatible, so tools written for TikFinity's local socket work unchanged. That also means it is not the normalized cloud format documented above:
{ "event": "gift", "data": { ...raw camelCase fields, TikFinity-shaped } }eventuses TikFinity's names, not the cloud type names —chat(notcomment),member(notjoin),subscribe(notsuperfan),roomUser(notviewer_count). There is no local equivalent ofshop_purchaseorstream_start.datais the raw internal payload in camelCase (nickname,giftName,uniqueId) — not the snake_case fields documented above, and with no outer envelope, so nov,idor timestamps.data.usernameis the sender's handle here — in the cloud envelope the same key means the creator's handle. This is the one difference that will not show up asundefined.- Bound to loopback (
127.0.0.1) — nothing on your network can reach it, and there is no firewall prompt. It is on by default and starts with the desktop app; turn it off in the desktop app's settings if you don't want it running. - It is unauthenticated by design, for drop-in TikFinity compatibility. Any software running on the same machine — including a page open in your browser — can connect to it and read your live event feed. Turn Local Mode off if that is not what you want.
- Port
21213is the default, but if it is already taken (TikFinity itself uses it) the app walks up to21222— check the desktop app for the live port before hardcoding it. - Use the cloud endpoint instead when you need de-duplication ids, correlation ids or a server that isn't the streamer's PC.
// Desktop app running, no key needed.
// NOTE: local mode speaks TikFinity's wire format, NOT the cloud envelope —
// raw camelCase fields, and TikFinity's event names (chat/member/subscribe).
const ws = new WebSocket("ws://127.0.0.1:21213");
ws.onmessage = (e) => {
const { event, data } = JSON.parse(e.data);
if (event === "gift") {
console.log(data.nickname, "sent", data.giftName);
}
};Copy-paste samples
Node.js
// npm i ws
import WebSocket from "ws";
const ws = new WebSocket("wss://api.bettertok.app/api/v1/developer/stream", {
headers: { Authorization: `Bearer ${process.env.BETTERTOK_API_KEY}` },
});
ws.on("open", () => {
// Optional. Omit this entirely and you get all 15 types.
ws.send(JSON.stringify({ type: "subscribe", events: ["gift", "comment"] }));
});
ws.on("message", (raw) => {
const evt = JSON.parse(raw.toString());
if (evt.type === "error") return console.error(evt.code, evt.message);
if (evt.type === "connected") return console.log("streaming @" + evt.username);
if (evt.type === "gift") {
const d = evt.data;
console.log(`${d.display_name} sent ${d.gift_count}x ${d.gift_name} — ${d.gift_total_value} diamonds`);
}
if (evt.type === "comment") {
console.log(`${evt.data.display_name}: ${evt.data.comment}`);
}
});Python
# pip install websockets
import asyncio, json, os, websockets
URL = "wss://api.bettertok.app/api/v1/developer/stream"
HEADERS = {"Authorization": f"Bearer {os.environ['BETTERTOK_API_KEY']}"}
async def main():
async with websockets.connect(URL, additional_headers=HEADERS) as ws:
# Optional. Omit this entirely and you get all 15 types.
await ws.send(json.dumps({"type": "subscribe", "events": ["gift", "comment"]}))
async for raw in ws:
evt = json.loads(raw)
if evt["type"] == "error":
print("error:", evt["code"], evt["message"])
break
if evt["type"] == "gift":
d = evt["data"]
print(d["display_name"], "sent", d["gift_count"], "x", d["gift_name"])
elif evt["type"] == "comment":
print(f"{evt['data']['display_name']}: {evt['data']['comment']}")
asyncio.run(main())Browser
// A browser can't set headers on a WebSocket, so the key goes on the URL.
// Only do this in a page you control — anyone who opens devtools sees the key.
const ws = new WebSocket(
"wss://api.bettertok.app/api/v1/developer/stream?access_token=" + encodeURIComponent(API_KEY)
);
ws.onmessage = (e) => {
const evt = JSON.parse(e.data);
console.log(evt.type, evt.data);
};No code? Start here
The API is for people building their own tools. These do the same jobs with nothing to host.
Actions & Events
Want a gift to fire a sound, a voice line or an overlay? Actions & Events does it with no code.
Streamer.bot for TikTok LIVE
Already use Streamer.bot? Let gifts, follows and chat keywords run your actions.
TikTok LIVE overlays
Need something on screen tonight? 300+ ready-made overlays, one link each.
TikTok LIVE chatbot
Want replies to commands in chat? Set them up without running a server.
Developer API: FAQ
Is there a free TikTok Live API?
Yes. BetterTok's desktop app exposes live TikTok events on a local WebSocket at ws://127.0.0.1:21213 for free, with no API key. Local mode uses TikFinity's frame format (raw camelCase fields, TikFinity event names), so TikFinity tools work against it unchanged. The hosted cloud stream (wss://api.bettertok.app/api/v1/developer/stream) delivers the normalized snake_case envelope and needs a Creator plan.
What TikTok LIVE events can I receive?
15 types: comment, gift, like, follow, share, join, superfan, emote, envelope, treasure_chest, portal, viewer_count, shop_purchase, stream_start, stream_end. Every one arrives as JSON with the viewer's identity and role flags attached.
How do I authenticate with the BetterTok Developer API?
Send your key as an Authorization: Bearer btk_live_... header. Browsers cannot set headers on a WebSocket, so they pass ?access_token=btk_live_... on the URL instead.
Can I send chat or trigger actions through the API?
No. The Developer API is read-only. It streams events out; it never writes back to TikTok.
Ready to build?
Create a key in the dashboard, or grab the desktop app and use local mode for free.