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, 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.

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" }
    }
  },
  "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.

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
}

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.