Developer Docs
Realtime
The any-creator WebSocket LIVE event stream, its frames and backpressure.
One authenticated WebSocket streams LIVE events for any creator you subscribe to, on demand. It is the Live module's realtime surface — sold by LIVE rooms held at once, with no credits spent, and honest about the wait when a room has to be acquired.
The model
You open one socket and subscribe to one or more creators over it. Because any creator can be requested, a subscription is not instant: we must admit the room and have a worker own it before events flow. You are told exactly where each subscription stands through status frames — queued then active, or offline when the creator is not live. Drive your UI off those statuses, not off event volume.
Realtime is sold by LIVE rooms held at once: your plan sets how many the workspace can hold at once (1 on Free, up to 3,000 on Agency), and streaming spends no credits. A creator you stay subscribed to can keep one of those rooms while you wait for them to go live, so unsubscribe from the ones you no longer need.
Plans, the price per room and the full event list: TikTok LIVE WebSocket API.
Handshake
Mint a short-lived token over REST, then open the socket carrying that token in the Authorization header or the Sec-WebSocket-Protocol subprotocol — never in the URL (query strings leak into logs). The token expires quickly; the SDKs mint and refresh it for you across reconnects.
{ "token": "<jwt>", "tokenType": "Bearer", "expiresIn": 60, "wsUrl": "wss://…/v1/live/stream" }Then send commands as JSON frames:
| Command | Meaning |
|---|---|
subscribe | Start (or resume) events for one creator: { type, creatorId, requestId? }. |
unsubscribe | Stop events for one creator and free the concurrent room it was holding. |
Subscription lifecycle
Every status frame carries one of these. None of them spends credits:
| Status | Meaning |
|---|---|
queued | Accepted, waiting for capacity. The creator's room is being admitted and a worker asked to own it; you receive events once it flips to active. Any-creator demand means this wait is real — it is not instant. |
active | A worker owns the room and its LIVE events are streaming to you now. |
unavailable | The room cannot be owned right now — no capacity, or the creator's stream cannot be reached. Transient; the subscription stays and may recover to active. |
offline | The creator is not live. Stay subscribed: when they start a broadcast the status moves to queued, then active, on the same connection — no need to reconnect or subscribe again. A subscribed creator can count toward your plan's limit of LIVE rooms held at once while they are offline too. |
unsubscribed | You (or a reconnect that dropped the creator) removed the subscription. No further frames for it. |
When the workspace already holds as many rooms as its plan allows, a new subscription gets unavailable with the reason plan_cap_reached. Unsubscribe from a creator you no longer need, or move to a larger plan, then subscribe again. The reason creator_resolve_failed means the creator's live room could not be looked up on TikTok for 10 minutes: unsubscribe and subscribe again to retry.
Server frames
Four frame types arrive on the socket. Branch on type first:
| type | What it is |
|---|---|
hello | First frame after the handshake. Echoes your connectionId, gatewayId and bound workspaceId — confirm the tenant here. |
status | A subscription changed state: { type, creatorId, status, roomId?, reason?, requestId? }. Drive your UI off this, not off event volume. |
event | A LIVE event for a subscribed creator. Shape below; the payload is under data.decoded. |
error | A command was rejected: { type, code, message, requestId?, retryable }. See the error codes below. |
A rejected command comes back as an error frame with a stable code:
| code | Meaning | Retry? |
|---|---|---|
invalid_message | The frame you sent was not valid JSON or not a known command. | No |
invalid_creator | The creator handle/id in a subscribe/unsubscribe could not be resolved. | No |
subscription_failed | The subscription could not be established (e.g. admission was refused). | Yes |
The event frame
Every LIVE event shares one envelope: type: "event", the event name, an eventId, creatorId, roomId, a monotonic sequence, roomState, and the decoded body under data.decoded. Sequence increases per room but is not a gap-free guarantee — treat events as a stream, not a ledger, and make client handling idempotent.
{
"type": "event",
"event": "gift",
"eventId": "01JQ8Z6M9C7K2R…",
"creatorId": "@creator_handle",
"roomId": "7451234567890123456",
"sequence": 128,
"roomState": "active",
"data": {
"messageType": "WebcastGiftMessage",
"decoded": {
"gift": { "giftName": "Rose", "diamondCount": 1 },
"repeatCount": 5,
"repeatEnd": true,
"user": { "uniqueId": "whale_01", "nickname": "Top Fan" }
}
}
}Event-type reference
These are every event the gateway emits — 15 types, no more. We do not advertise events we do not actually produce. Fields below are on data.decoded.
chat — a viewer comment
A text message posted in the room.
| Field | Type | Note |
|---|---|---|
comment | string | The message text. |
user.uniqueId | string | Author handle; also nickname, secUid, verified, avatar. |
{
"comment": "let's go 🔥",
"user": { "uniqueId": "guest_842", "nickname": "Guest", "verified": false }
}gift — a gift send
A gift, streaks included. diamondCount is per unit; repeatCount / comboCount give the multiple. For the exact diamonds, count a send once, on the frame where isCompleted is true: diamondCount × repeatCount. A batch send (several gifts sent at once) arrives as one frame per gift, each with its own msgId and the same logId; on the batches measured (one-shot gifts) every copy is complete on its own frame, so that rule counts it once per gift.
| Field | Type | Note |
|---|---|---|
gift.giftName | string | Display name (e.g. "Rose"). |
gift.diamondCount | number | Diamond value of ONE gift. |
repeatCount | number | Units in this (possibly streaking) send. |
isCompleted | boolean | True on the one frame that completes a send (a streak's last frame, or a one-shot gift's first; a batch send: every copy, one per gift) — total = diamondCount × repeatCount. |
fanTicketCount | number | Diamonds this frame adds (a streak step only its increment, 0 on a closing frame). On a batch send every copy carries the whole batch (4 gifts of 10,000 → four frames of 40,000 each), so summing it over frames over-counts batches; use the isCompleted rule. |
user.uniqueId | string | Sender; toUser is the recipient when gifted to a guest. |
{
"gift": { "giftName": "Rose", "diamondCount": 1, "giftType": 1 },
"repeatCount": 5,
"repeatEnd": true,
"isCompleted": true,
"fanTicketCount": 0,
"user": { "uniqueId": "whale_01", "nickname": "Top Fan" }
}member — a room join / leave
Someone entered or left the room. action encodes which; memberCount is the running total.
| Field | Type | Note |
|---|---|---|
action | number | Member action code (join/leave). |
memberCount | number | Members in the room after this action. |
user.uniqueId | string | The member. |
{
"action": 1,
"memberCount": 3120,
"user": { "uniqueId": "new_viewer" }
}like — a like burst
A batch of likes. likeCount is this burst; totalLikeCount is the room total. High volume — safe to drop under backpressure.
| Field | Type | Note |
|---|---|---|
likeCount | number | Likes in this burst. |
totalLikeCount | number | Cumulative likes for the room. |
user.uniqueId | string | Who liked. |
{
"likeCount": 15,
"totalLikeCount": 998240,
"user": { "uniqueId": "fan_77" }
}social — follow / share
A follow or share action. socialType distinguishes them; followCount / shareCount are the running totals.
| Field | Type | Note |
|---|---|---|
socialType | "follow" | "share" | "other" | Which social action. |
followCount | number | Room follows so far (for follow). |
shareCount | number | Room shares so far (for share). |
user.uniqueId | string | The actor. |
{
"socialType": "follow",
"followCount": 412,
"user": { "uniqueId": "loyal_viewer" }
}roomUser — viewer count & top contributors
A periodic room summary: current viewers and the top gifters. This is the source of the live viewer count and the contributor ranking.
| Field | Type | Note |
|---|---|---|
viewerCount | number | Concurrent viewers right now. |
totalUser | number | Cumulative users who have entered. |
topViewers[] | ContributorData[] | Ranked contributors: { rank, coinCount, delta, user }. |
{
"viewerCount": 2841,
"totalUser": 51230,
"topViewers": [
{ "rank": 1, "coinCount": 12040, "user": { "uniqueId": "whale_01" } }
]
}control — stream lifecycle
A room lifecycle signal — paused, resumed, ended or suspended. When isStreamEnd is true the broadcast is over.
| Field | Type | Note |
|---|---|---|
action | ControlActionType | STREAM_PAUSED | STREAM_UNPAUSED | STREAM_ENDED | STREAM_SUSPENDED | UNKNOWN. |
isStreamEnd | boolean | True when this ends the broadcast. |
tips | string | Human-readable note, when present. |
{
"action": "STREAM_ENDED",
"isStreamEnd": true,
"tips": ""
}envelope — red-envelope (treasure box)
A red-envelope drop with a countdown. unpackAt (epoch seconds) is when it opens; diamondCount is the pot.
| Field | Type | Note |
|---|---|---|
envelopeInfo.diamondCount | number | Total diamonds in the envelope. |
envelopeInfo.unpackAt | number | Epoch seconds when it can be opened. |
envelopeInfo.sendUserName | string | Who dropped it. |
{
"display": 1,
"envelopeInfo": { "diamondCount": 999, "peopleCount": 200, "unpackAt": 1769200000, "sendUserName": "host" }
}goodyBag — goody-bag reward
A goody-bag reward event (collected via the mobile API). Similar shape to envelope, with a countdown and a diamond pot.
| Field | Type | Note |
|---|---|---|
diamondCount | number | Diamonds in the goody bag. |
countDownMinutes | number | Minutes until it opens. |
joinedHeadcount | number | Participants so far. |
{
"goodyBagId": "gb_88",
"diamondCount": 500,
"countDownMinutes": 5,
"joinedHeadcount": 140
}subscribe — a channel subscription
A new or renewed paid channel subscription — a direct monetization signal. subMonth is the headline: how many consecutive months the viewer has subscribed.
| Field | Type | Note |
|---|---|---|
subMonth | number | Consecutive months subscribed. |
subscribeType | number | TikTok's subscribe-type code (new / renew / gifted), verbatim. |
user.uniqueId | string | The subscriber. |
{
"subMonth": 3,
"subscribeType": 1,
"user": { "uniqueId": "loyal_fan", "nickname": "Loyal Fan" }
}linkMicBattle — a PK battle starts
The start of a PK / versus (LinkMic) battle, listing the competing creators.
| Field | Type | Note |
|---|---|---|
battleType | number | The battle format: 1 = 1v1, 2 = 2v2, 3 = free-for-all, 4 = 1v3; 0 when TikTok sends none. |
participants[].user.uniqueId | string | Each creator on the battle roster. |
{
"battleType": 1,
"participants": [
{ "user": { "uniqueId": "hostA", "nickname": "Host A" } },
{ "user": { "uniqueId": "hostB", "nickname": "Host B" } }
]
}linkMicArmies — live PK scoreboard
The running score of a PK battle. A 1v1 battle puts each host's side in teams, with its current points and the supporters counted into them. A 2v2, 1v3 or free-for-all battle puts its points in teamArmies: one entry per team, with the team's points and each host's own points. Whenever teamArmies is non-empty, read it and ignore teams: a 2v2 often still sends teams, but each of its sides holds a whole team's total, and not always under a host of that team. battleStatus encodes in-progress vs ended.
| Field | Type | Note |
|---|---|---|
battleId | string | The battle this scoreboard belongs to; "" when absent. |
battleStatus | number | TikTok battle-status code, verbatim. |
battleType | number | The battle format: 1 = 1v1, 2 = 2v2, 3 = free-for-all, 4 = 1v3; 0 when the message carries none (some scoreboards omit it; read it from another message of the same battleId). |
teams[].hostUserId | string | 1v1: the host this side belongs to. Ignore teams when teamArmies is non-empty. |
teams[].groups[].points | number | 1v1: that side's current battle points. |
teamArmies[].teamId | string | 2v2 and 1v3: "1" or "2". Free-for-all: every host is a team of one, and this is that host's user id. |
teamArmies[].points | number | The team's current battle points. Use this for the team score: its hosts' points usually add up to it, but not always. |
teamArmies[].hosts[].userId | string | A host on that team (TikTok user id). |
teamArmies[].hosts[].points | number | That host's own battle points; 0 when TikTok sends none. |
{
"battleId": "7700872369384409908",
"battleStatus": 1,
"battleType": 2,
"teams": [],
"teamArmies": [
{ "teamId": "1", "points": 2200, "hosts": [ { "userId": "111", "points": 1200 }, { "userId": "333", "points": 1000 } ] },
{ "teamId": "2", "points": 980, "hosts": [ { "userId": "222", "points": 980 }, { "userId": "444", "points": 0 } ] }
]
}hourlyRank — the room's rank standing
The room's current position on TikTok's hourly ranking board.
| Field | Type | Note |
|---|---|---|
label | string | The rank label TikTok renders (e.g. the position text). |
type | string | Ranking board type key, verbatim. |
rankId | string | Rank item id, verbatim. |
{
"type": "hourly",
"label": "No. 3",
"rankId": "3",
"colour": "#FFB300"
}liveStart — the creator went live
A synthetic marker the platform emits the instant a tracked creator's room transitions to live — not a frame decoded from TikTok. It carries no gift/score data; use it to react to a creator going live (the same signal that powers the 'Went live' alert rule). roomState is 'active' and it is never a session end.
| Field | Type | Note |
|---|---|---|
startedAt | string | ISO instant the room was observed to go live. |
{
"startedAt": "2026-08-05T10:00:00.000Z"
}unknown — undecoded frame
A frame we received but do not decode into a typed body — either a message type we do not model, or one whose protobuf failed to decode. Honest by design: rather than drop it, we surface it so nothing is silently lost. There is no data.decoded; read data.messageType / data.decodeError.
| Field | Type | Note |
|---|---|---|
(none) | — | No decoded body. The frame's data carries messageType, msgId and, if decoding failed, decodeError. |
// no data.decoded — inspect the raw fields:
// data.messageType, data.msgId, data.decodeErrorBackpressure & reconnect
Each socket has a bounded send queue. If your client falls behind, high-volume lossy frames — like, member, roomUser, unknown — may be dropped to keep up; critical overflow closes only that socket with code 1013. Never assume you saw every like. Reconnect with exponential backoff and re-subscribe (the SDKs replay your subscriptions automatically); treat gaps as normal and keep your handlers idempotent.
Connect
The SDKs own the token lifetime, reconnection and resume. Raw is there if you drive the socket yourself:
import { TokTikClient } from "@toktikhq/sdk-js";
const client = new TokTikClient({ apiKey: process.env.TOKTIK_API_KEY! });
const stream = await client.live.stream(["@creator_handle"], {
onStatus: (s) => console.log("status", s.creatorId, s.status), // queued → active
onEvent: (frame) => {
if (frame.event === "gift") {
const g = frame.data.decoded;
if (g.isCompleted) console.log(g.gift?.giftName, (g.gift?.diamondCount ?? 0) * g.repeatCount);
}
},
onError: (e) => console.error(e),
onReconnect: (attempt) => console.warn("reconnected", attempt)
});
// later: await stream.close();Realtime needs the live:stream scope and an active plan; see Authentication and Credits.