Skip to main content

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/stream

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 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:21213 with no key — see local mode.

Quick start

  1. 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. 2

    Open the socket

    Connect to wss://api.bettertok.app/api/v1/developer/stream with your key in the Authorization header. You'll get a connected frame back.

  3. 3

    Read events

    Go live on TikTok. Every event arrives as one JSON frame. Switch on type and read data.

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>
The key is checked after the WebSocket handshake, so a bad key gives you an open socket that immediately receives { "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 }
fieldtypedescription
vnumberContract version. Always 1 today.
idstringUnique id for this event (evt_<uuid>) — safe to de-duplicate on.
correlation_idstringTikTok's own message id when it sent one, otherwise a generated corr_<uuid>.
user_idstring | nullThe BetterTok account the stream belongs to.
session_idstring | nullThe live session — same value as room_id, so it changes each time the creator goes live.
typestringWhich event this is; one of the types listed below.
room_idstring | nullTikTok's live room id.
received_atstringISO 8601 — when TikTok stamped the event (falls back to when we got it).
normalized_atstringISO 8601 — when BetterTok normalized and sent it.
dataobjectThe 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.

fieldtypedescription
usernamestringThe creator's @handle — whose stream this came from.
viewer_id?stringTikTok's user id for the viewer.
viewer_username?stringThe viewer's @handle.
display_name?stringThe viewer's display name; falls back to their @handle.
profile_picture?stringURL of the viewer's avatar.
is_followerbooleanThe viewer follows the creator.
is_subscriberbooleanThe viewer is a paid subscriber.
is_moderatorbooleanThe viewer is a moderator of the room.
is_fan_club_memberbooleanThe viewer is in the fan club (joining is free — not the same as subscribing).
is_gifterbooleanThe viewer has sent a gift.
top_gifter_ranknumber | nullPlace on the room's gifter leaderboard, or null if unranked.
gifter_levelnumberThe viewer's gifter level; 0 when unknown.
fan_club_levelnumberFan club level; 0 when not a member.
subscriber_levelnumberSubscriber level; 0 when not a subscriber.
follow_rolenumberFollow relationship: 0 = stranger, 1 = follows the creator, 2 = mutual friends.

comment

A viewer posted a chat message.

data carries all identity fields, plus:

fieldtypedescription
commentstringThe 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:

fieldtypedescription
gift_id?numberTikTok's id for the gift.
gift_name?stringThe gift's display name, e.g. "Rose".
gift_countnumberHow many were sent in this combo.
gift_valuenumberDiamonds for one of them.
gift_total_valuenumberDiamonds for the whole combo (gift_value x gift_count).
gift_image_url?stringURL of the gift's icon.
gift_image_hash?stringHash 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:

fieldtypedescription
like_countnumberLikes in this batch.
total_likes?numberRunning total for the whole stream.

follow

A viewer followed the creator.

data carries all identity fields, plus:

fieldtypedescription
total_followers?numberThe 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.

share

A viewer shared the live stream.

data carries all identity fields.

join

A viewer entered the live room.

data carries all identity fields, plus:

fieldtypedescription
current_viewers?numberViewers 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:

fieldtypedescription
sub_month?numberMonths purchased in this transaction — NOT how long they have been subscribed. Use total_months for tenure.
total_months?numberTotal months they have been subscribed.
subscribe_type?number1 = new subscription, 2 = renewal.

emote

A viewer sent a subscriber emote / sticker.

data carries all identity fields, plus:

fieldtypedescription
emote_id?stringTikTok's id for the emote.
emote_image_url?stringURL 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:

fieldtypedescription
coins?numberCoins inside the box.
count?numberHow many viewers can claim a share.
envelope_id?stringTikTok's id for the box.
business_typenumberTikTok'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:

fieldtypedescription
coins?numberCoins inside the chest.
count?numberHow many viewers can claim a share.
envelope_id?stringTikTok's id for the chest.
business_typenumberTikTok'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:

fieldtypedescription
coins?numberCoins inside the portal.
count?numberHow many viewers can claim a share.
envelope_id?stringTikTok's id for the portal.
business_typenumberTikTok's box category. Always 4 for this event.

viewer_count

The room's viewer count changed. No viewer identity on this one.

fieldtypedescription
usernamestringThe creator's @handle.
viewer_countnumberViewers watching right now.
top_viewersobject[]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.

fieldtypedescription
usernamestringThe creator's @handle.
product_name?stringName of the product.
product_image?stringURL of the product image.
price?stringPrice as TikTok formatted it, e.g. "$12.99" — a string, not a number.
shop_name?stringName of the shop.

stream_start

The creator went live.

fieldtypedescription
usernamestringThe creator's @handle.

stream_end

The creator's stream ended.

fieldtypedescription
usernamestringThe 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" }
Sending 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.

codeclosemeaning
UNAUTHORIZED1008The key is missing, malformed, revoked, or does not exist. Also sent mid-stream if the key is revoked while you are connected.
TIER_REQUIRED1008The key is valid but its owner is not on a Creator plan. Also sent mid-stream if the plan lapses.
NO_USERNAME1008The key's owner has no TikTok account linked yet.
RATE_LIMIT1008 / 1013Too 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 connections10 per account
API keys20 per account
Inbound control frames30 per second, then disconnect
Max frame size16 KB
Connection attempts600 per minute per IP
Server keepaliveWebSocket ping every 30s
Key / plan re-checkevery 30s — revoking a key cuts live sockets within ~30s
Slow consumersevents 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 } }
  • event uses TikFinity's names, not the cloud type names — chat (not comment), member (not join), subscribe (not superfan), roomUser (not viewer_count). There is no local equivalent of shop_purchase or stream_start.
  • data is the raw internal payload in camelCase (nickname, giftName, uniqueId) — not the snake_case fields documented above, and with no outer envelope, so no v, id or timestamps.
  • data.username is 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 as undefined.
  • 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 21213 is the default, but if it is already taken (TikFinity itself uses it) the app walks up to 21222 — 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.