Skip to content

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
}
FieldNotes
idIncreases within a channel. The oldest id you hold is your paging cursor.
typetext unless you say otherwise. gift, reaction, system: any short lowercase name is yours to define.
textUp to 4096 bytes
metadataUp to 4 KB of JSON that the service never reads
toSet on a direct message
clientMsgIdEchoed 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

ActionWhoHow
Mute or unmute someonehost, moderatorchat.muteChat(identity, true)
Delete any messagehost, moderatorchat.deleteMessage(id)
Delete your own messageAnyonechat.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

LimitValue
Text per message4096 bytes
Metadata per message4 KB
Messages per connection5 a second, with a burst of 20
History per channel500 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.