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.
{ "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 release its room-minute billing. |
Subscription lifecycle
Every status frame carries one of these. Only active is billed:
| Status | Meaning | Billed? |
|---|---|---|
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. | No |
active | A worker owns the room and its LIVE events are streaming to you now. | Yes — room-minute |
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. | No |
offline | The creator is not live. There is nothing to stream until they start a broadcast. | No |
unsubscribed | You (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:
| 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, a provenance block, 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" }
}
},
"provenance": {
"observedAt": "2026-07-24T04:12:33.481Z",
"freshness": "near_realtime",
"source": "platform_observed_live_stream",
"coverageStatus": "observed",
"methodologyVersion": "realtime-v1"
}
}Event-type reference
These are every event the gateway emits — 10 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. Wait for repeatEnd before summing a streak so you count it once.
| 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. |
repeatEnd | boolean | True on the final frame of a streak — total = diamondCount × repeatCount. |
user.uniqueId | string | Sender; toUser is the recipient when gifted to a guest. |
{
"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.
| 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
}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 "@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.