Docs
Gift rooms
A room is everyone watching one live stream, audio room or match. Every gift sent into the room plays on every viewer's screen, as fast as the network carries it. Gift events only: no chat, no audio or video, no gift messages.
How it works
1. Your server mints a client token that lists the rooms the user may join. 2. The app joins the room with the SDK: one WebSocket to rt.giftingstack.com. 3. Someone sends a gift with a room_id. The send is booked as usual. 4. The room passes a small gift event to every viewer. The SDK plays the effect.
The room is best effort: a send never waits for the room and never fails because of it. Under heavy load a room groups gifts: above 10 gifts a second viewers get one update every quarter of a second (once a second in a very busy room), with repeated gifts from the same sender added into one quantity. The SDK reconnects, catches up on gifts from the last few seconds and refreshes the token by itself. A viewer who was offline for longer simply does not see the older gifts.
1. The rooms switch
Rooms are on for every project. You can turn Gift rooms off in the
project settings of the dashboard. While it is off, joins are refused (4010)
and sends with a room_id still succeed but reach no one.
2. Mint tokens with rooms
Rooms are closed by default: a client token without rooms
cannot join any room, and a send from it that names a room_id fails with
403 room_not_allowed. List the rooms this user may watch and send into: exact
room ids, or prefixes ending in *, at most 8. Add name (1 to 32
characters, no control, zero-width, direction-changing or other invisible characters)
for what viewers see as the sender.
curl -X POST https://api.giftingstack.com/v1/client-tokens \
-H "Authorization: Bearer $GS_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"user": "usr_8f3a", "rooms": ["live_123"], "name": "Ava"}'
Keep rooms as narrow as the screen needs: live_123 rather than
live_* for a private room. Room ids are 1 to 64 characters of
A-Z a-z 0-9 _ . : -, case sensitive.
3. Join with the SDK
Join on the viewer screen with the same getToken you give the client. Room
gifts then play in your effect layer; the user's own gifts already played on tap and are
not played twice. Web:
import { createGiftingClient } from "@giftingstack/sdk";
import { GiftEffectLayer, joinRoom } from "@giftingstack/sdk/web";
const getToken = async () => (await fetch("/api/gift-token")).text();
const client = createGiftingClient({ getToken });
const effects = new GiftEffectLayer(document.getElementById("stage"));
const room = joinRoom({ room: "live_123", getToken, client, effects });
room.subscribe(() => (watching.textContent = `${room.viewers ?? "-"} watching`));
// Leaving the stream page:
room.leave();
The rooms address must be wss://. A ws:// URL is refused (a
programming error, not a GiftingError) unless the host is
localhost, 127.0.0.1, ::1 or
10.0.2.2, or you pass allowInsecure to
joinRoom (the other SDKs take the same option;
<gift-room allow-insecure> for the element).
Without an SDK, a room is a plain WebSocket to
wss://rt.giftingstack.com/v1/rooms/{room_id} with the subprotocols
gs.v1 and the client token (browsers cannot set headers, so the token never
goes in the URL); the protocol is in the SDK spec; contact support@giftingstack.com to get it.
The same on the other platforms: useGiftRoom (React Native),
GiftRoom (Flutter), rememberGiftRoom (Android) and
GiftRoomModel (iOS). See SDKs. On phones the
connection closes when the app goes to the background and resumes when it comes back.
4. Send into the room
Add the room to the send. From the app, pass roomId to the tray or to
sendGift; the token's rooms must allow it. From your server, add
room_id to POST /v1/gift-transactions; a secret key may name any
room of the project. If the sender's token has no name, viewers get the gift
without a sender name.
createGiftTray({ container, client, receiver: hostId, effects, roomId: "live_123" });
The gift is booked whether or not the room can show it. A send that names a
room_id answers with a GiftingStack-Room-Event header that says
what happened to the room event: queued, or
skipped; plan_required (live environment on a plan without live gift
rooms), or skipped; rooms_disabled (Gift rooms are switched off in the
project settings). Use it to find out why nobody watching saw a gift. Test-environment
rooms work on every plan, with up to 20 viewers.
A comment bar under the stream
The comments are your app's chat, not ours. To put a gift button in your comment bar, use
the SDK's GiftButton and give the tray your comment field and the room: the
button closes the keyboard and opens the tray in its place, a tap on the field brings the
keyboard back (Gift button in your message bar).
Show your own gifts in your comment column from onSent (the room does not
send your own gifts back to you) and everybody else's from the room's
onEvent.
const button = new GiftButton(commentBar, { theme, filled: true });
createGiftTray({ container: screen, client, receiver: hostId, effects, roomId: "live_123",
anchor: button.element, input: commentField, onSent: (tx) => addMyGiftLine(tx) });
One gift button for a battle
A battle does not need a Gift button per side. Give the tray the round's sides as
receivers: it shows a "Send to" row with a small avatar per side, ringed in
the side's colour, and every gift goes to the side that is picked (by default the side
this viewer gifted last, else the first). Make the battle bar's sides tappable with
onPressSide and open the tray with that side picked. Colour your gift lines
with the receiving side's colour so the chat matches the bar.
const sides = [{ id: "mia", name: "Mia" }, { id: "theo", name: "Theo" }];
const tray = createGiftTray({ container: screen, client, receiver: "mia", receivers: sides,
roomId: "live_123", receiverOf: room.receiverFor, anchor: giftButton.element, input: commentField });
new GiftBattleBar(barSlot, { room, onPressSide: (id) => { tray.selectReceiver(id); tray.open(); } });
// React Native: useGiftTray({ ..., receivers }) and <BattleBar onPressSide={(id) => { tray.selectReceiver(id); tray.open(); }} />
Battles (versus)
Two to eight hosts share one live room, for example Ava and Mateo. Viewers tap a side to gift that host, and everyone sees a live score bar. The room adds up the gifts for you, so you do not count anything yourself.
Your server starts the round with the secret key. Client tokens cannot start, end or reset a round. Starting sets both scores to 0.
curl -X POST https://api.giftingstack.com/v1/rooms/live_123/battle \
-H "Authorization: Bearer $GS_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"round": "match_42_r1", "receivers": ["ava", "mateo"], "duration_s": 300}'
-
receivers(required): the hosts who compete, 1 to 8. The bar keeps this order. Gifts to anyone else in the room still play, but do not count and never take a place in the round. -
round(required): your id for the round. Sending the same id again changes nothing, so retries are safe. Use a new id for each round: a late retry of an earlier round (one of the room's last 16) answers409 round_supersededand does not reset the round that replaced it. -
duration_s: when counting stops (at most 24 hours). Stop early withPOST /v1/rooms/live_123/battle/end. Read the result withGET /v1/rooms/live_123/battle. Remove the bar withDELETE /v1/rooms/live_123/battle. -
The calls need the secret key (client tokens get
401) and answer403 rooms_disabled,403 plan_requiredor503 rooms_unavailablelike a send into a room. Sending the sameroundagain answers with"replayed": true. While the live environment is suspended, starting, ending and removing a round answer403 project_suspended; reading the round still works.
The viewer picks the side by choosing the receiver of the gift. Use a tray per host, or change the tray's receiver when the viewer taps a side. Scores are the gifts' value: price times quantity, plus a gift count.
Scores arrive by themselves. A viewer who joins late gets the current scores at once. After that, updates come at most 4 times a second (once a second in a very busy room). The same gift is never counted twice. On web and React Native you can let the SDK draw the bar (below); this is the data it draws from, on web:
const room = joinRoom({ room: "live_123", getToken, client, effects });
room.onBattle(() => {
const view = room.battleView(); // null when there is no round
if (!view) return hideBar();
const [ava, mateo] = view.sides; // in the order your server gave
bar.style.setProperty("--left", `${ava.share * 100}%`);
leftScore.textContent = ava.units;
rightScore.textContent = mateo.units;
timer.textContent = view.open ? `${Math.ceil(view.remainingMs / 1000)} s` : "Final";
});
tapAva.onclick = () => client.sendGift({ receiver: "ava", giftId: "pink-tulip", roomId: "live_123" });
tapMateo.onclick = () => client.sendGift({ receiver: "mateo", giftId: "pink-tulip", roomId: "live_123" });
React Native: useBattleScore(useGiftRoom({ ... }).controller) returns the same
view and keeps the countdown moving. Flutter, Android and iOS have the same headless
client: GiftRoom.battleView and onBattle (Flutter),
GiftRoom.battle, battleView() and
rememberGiftRoom(onBattle = ...) (Android), GiftRoomModel with
battle, battleView() and onBattle (iOS). All five
SDKs also draw the bar (see below), or you draw your own from the view. Battles follow the same plan
rules as rooms: live rooms need a paid plan, and test rooms work on every
plan.
The bar, drawn for you (web, React Native, Flutter, Android and iOS). The receivers with
their scores, a countdown, and a split bar whose boundary slides as gifts arrive; the
side that just got a gift pulses, and when the round ends the winner is marked. A round
can have up to eight receivers (the API limit): two draw the classic versus, three or
more a row of cells with a bar of proportional segments. Colours come from the SDK theme
(the accent, the coin colour, then teal and pink, light or dark; or pass
sideColors, one colour per side), and reduced motion turns the animation off. The bar only
shows what battleView says; your server still starts and ends rounds. The
gift notice of a battle says who got the gift ("sent Rose to Mia") and borders its pill in
that side's colour; give the room the same sideColors as the bar
(Customize).
// web: pass the room you joined; it follows the battle by itself
new GiftBattleBar(container, { room, resolveUsers });
<gift-battle-bar> // el.room = roomHandle; el.resolveUsers = ...
// React Native
const { controller } = useGiftRoom({ room: "live_123", getToken });
const view = useBattleScore(controller);
<BattleBar view={view} users={{ ava: { name: "Ava" }, mateo: { name: "Mateo" } }} />
// Flutter: GiftBattleBar(room: room, users: {"ava": BattleBarUser(name: "Ava"), ...})
// Android: GiftBattleBar(room, users = mapOf("ava" to BattleBarUser("Ava"), ...))
// iOS: GiftBattleBar(room: room, users: ["ava": BattleBarUser(name: "Ava"), ...])
Limits
-
Viewers per room: 20 at a time in the test environment, on every
plan. Live-environment rooms need a paid plan (Starter 1,000 per room, Growth 5,000, Scale
20,000, more on request); on the Test plan a live join is closed with
4010. A join over the cap is closed with4009. -
Join rates: per user, per room and per project (1,000 joins a minute for
the test environment and Starter, 3,000 for Growth, 15,000 for Scale). A join over a
rate is closed with
4008, usually withretry_after. The JavaScript SDK waits at least that long (30 seconds at the least, growing to 5 minutes) and keeps trying; the other SDKs wait at least 30 seconds and try at most 5 times. -
Connections per user: 3 per room. A fourth replaces the oldest, which
is closed with
4009and does not retry. -
Connection lifetime: 60 minutes or the token's expiry, whichever is
first (
4001). The SDK gets a fresh token and reconnects without a gap.
Close codes and what the SDKs do. These end the session: the SDK reports the code and does not retry.
4003: the token does not allow this room, or it is invalid.4004: the room id breaks the rule above.-
4010: rooms are switched off for the project (reasonrooms_disabled), or the project's plan has no live rooms (reasonplan_required, live environment only). -
4400: protocol error (a frame the room does not accept), or reasonhello_timeout: the connection sent nohellowithin 15 seconds. It points to a bug in a client, so it is not retried. The SDKs say hello at once. 4009with reasonreplaced: a newer connection took over.
These the SDK handles by itself:
4001: the token or the connection lifetime ran out. New token, reconnect.-
4008and4009over the viewer cap: wait at least 30 seconds (longer if the room says so), at most 5 times in a row, then stop. A4008that saysretry_afternever stops the room, on any SDK: the wait grows with each try, up to 5 minutes. -
1011,1006,1012and lost connections: back off (a random wait that grows to 30 seconds and is never under half a second), reconnect and catch up. After 12 failed attempts in a row (about 3.5 minutes at the longest) the SDK stops: the room state isclosedwith the reasonretries_exhausted. Show your own "connection lost" message and callreconnectNow()(webroom.reconnectNow(); the other SDKs do it when the app returns to the foreground) to start again with a fresh budget.
reconnectNow() after retries_exhausted or 4011
starts again at once, asking for a token first. During an ordinary back-off wait it skips
the wait. It never skips a 4008 or 4009 wait: the platform said
when to come back. It does nothing after a final close (4003,
4004, 4010, 4400, 4009
replaced) or after leave().
A room rechecks its open connections every few minutes (within about 5 minutes 30 seconds): if the key that minted a token was revoked, the project is scheduled for deletion
or switched off for rooms, or the live environment was suspended, the connection is
closed with 4003 (key_invalid), 4010 or
4011, with a generic reason. Revoking a single user's tokens still applies
to new joins only.
Suspension: 4011 (reason
project_suspended) closes a live join, and open live connections, while the
project's live environment is suspended. The SDKs do not retry it: the room state is closed with code 4011. Your app decides: call reconnectNow() when the suspension is over, or stop the room itself (room.leave() on web).
Privacy: what viewers see
Everyone in the room receives, for each gift:
- the gift: id, name, price and picture, and the quantity;
-
the sender's user id and display name (the token's
name), and the receiver's user id; - the time of the send.
Never sent to a room: balances, the gift message, metadata or
reference. Totals go out only while a battle round exists: the score of each
competing receiver (value and gift count). Use opaque user ids (not phone numbers, emails or
usernames) and room ids that do not name people, because every viewer sees them.
Display names come from your server and are shown to other people as they are. You are responsible for what they say: check names before you put them in tokens and act on reports from your users. See the Terms and the Privacy Policy.
Rooms store no gift events. Gift events stay in memory for about 15 seconds so a reconnecting viewer can catch up. During a battle the room keeps the round's scores (receiver ids and totals) until you start a new round or remove it. Our logs never contain room ids, user ids or display names.