GiftingStack
Sign in Get started

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

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);
});

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

Colours, texts and motion: Customize. Rooms, tokens and limits: Gift rooms.

Next steps