Build
Chat: Realtime Messaging for Rooms and Live Streams
Add realtime chat with VoltKit RTC: messages in every room, chat-only connections for large audiences, direct messages, moderation and history.
Every room has chat the moment it exists, and a chat channel can also exist with no room at all. There is nothing to switch on.
A channel shares its name with the room of the same name. The people in room show-42 and the people who are only in the chat for show-42 are in one conversation.
Two ways in
People in a room already have chat. It runs over the connection they already hold for video. No second connection and no second token.
People who are only reading along get a chat-only connection. It carries messages and nothing else, so it suits an audience that is watching a broadcast rather than sitting in a room. Mint a chat token from your backend:
curl -X POST "$VOLTKIT_RTC_URL/v1/chat/show-42/tokens" \
-H "X-API-Key: $VOLTKIT_RTC_SECRET" \
-H "Content-Type: application/json" \
-d '{"identity":"user-99","role":"viewer"}'
{
"token": "eyJhbGciOi...",
"wsUrl": "wss://your-api-base-url/ws/chat",
"role": "viewer",
"identity": "user-99",
"channel": "show-42",
"expiresAt": 1755600600
}
A chat token cannot join a room, and a room token cannot open a chat-only connection. Admitting someone to chat can never admit them to video.
From the browser
import { ChatClient } from "@voltkit-rtc/client";
const chat = new ChatClient(wsUrl, token);
chat.on("message", (m) => appendToList(m)); // includes your own
chat.on("messageDeleted", ({ id }) => removeFromList(id));
chat.on("chatMuted", ({ identity, muted }) => lockComposer(identity, muted));
chat.on("typing", ({ identity, typing }) => showTyping(identity, typing));
const { messages, members } = await chat.connect(); // recent history comes with the join
renderAll(messages);
await chat.sendMessage("hello");
Inside a room, the same methods and events are on StreamClient, so a host needs no separate chat client.
Messages
{
"id": "1755600000000-0",
"channel": "show-42",
"from": "user-7",
"type": "text",
"text": "hello",
"metadata": { "replyTo": "1755599990000-0" },
"clientMsgId": "c-1",
"ts": 1755600000000
}
| Field | Notes |
|---|---|
id | Increases within a channel. The oldest id you hold is your paging cursor. |
type | text unless you say otherwise. gift, reaction, system: any short lowercase name is yours to define. |
text | Up to 4096 bytes |
metadata | Up to 4 KB of JSON that the service never reads |
to | Set on a direct message |
clientMsgId | Echoed back, to match a message you drew optimistically with the one that was accepted |
A message needs text or metadata. A gift or a sticker has no text and is still a message.
Direct messages
await chat.sendMessage("can you hear me?", { to: "user-7" });
A direct message reaches its recipient and the sender's own devices, and nobody else. It is absent from everyone else's history.
Reactions that are not stored
await chat.sendMessage("", { type: "reaction", metadata: { emoji: "heart" }, ephemeral: true });
An ephemeral message is delivered and then forgotten. Use it for hearts and reactions that are worth showing and not worth replaying to everyone who joins later.
Typing indicators
await chat.setTyping(true);
Safe to call on every keystroke: the service limits it to one notice a second.
History
A chat-only connection receives the most recent 50 messages when it joins. Page further back with the oldest id you hold:
const older = await chat.getMessages({ before: messages[0].id, limit: 50 });
Each channel keeps its most recent 500 messages. History is cleared when the channel's room closes, or after 24 hours of silence for a channel with no room. This is a live chat, not an archive. To keep messages, take them by webhook.
Moderation
| Action | Who | How |
|---|---|---|
| Mute or unmute someone | host, moderator | chat.muteChat(identity, true) |
| Delete any message | host, moderator | chat.deleteMessage(id) |
| Delete your own message | Anyone | chat.deleteMessage(id) |
A mute is held for the channel, not for the connection, so reconnecting does not undo it. A muted user can still read.
From your backend
Post a system notice, read history or moderate without holding a connection:
# Announce something
curl -X POST "$VOLTKIT_RTC_URL/v1/chat/show-42/messages" \
-H "X-API-Key: $VOLTKIT_RTC_SECRET" \
-H "Content-Type: application/json" \
-d '{"text":"user-7 sent a rose","type":"gift","metadata":{"giftId":12}}'
# How many are here
curl "$VOLTKIT_RTC_URL/v1/chat/show-42" -H "X-API-Key: $VOLTKIT_RTC_SECRET"
# Mute someone
curl -X PUT "$VOLTKIT_RTC_URL/v1/chat/show-42/mutes/user-99" -H "X-API-Key: $VOLTKIT_RTC_SECRET"
The member count covers everyone connected to the channel, in the room or only in its chat. Beside a broadcast, it is your viewer count.
Limits
| Limit | Value |
|---|---|
| Text per message | 4096 bytes |
| Metadata per message | 4 KB |
| Messages per connection | 5 a second, with a burst of 20 |
| History per channel | 500 messages |
Past the rate limit, sending answers rate-limited.
Cost
Chat is counted in your usage and not charged. It does not use your hours.
Last updated . Something unclear or wrong? Tell us.