Developer API · v1
TikTok LIVE events, over one WebSocket
Connect one socket and receive 15normalized event types from your TikTok LIVE room — gifts, comments, likes, follows, subs and more — as plain JSON, 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/streamWhat 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 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.
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 | How many were sent in this combo. |
| gift_value | number | Diamonds for one of them. |
| gift_total_value | number | Diamonds for the whole combo (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'shandle 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 defaultand 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);
};Ready to build?
Create a key in the dashboard, or grab the desktop app and use local mode for free.