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 — priced per room-minute, 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.

Metering is truthful: only an active subscription owns a room and bills room-minute credits. queued, unavailable and offline are free — you never pay to wait.

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.

POST /v1/live/stream/token → response
{ "token": "<jwt>", "tokenType": "Bearer", "expiresIn": 60, "wsUrl": "wss://…/v1/live/stream" }

Then send commands as JSON frames:

CommandMeaning
subscribeStart (or resume) events for one creator: { type, creatorId, requestId? }.
unsubscribeStop events for one creator and release its room-minute billing.

Subscription lifecycle

Every status frame carries one of these. Only active is billed:

StatusMeaningBilled?
queuedAccepted, 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.No
activeA worker owns the room and its LIVE events are streaming to you now.Yes — room-minute
unavailableThe 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.No
offlineThe creator is not live. There is nothing to stream until they start a broadcast.No
unsubscribedYou (or a reconnect that dropped the creator) removed the subscription. No further frames for it.No

When the workspace runs out of credits a subscription cannot stay active — you get a 402-class close/status and the room is released. Top up and re-subscribe.

Server frames

Four frame types arrive on the socket. Branch on type first:

typeWhat it is
helloFirst frame after the handshake. Echoes your connectionId, gatewayId and bound workspaceId — confirm the tenant here.
statusA subscription changed state: { type, creatorId, status, roomId?, reason?, requestId? }. Drive your UI off this, not off event volume.
eventA LIVE event for a subscribed creator. Shape below; the payload is under data.decoded.
errorA 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:

codeMeaningRetry?
invalid_messageThe frame you sent was not valid JSON or not a known command.No
invalid_creatorThe creator handle/id in a subscribe/unsubscribe could not be resolved.No
subscription_failedThe 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.

event frame
{
  "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.

FieldTypeNote
commentstringThe message text.
user.uniqueIdstringAuthor handle; also nickname, secUid, verified, avatar.
data.decoded
{
  "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. Wait for repeatEnd before summing a streak so you count it once.

FieldTypeNote
gift.giftNamestringDisplay name (e.g. "Rose").
gift.diamondCountnumberDiamond value of ONE gift.
repeatCountnumberUnits in this (possibly streaking) send.
repeatEndbooleanTrue on the final frame of a streak — total = diamondCount × repeatCount.
user.uniqueIdstringSender; toUser is the recipient when gifted to a guest.
data.decoded
{
  "gift": { "giftName": "Rose", "diamondCount": 1, "giftType": 1 },
  "repeatCount": 5,
  "repeatEnd": true,
  "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.

FieldTypeNote
actionnumberMember action code (join/leave).
memberCountnumberMembers in the room after this action.
user.uniqueIdstringThe member.
data.decoded
{
  "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.

FieldTypeNote
likeCountnumberLikes in this burst.
totalLikeCountnumberCumulative likes for the room.
user.uniqueIdstringWho liked.
data.decoded
{
  "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.

FieldTypeNote
socialType"follow" | "share" | "other"Which social action.
followCountnumberRoom follows so far (for follow).
shareCountnumberRoom shares so far (for share).
user.uniqueIdstringThe actor.
data.decoded
{
  "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.

FieldTypeNote
viewerCountnumberConcurrent viewers right now.
totalUsernumberCumulative users who have entered.
topViewers[]ContributorData[]Ranked contributors: { rank, coinCount, delta, user }.
data.decoded
{
  "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.

FieldTypeNote
actionControlActionTypeSTREAM_PAUSED | STREAM_UNPAUSED | STREAM_ENDED | STREAM_SUSPENDED | UNKNOWN.
isStreamEndbooleanTrue when this ends the broadcast.
tipsstringHuman-readable note, when present.
data.decoded
{
  "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.

FieldTypeNote
envelopeInfo.diamondCountnumberTotal diamonds in the envelope.
envelopeInfo.unpackAtnumberEpoch seconds when it can be opened.
envelopeInfo.sendUserNamestringWho dropped it.
data.decoded
{
  "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.

FieldTypeNote
diamondCountnumberDiamonds in the goody bag.
countDownMinutesnumberMinutes until it opens.
joinedHeadcountnumberParticipants so far.
data.decoded
{
  "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.

FieldTypeNote
subMonthnumberConsecutive months subscribed.
subscribeTypenumberTikTok's subscribe-type code (new / renew / gifted), verbatim.
user.uniqueIdstringThe subscriber.
data.decoded
{
  "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.

FieldTypeNote
participants[].user.uniqueIdstringEach creator on the battle roster.
data.decoded
{
  "participants": [
    { "user": { "uniqueId": "hostA", "nickname": "Host A" } },
    { "user": { "uniqueId": "hostB", "nickname": "Host B" } }
  ]
}

linkMicArmies — live PK scoreboard

The running score of a PK battle: each host's side (team) with its current points and the supporters counted into them. battleStatus encodes in-progress vs ended.

FieldTypeNote
battleStatusnumberTikTok battle-status code, verbatim.
teams[].hostUserIdstringThe host this side belongs to.
teams[].groups[].pointsnumberThat side's current battle points.
data.decoded
{
  "battleStatus": 1,
  "teams": [
    { "hostUserId": "111", "groups": [ { "points": 1200, "users": [] } ] },
    { "hostUserId": "222", "groups": [ { "points": 980, "users": [] } ] }
  ]
}

hourlyRank — the room's rank standing

The room's current position on TikTok's hourly ranking board.

FieldTypeNote
labelstringThe rank label TikTok renders (e.g. the position text).
typestringRanking board type key, verbatim.
rankIdstringRank item id, verbatim.
data.decoded
{
  "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.

FieldTypeNote
startedAtstringISO instant the room was observed to go live.
data.decoded
{
  "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.

FieldTypeNote
(none)No decoded body. The frame's data carries messageType, msgId and, if decoding failed, decodeError.
data.decoded
// no data.decoded — inspect the raw fields:
// data.messageType, data.msgId, data.decodeError

Backpressure & 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 "@toktik/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.repeatEnd) 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.