Guide
How to add virtual gifts to Stream Chat
Your app already chats with Stream. GiftingStack adds the gifts: coins, the gift tray, the effects and the ledger. GiftingStack carries no chat of its own, so every gift lands in your Stream channel, next to the messages your users already read. This guide shows both ways to get it there, and where the gift button, the tray and the effects go on the screen.
Two ways a gift reaches the chat
-
From your server (direct messages, group chats, community channels):
the
gift.sentwebhook reaches your server, which posts a system message into the Stream channel. The gift becomes part of the channel's history, and people who were offline see it later. - In the app, in a live room (live streams, audio rooms, watch parties): the gift room tells every viewer about every gift in under a second, and the app adds a gift row to the chat list it already draws. Nothing is stored in Stream, which keeps a busy stream's channel free of thousands of gift messages.
Pick one per screen, so a gift never shows twice. Both start from the same setup: a project, a gift catalog and a client token (quickstart).
1. Send the gift with the channel as its reference
For the server path, the webhook has to know which channel the gift belongs to. Give the
tray the channel's cid as reference (your own context id, up to
128 characters); it comes back in the gift.sent event.
const tray = createGiftTray({
container: screen,
client,
receiver: otherMemberId, // who gets the gift (your user id)
reference: `chat:${channel.cid}`, // "chat:messaging:ava-mateo"
effects,
onTopUp: openCoinStore,
});
2. Server: post a system message on gift.sent
Verify the signature on the raw body first, with the verify function from
Webhooks. Then post with Stream's server SDK (npm i @stream-io/node-sdk). The message id is the event id, and handled event ids are kept, so a repeated delivery
never posts twice.
import express from "express";
import { StreamClient } from "@stream-io/node-sdk";
const stream = new StreamClient(process.env.STREAM_API_KEY, process.env.STREAM_API_SECRET);
// once, at setup: the user that posts gift messages
// await stream.upsertUsers([{ id: "gifts-bot", name: "Gifts" }]);
app.post("/webhooks/giftingstack", express.text({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("GiftingStack-Signature") ?? "", process.env.GS_WEBHOOK_SECRET))
return res.sendStatus(400);
const event = JSON.parse(req.body);
const gift = event.data;
if (event.type !== "gift.sent" || !gift.reference?.startsWith("chat:")) return res.sendStatus(200);
if (await handled.has(event.id)) return res.sendStatus(200); // your store of handled ids
const [, type, id] = gift.reference.split(":");
const sender = await displayName(gift.sender); // from your own users table
const times = gift.quantity > 1 ? ` x${gift.quantity}` : "";
try {
await stream.chat.channel(type, id).sendMessage({
message: {
id: event.id,
type: "system",
user_id: "gifts-bot",
text: `${sender} sent ${gift.gift.name}${times}`,
custom: { gift_id: gift.gift.id, gift_icon: gift.gift.icon_url, quantity: gift.quantity },
},
});
} catch {
return res.sendStatus(500); // delivered again later
}
await handled.add(event.id);
res.sendStatus(200);
});
-
The text is built from the sender's name, the gift and the quantity only. The gift's
messageis never copied into a channel. -
Stream's UI kits draw system messages as a centred line in the message list. To show the
gift picture, render messages that carry
gift_idwith your own message component. - Only your server posts gift rows. A client needs Stream's Create System Message permission to post a system message: keep it off for your users' roles, so nobody fakes a gift.
- When Stream fails, the handler answers 500 and GiftingStack delivers the event again later. Answer within 10 seconds: when posting can take longer, queue it and answer first.
3. App: a live gift row from the gift room
In a live stream, every viewer joins the gift room. The room's onEvent hands
over each gift sent into it by someone else; the tray's onSent hands over the
viewer's own. Both carry the gift transaction id, so a gift replayed after a reconnect is
not added again. The Stream channel's messages go into the same list.
import { GiftEffectLayer, createGiftTray, joinRoom } from "@giftingstack/sdk/web";
const shown = new Set();
function addGiftRow(tx, who, giftName, icon, quantity) {
if (shown.has(tx)) return;
shown.add(tx);
chatList.append(renderGiftRow({ who, giftName, icon, quantity })); // your row component
}
const effects = new GiftEffectLayer(videoBox); // effects play over the video
const room = joinRoom({
room: "live_123",
getToken,
client,
effects,
onEvent: (e) => addGiftRow(e.tx, e.senderName ?? "Someone", e.gift.name, e.gift.icon, e.quantity),
});
const tray = createGiftTray({
container: screen,
client,
receiver: hostId,
roomId: "live_123",
effects,
anchor: giftButton,
collapseOnSelect: true,
scrim: true,
onTopUp: openCoinStore,
onSent: (t) => addGiftRow(t.id, "You", t.gift.name, t.gift.icon_url, t.quantity),
});
giftButton.onclick = () => tray.toggle();
// the stream's chat, from Stream, into the same list
const listener = channel.on("message.new", (event) => chatList.append(renderMessage(event.message)));
// leaving the stream: room.leave(); tray.destroy(); listener.unsubscribe();
React Native: the same two callbacks on the hooks.
const room = useGiftRoom({
room: "live_123", getToken, client, effects,
onEvent: (e) => addGiftRow(e.tx, e.senderName ?? "Someone", e.gift.name, e.gift.icon, e.quantity),
});
const tray = useGiftTray({
client, receiver: hostId, roomId: "live_123", effects,
onSent: (t) => addGiftRow(t.id, "You", t.gift.name, t.gift.icon_url, t.quantity),
});
Flutter GiftRoom(onEvent: ...) with
GiftTrayNotifier(onSent: ...), Android
rememberGiftRoom(onEvent = ...) with
rememberGiftTrayHolder(onSent = ...), iOS
GiftRoomModel(..., onEvent: ...) with
GiftTrayModel(..., onSent: ...). Every room event has tx,
senderName, gift.name and quantity. The room
carries gift events only: no message text, no chat.
4. The screen layout
-
Gift button next to the chat input. Put it beside Stream's message
input; one tap calls
tray.toggle(). -
The tray as a bottom sheet. On phones it slides up over the lower part
of the screen.
collapseOnSelect: trueshrinks it to the picked gift and Send, so the stream and the chat stay in sight;scrim: truedims the page behind a tall sheet, and a tap beside it lowers or closes it. On wide screens,anchor: giftButtonmakes it a popover next to the button (web). - Effects over the video. Give the effect layer the video container, not the chat: big gifts play full size over the stream, and the chat list stays readable.
Colours, texts and motion: Customize. Rooms, tokens and limits: Gift rooms.
Next steps
- Quickstart: your first gift in minutes.
- Webhooks: events, signatures and retries.
- Sendbird and Firebase versions of this guide.
- llms-full.txt: the complete integration guide for AI coding assistants.