Guide
How to add virtual gifts to Sendbird chat
Your app already chats with Sendbird. 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 Sendbird 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 (group channels: direct messages, group chats,
communities): the
gift.sentwebhook reaches your server, which posts an admin message into the Sendbird 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 in open channels, 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 Sendbird, 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 URL 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.url}`, // "chat:sendbird_group_channel_4071_9b2e..."
effects,
onTopUp: openCoinStore,
});
2. Server: post an admin message on gift.sent
Verify the signature on the raw body first, with the verify function from
Webhooks. Then post with Sendbird's server API, the Platform
API, using the API token from the Sendbird dashboard. dedup_id is the event
id and handled event ids are kept, so a repeated delivery never posts twice.
import express from "express";
const SENDBIRD = `https://api-${process.env.SENDBIRD_APP_ID}.sendbird.com/v3`;
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 channelUrl = gift.reference.slice("chat:".length);
const sender = await displayName(gift.sender); // from your own users table
const times = gift.quantity > 1 ? ` x${gift.quantity}` : "";
const r = await fetch(`${SENDBIRD}/group_channels/${encodeURIComponent(channelUrl)}/messages`, {
method: "POST",
headers: { "Api-Token": process.env.SENDBIRD_API_TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({
message_type: "ADMM",
message: `${sender} sent ${gift.gift.name}${times}`,
custom_type: "gift",
data: JSON.stringify({ gift_id: gift.gift.id, gift_icon: gift.gift.icon_url, quantity: gift.quantity }),
dedup_id: event.id,
}),
});
if (!r.ok) 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. -
Sendbird's UIKits draw admin messages as a centred line in the message list. To show the
gift picture, render messages with
customTypegiftwith your own message view and read the picture fromdata. - Only your server posts gift rows: admin messages come from the Platform API, never from a user's app, so nobody fakes a gift.
- When Sendbird 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 open channel's messages go into the same list.
import { GiftEffectLayer, createGiftTray, joinRoom } from "@giftingstack/sdk/web";
import { OpenChannelHandler } from "@sendbird/chat/openChannel";
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 Sendbird, into the same list
sb.openChannel.addOpenChannelHandler("live-chat", new OpenChannelHandler({
onMessageReceived: (channel, message) => {
if (channel.url === liveChannel.url) chatList.append(renderMessage(message));
},
}));
// leaving the stream: room.leave(); tray.destroy(); sb.openChannel.removeOpenChannelHandler("live-chat");
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 Sendbird'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.
- Stream Chat and Firebase versions of this guide.
- llms-full.txt: the complete integration guide for AI coding assistants.