Developer Docs
Goody-bag feed
Watch LIVE rooms for goody bags and receive each one the moment it appears: choose the rooms, connect one WebSocket, parse the event.
Tell us which LIVE rooms to watch — the best-ranked N, or creators you name — and receive every goody bag the moment we see it, on one WebSocket. We keep the set full as rooms end, so you do not manage room lifecycles.
How it works
A watch set is the rooms we sweep for you. We check every room in your set on a fixed cycle that is far shorter than a bag's countdown, and publish a bag once when it appears — a bag counts down for minutes before it opens, so you get it with most of that countdown left. Bags that already opened are never sent. When a room ends we replace it from the same source, so a top set of 2,000 stays at 2,000 live rooms.
| Mode | What you watch |
|---|---|
top | The N best-ranked live rooms (optionally one board region). Refilled as rooms end. |
custom | The live rooms of creators you list (handles or numeric creator ids, up to 5,000). Each creator is reported live, offline or not_tracked — never dropped silently. |
Access
The feed is a granted capability: your workspace needs a goody-bag grant (contact us — a trial is a time-limited grant) and an API key with three scopes:
| Scope | Used for |
|---|---|
live:stream | Opening the realtime socket and minting its token. |
goodybag:read | Receiving the feed. The token carries it only while your grant is in force. |
goodybag:manage | Choosing the rooms: PUT /v1/goodybag/watch-set and GET /v1/goodybag/capacity. |
A consumer that only listens needs live:stream + goodybag:read. Setting rooms is not credit-metered. When a grant ends the feed stops within one token lifetime and your set is kept, ready for when it is renewed.
1 · Choose the rooms
# Watch the 2,000 best-ranked live rooms (the lane keeps the set full as rooms end):
curl -sX PUT 'https://api.toktikhq.com/v1/goodybag/watch-set' \
-H "Authorization: Bearer $TOKTIK_API_KEY" -H 'Content-Type: application/json' \
-d '{ "mode": "top", "size": 2000 }'
# …or watch specific creators (handles or numeric creator ids; each is reported live/offline/not_tracked):
curl -sX PUT 'https://api.toktikhq.com/v1/goodybag/watch-set' \
-H "Authorization: Bearer $TOKTIK_API_KEY" -H 'Content-Type: application/json' \
-d '{ "mode": "custom", "size": 500, "entries": ["@creator_handle", "7298720060985852961"] }'
# How many rooms you can still ask for:
curl -s 'https://api.toktikhq.com/v1/goodybag/capacity' -H "Authorization: Bearer $TOKTIK_API_KEY"size is the most live rooms you watch. The response is the stored set plus the lane capacity; the rooms are filled within about 30 seconds and rooms shows how many are live right now. Reading it back with GET /v1/goodybag/watch-set also lists every custom entry with its status. The full request and response shapes are in the API Reference.
| HTTP | code | Meaning | Retry? |
|---|---|---|---|
| 400 | invalid_watch_set | The body is malformed: mode must be top or custom, size an integer ≥ 1, a custom set needs entries, a top set takes none. | No |
| 403 | goodybag_not_entitled | Your workspace has no goody-bag grant. Ask us to enable it. | No |
| 403 | goodybag_entitlement_expired | Your grant (for example a trial) has ended. Your set is kept; it resumes when the grant is renewed. | No |
| 409 | goodybag_plan_limit | size is above the rooms your grant includes (details.includedRooms). | No |
| 409 | goodybag_lane_capacity | The lane has not enough free rooms for your request (details.available of details.capacity). Ask for fewer rooms, or retry later. | Yes |
2 · Connect and listen
Mint a short-lived token with POST /v1/live/stream/token, open the socket with it in the Authorization header (or the Sec-WebSocket-Protocol subprotocol — never the URL), then send goodybag.subscribe. This is the same socket as the Realtime guide; the goody-bag feed is a separate plane on it, so it does not affect or bill any per-creator subscription.
The JavaScript sample needs Node 24 or newer (older lines are end-of-life), and `npm i ws`.
import WebSocket from "ws";
const API = process.env.TOKTIK_API ?? "https://api.toktikhq.com";
async function connect() {
// 1. A short-lived handshake token (it carries goodybag:read only while your grant is in force).
const res = await fetch(`${API}/v1/live/stream/token`, { method: "POST", headers: { Authorization: `Bearer ${process.env.TOKTIK_API_KEY}` } });
if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
const { token, wsUrl } = await res.json();
// 2. Open the socket with the token in the Authorization header (never in the URL).
const ws = new WebSocket(process.env.TOKTIK_WS ?? wsUrl, { headers: { Authorization: `Bearer ${token}` } });
ws.on("open", () => ws.send(JSON.stringify({ type: "goodybag.subscribe", requestId: "r1" })));
ws.on("message", (data) => {
const frame = JSON.parse(data.toString());
if (frame.type === "goodybag.status") console.log("feed", frame.status);
else if (frame.type === "goodybag.error") console.error("error", frame.code, frame.retryable ? "(retry)" : "");
else if (frame.type === "goodybag.event") {
const bag = frame.goodyBag;
const leadSeconds = bag.unpackAt - Math.floor(Date.parse(frame.provenance.observedAt) / 1000);
console.log(`bag ${bag.goodyBagId} in room ${frame.roomId} (@${frame.creatorId}): ${bag.diamondCount} diamonds, opens in ${leadSeconds}s`);
}
});
// Tokens are short-lived: when the socket closes, mint a new token and reconnect (with backoff).
ws.on("close", () => setTimeout(connect, 5000));
}
connect();Commands you can send:
| Command | Meaning |
|---|---|
goodybag.subscribe | Start receiving the goody bags found in your watch set's rooms. Optional filters (below) can only NARROW what you get — a room outside your own watch set is never delivered. Send it again to change the filters; the new ones replace the old. |
goodybag.unsubscribe | Stop the goody-bag feed on this socket. Other subscriptions on the socket are untouched. |
Filters are checked on our side before a frame is sent, so you only receive the bags you want. Combine any of them; each one only narrows. The goodybag.status reply echoes the filters in force.
| Filter | Value | Meaning |
|---|---|---|
roomIds | string[] (≤ 2,000) | Only these rooms (numeric TikTok room ids) — still only rooms of your own watch set. |
minCoins | number ≥ 0 | Only bags holding at least this many coins (diamondCount). |
minCoinsPerPerson | number ≥ 0 | Only bags where diamondCount ÷ peopleCount is at least this (a peopleCount of 0 counts as 1). |
maxLevel | integer ≥ 0 | Only bags whose join level is at most this. The level is the number in participateThresholdSelected ("Level: 3" → 3); no number (for example "All viewers") is level 0. |
maxViewers | integer ≥ 0 | Only rooms with at most this many viewers (room.viewerCount). A room whose viewer count is not known yet is still delivered. |
isBxh | 0 | 1 | 1 = only rooms on a leaderboard; 0 = only rooms from your own lists (the frame's is_bxh). |
{ "type": "goodybag.subscribe", "filters": { "minCoins": 500, "minCoinsPerPerson": 20, "maxLevel": 2, "maxViewers": 300 }, "requestId": "r1" }Frames you receive
| type | What it is |
|---|---|
goodybag.status | Acknowledges a subscribe or unsubscribe (status subscribed | unsubscribed) and echoes the effective filters. |
goodybag.event | One goody bag found in a room of your watch set. Exactly one frame per bag. is_bxh is 1 when the room is on a leaderboard and 0 when it comes only from your own lists (follow lists or creators you listed). |
goodybag.error | A rejected goodybag.* command, with a stable code and a retryable flag. It never closes the socket. |
{ "type": "goodybag.status", "status": "subscribed", "filters": { "minCoins": 500, "minCoinsPerPerson": 20, "maxLevel": 2, "maxViewers": 300 }, "requestId": "r1" }The goodybag.event frame
{
"type": "goodybag.event",
"event": "goodyBag",
"eventId": "evt_goody_bag:7699967922753145632:7700088522992667425",
"roomId": "7699967922753145632",
"creatorId": "harisfarmer2",
"creatorUid": "7298720060985852961",
"goodyBag": {
"roomId": "7699967922753145632",
"goodyBagId": "7700088522992667425",
"biz": "Cau lac bo fan",
"diamondCount": 200,
"peopleCount": 100,
"countDownMinutes": 10,
"unpackAt": 1792817215,
"joinedHeadcount": 61,
"participateMethod": "Chia se",
"participateMethodContent": "",
"participateThresholdSelected": "Level: 3"
},
"is_bxh": 1,
"provenance": {
"observedAt": "2026-10-24T04:37:26.069Z",
"freshness": "near_realtime",
"source": "platform_observed_goody_bag_poll",
"coverageStatus": "observed",
"methodologyVersion": "goodybag-watch-v1"
}
}eventId is stable per bag, so make your handling idempotent. creatorId is the creator's handle (or null when unknown). provenance.observedAt is when we saw the bag. Fields of goodyBag:
| Field | Type | Note |
|---|---|---|
goodyBagId | string | TikTok's id of the bag. With roomId it identifies the bag — use it to de-duplicate on your side. |
biz | string | The bag's kind as TikTok labels it (for example a fan-club bag). |
diamondCount | number | Diamonds in the bag. |
peopleCount | number | How many people the bag is split between. |
countDownMinutes | number | The countdown TikTok started, in minutes. |
unpackAt | number | Epoch SECONDS when the bag opens. Lead time = unpackAt − (provenance.observedAt in epoch seconds). |
joinedHeadcount | number | How many viewers had joined when we saw it. |
participateMethod | string | How viewers join (for example comment or share). |
participateMethodContent | string | The join instruction, when TikTok gives one. |
participateThresholdSelected | string | null | Who may join (for example All viewers or Level: 3). |
A rejected command comes back as a goodybag.error frame:
| code | Meaning | Retry? |
|---|---|---|
goodybag_forbidden | The feed is not available to this token: it lacks the goodybag:read scope (your key lacks it, or your workspace has no goody-bag grant in force) or the feed is switched off. Mint a new token after fixing the cause. | No |
goodybag_capacity | The gateway (or your workspace) is at its goody-bag subscriber ceiling. Retry shortly, or close an idle socket of yours. | Yes |
invalid_filters | filters is malformed: an unknown filter name, or a value outside what the filter table allows (the message names it). Your previous filters stay in force. | No |
{ "type": "goodybag.error", "code": "goodybag_forbidden", "message": "The goody-bag feed requires the goodybag:read scope.", "retryable": false, "requestId": "r1" }Limits & behaviour
Delivery. Frames are never dropped silently: a client that cannot keep up is closed with code 1013 — reconnect with a fresh token and backoff. The feed is not a ledger: after a reconnect you receive bags found from then on, so reconcile from your own records if you need history. Capacity. The lane can only sweep so many rooms per cycle; the rooms promised to every customer are counted, and a request that would not fit is refused with goodybag_lane_capacity rather than quietly swept more slowly. Isolation. You only ever receive bags from rooms in your own watch set.
Keys and scopes: Authentication. Errors in general: Errors.