Reference
VoltKit RTC API Reference
Every VoltKit RTC endpoint with its request, response, errors and the SDK call that wraps it: rooms, tokens, chat, broadcast, usage and realtime.
The API has two parts. Your backend calls the REST API with an API secret. Your clients connect to the realtime API with a token your backend minted.
Every REST call below is shown three ways: the HTTP request, the reply, and the SDK call that makes it for you. The SDK lines use the Node.js server SDK; nothing requires it, and any HTTP client in any language works.
Making a request
All REST requests go to your API base URL (on your project's Credentials tab) and are authenticated with your API secret. Bodies and replies are JSON.
curl -X POST "$VOLTKIT_RTC_URL/v1/rooms" \
-H "X-API-Key: $VOLTKIT_RTC_SECRET" \
-H "Content-Type: application/json" \
-d '{"roomId":"show-42","hostIdentity":"user-7"}'
const { StreamClient } = require("@voltkit-rtc/server");
const rtc = new StreamClient({ baseUrl: process.env.VOLTKIT_RTC_URL, apiKey: process.env.VOLTKIT_RTC_SECRET });
Authorization: Bearer sk_... is accepted as well as X-API-Key.
Times named createdAt, startedAt and ts are milliseconds since the epoch. expiresAt is seconds.
When a request fails
An error is JSON with a machine-readable error and a readable message. Decide on error, show or log message.
{ "error": "room-exists", "message": "a room with that id already exists" }
Any authenticated call can also fail with 401 unauthorized, 403 tenant-suspended, 429 rate-limited or 500 internal. Those are not repeated under each endpoint. Every code, what it means and what to do about it is on the Errors page.
With the SDK, a failure is a StreamError carrying the same code:
try {
await rtc.createRoom({ roomId: "show-42", hostIdentity: "user-7" });
} catch (err) {
console.log(err.code, err.status, err.retryable); // "room-exists" 409 false
}
REST: rooms
Create a room
| HTTP | POST /v1/rooms |
| SDK | rtc.createRoom({ roomId, hostIdentity, metadata }) |
Creates a room and returns a token for its host. roomId is yours to choose (letters, digits, ., -, _, up to 128) and is generated if you leave it out. metadata is any JSON you want every participant to receive when they join.
Request:
{
"roomId": "show-42",
"hostIdentity": "user-7",
"metadata": {
"layout": "grid"
}
}
Response, 201 Created:
{
"roomId": "show-42",
"tenantId": "vk_3f9c1a7b2d4e6f80",
"originNode": "node-1",
"state": "live",
"hostIdentity": "user-7",
"createdAt": 1791048388384,
"nodes": [
"node-1"
],
"participants": 0,
"metadata": {
"layout": "grid"
},
"hostToken": "eyJhbGciOi...",
"wsUrl": "wss://rtc.example.com/ws",
"expiresAt": 1791048988
}
Errors:
Status and error | When |
|---|---|
400 bad-request | hostIdentity is missing, or roomId has characters that are not allowed |
402 quota-exhausted | The project has nothing left. Also daily-limit-reached, monthly-limit-reached. Anything live is warned, then ended. |
409 room-exists | You already have a live room with that id. Use PUT (ensureRoom) if that is fine. |
429 room-quota | The project is at its limit of rooms running at once |
503 capacity-exhausted, no-nodes | No capacity for a new room right now. Safe to retry shortly. |
Create or get a room
| HTTP | PUT /v1/rooms/{roomId} |
| SDK | rtc.ensureRoom({ roomId, hostIdentity, metadata }) |
Creates the room, or returns the live one with a fresh host token if it already exists. This is the call to use when a host may be reconnecting. metadata is only applied when the room is created.
Request:
{
"hostIdentity": "user-7"
}
Response, 201 Created, or 200 OK if it already existed:
{
"roomId": "show-42",
"tenantId": "vk_3f9c1a7b2d4e6f80",
"originNode": "node-1",
"state": "live",
"hostIdentity": "user-7",
"createdAt": 1791048388384,
"nodes": [
"node-1"
],
"participants": 0,
"metadata": {
"layout": "grid"
},
"hostToken": "eyJhbGciOi...",
"wsUrl": "wss://rtc.example.com/ws",
"expiresAt": 1791048988
}
Errors:
Status and error | When |
|---|---|
400 bad-request | hostIdentity is missing |
402 quota-exhausted | Out of minutes (or daily-limit-reached, monthly-limit-reached) |
429 room-quota | At the limit of rooms running at once |
503 capacity-exhausted, no-nodes | No capacity right now |
Mint a token
| HTTP | POST /v1/rooms/{roomId}/tokens |
| SDK | rtc.createToken({ roomId, identity, role }) |
Lets one user into the room. role is host, cohost, moderator or viewer, and defaults to viewer. Your backend decides the role; the service enforces it. Hand token and wsUrl to the client SDK.
Request:
{
"identity": "user-99",
"role": "viewer"
}
Response, 200 OK:
{
"token": "eyJhbGciOi...",
"wsUrl": "wss://rtc.example.com/ws",
"role": "viewer",
"identity": "user-99",
"roomId": "show-42",
"expiresAt": 1791048988
}
Errors:
Status and error | When |
|---|---|
400 bad-request | identity is missing |
400 bad-role | role is not one of the four |
402 quota-exhausted | Out of minutes (or daily-limit-reached, monthly-limit-reached) |
404 no-room | The room does not exist or has ended |
429 viewer-quota | The room is at its viewer limit |
Get a room
| HTTP | GET /v1/rooms/{roomId} |
| SDK | rtc.getRoom(roomId) |
One room with who is in it and what they are publishing. Poll this for a live viewer count rather than counting webhook events.
Response, 200 OK:
{
"room": {
"roomId": "show-42",
"tenantId": "vk_3f9c1a7b2d4e6f80",
"originNode": "node-1",
"state": "live",
"hostIdentity": "user-7",
"createdAt": 1791048388384,
"nodes": [
"node-1"
],
"participants": 0,
"metadata": {
"layout": "grid"
}
},
"participants": [
{
"identity": "user-7",
"role": "host",
"handRaised": false,
"muted": false
}
],
"producers": [
{
"producerId": "5d0c...",
"identity": "user-7",
"kind": "audio",
"source": "mic",
"paused": false
}
]
}
Errors:
Status and error | When |
|---|---|
404 no-room | The room does not exist, has ended, or is not yours |
List rooms
| HTTP | GET /v1/rooms |
| SDK | rtc.listRooms() |
Every room your project has live right now.
Response, 200 OK:
{
"rooms": [
{
"roomId": "show-42",
"tenantId": "vk_3f9c1a7b2d4e6f80",
"originNode": "node-1",
"state": "live",
"hostIdentity": "user-7",
"createdAt": 1791048388384,
"nodes": [
"node-1"
],
"participants": 0,
"metadata": {
"layout": "grid"
}
}
]
}
List participants
| HTTP | GET /v1/rooms/{roomId}/participants |
| SDK | rtc.listParticipants(roomId) |
Who is connected, at this moment.
Response, 200 OK:
{
"participants": [
{
"identity": "user-7",
"role": "host",
"handRaised": false,
"muted": false,
"metadata": {
"name": "Asha"
}
}
]
}
Errors:
Status and error | When |
|---|---|
404 no-room | The room does not exist or has ended |
End a room
| HTTP | DELETE /v1/rooms/{roomId} |
| SDK | rtc.deleteRoom(roomId) |
Ends the room for everyone and clears its chat. Clients receive roomClosed.
Response, 200 OK:
{
"closed": true
}
Errors:
Status and error | When |
|---|---|
404 no-room | Already ended, or never existed |
REST: chat
Mint a chat token
| HTTP | POST /v1/chat/{channel}/tokens |
| SDK | rtc.createChatToken({ channel, identity, role }) |
Lets one user into a channel's chat with no media rights. A channel has the same name as the room it belongs to; a channel with no room is chat on its own. Someone already in the room does not need this: chat is on their room connection.
Request:
{
"identity": "user-99",
"role": "viewer"
}
Response, 200 OK:
{
"token": "eyJhbGciOi...",
"wsUrl": "wss://rtc.example.com/ws/chat",
"role": "viewer",
"identity": "user-99",
"channel": "show-42",
"expiresAt": 1791048988
}
Errors:
Status and error | When |
|---|---|
400 bad-request | identity is missing, or the channel name has characters that are not allowed |
400 bad-role | Unknown role |
402 quota-exhausted | Out of minutes (or daily-limit-reached, monthly-limit-reached) |
429 viewer-quota | The channel is at its member limit |
Send a message
| HTTP | POST /v1/chat/{channel}/messages |
| SDK | rtc.sendMessage({ channel, text, type, from, to, metadata, ephemeral }) |
Posts a message from your backend: a system notice, a gift, a moderation warning. from defaults to system; you may also speak as one of your own users. to makes it a direct message. ephemeral: true delivers without storing. A message needs text or metadata.
Request:
{
"text": "The show starts in 5 minutes",
"type": "system"
}
Response, 201 Created:
{
"id": "1791048388390-0",
"channel": "show-42",
"from": "system",
"type": "system",
"text": "The show starts in 5 minutes",
"ts": 1791048388390
}
Errors:
Status and error | When |
|---|---|
400 bad-request | No text or metadata, text too long, or a direct message addressed to its own sender |
402 quota-exhausted | Out of minutes (or daily-limit-reached, monthly-limit-reached) |
Read history
| HTTP | GET /v1/chat/{channel}/messages?limit=50&before={id} |
| SDK | rtc.listMessages(channel, { limit, before }) |
Messages oldest first. Pass the oldest id you hold as before to page backwards. Your backend sees direct messages too; clients only ever see their own.
Response, 200 OK:
{
"messages": [
{
"id": "1791048388390-0",
"channel": "show-42",
"from": "system",
"type": "system",
"text": "The show starts in 5 minutes",
"ts": 1791048388390
}
]
}
Errors:
Status and error | When |
|---|---|
400 bad-request | before is not a message id |
Delete a message
| HTTP | DELETE /v1/chat/{channel}/messages/{messageId} |
| SDK | rtc.deleteMessage(channel, messageId) |
Removes it from history and tells every connected client.
Response, 200 OK:
{
"deleted": true
}
Errors:
Status and error | When |
|---|---|
404 no-message | No such message in this channel |
Mute or unmute someone
| HTTP | PUT or DELETE /v1/chat/{channel}/mutes/{identity} |
| SDK | rtc.muteChat(channel, identity) / rtc.unmuteChat(channel, identity) |
A muted identity cannot send. The mute survives them reconnecting, and lasts until you remove it or the room ends.
Response, 200 OK:
{
"identity": "user-13",
"muted": true
}
List mutes and members
| HTTP | GET /v1/chat/{channel}/mutes · GET /v1/chat/{channel} |
| SDK | rtc.listChatMutes(channel) / rtc.getChatInfo(channel) |
Who is muted, and how many distinct identities are connected to the channel.
Response, 200 OK:
{
"muted": [
"user-13"
]
}
REST: broadcast
Create a stream
| HTTP | POST /v1/streams |
| SDK | rtc.createStream({ streamId, playback, metadata }) |
Creates a broadcast stream. playback is public (anyone with the URL can watch) or token (each viewer needs a URL you mint). The publish block is returned only here: whipUrl carries the publish token, so treat it like a password.
Request:
{
"streamId": "show-42",
"playback": "public"
}
Response, 201 Created:
{
"streamId": "show-42",
"tenantId": "vk_3f9c1a7b2d4e6f80",
"streamKey": "9f2c41d07ab35e886c01d4f2",
"state": "idle",
"playback": "public",
"createdAt": 1791048388384,
"viewers": 0,
"peakViewers": 0,
"playbackUrls": {
"llhls": "https://cdn.example.com/app/9f2c41d07ab35e886c01d4f2/llhls.m3u8",
"webrtc": "wss://live.example.com/app/9f2c41d07ab35e886c01d4f2"
},
"publish": {
"whipUrl": "https://live.example.com/app/9f2c41d07ab35e886c01d4f2?direction=whip&token=eyJ...",
"token": "eyJhbGciOi...",
"expiresAt": 1791052000
}
}
Errors:
Status and error | When |
|---|---|
400 bad-request | streamId is missing or has characters that are not allowed |
402 quota-exhausted | Out of minutes (or daily-limit-reached, monthly-limit-reached) |
404 broadcast-disabled | Broadcast is not switched on for this service |
409 stream-exists | You already have a stream with that id |
429 stream-quota | At the limit of streams |
Mint a publish or playback URL
| HTTP | POST /v1/streams/{streamId}/tokens |
| SDK | rtc.createStreamToken({ streamId, action, identity }) |
action is publish (a fresh whipUrl for the broadcaster) or play (playback URLs for one viewer of a token stream).
Request:
{
"action": "play",
"identity": "user-99"
}
Response, 200 OK:
{
"streamId": "show-42",
"action": "play",
"token": "eyJhbGciOi...",
"expiresAt": 1791052000,
"llhls": "https://cdn.example.com/app/9f2c41d07ab35e886c01d4f2/llhls.m3u8?token=eyJ...",
"webrtc": "wss://live.example.com/app/9f2c41d07ab35e886c01d4f2?token=eyJ..."
}
Errors:
Status and error | When |
|---|---|
400 bad-request | action is not publish or play |
402 quota-exhausted | Out of minutes (or daily-limit-reached, monthly-limit-reached) |
404 no-stream | No such stream |
Get, list, update, delete
| HTTP | GET /v1/streams/{streamId} · GET /v1/streams · PATCH /v1/streams/{streamId} · DELETE /v1/streams/{streamId} |
| SDK | rtc.getStream(id) / rtc.listStreams() / rtc.updateStream(id, { playback, metadata }) / rtc.deleteStream(id) |
state is idle or live. viewers counts playback sessions the media server serves itself; an audience behind a CDN is not in that number. Deleting a stream takes it off air and stops its publish URL working.
Response, 200 OK:
{
"streamId": "show-42",
"tenantId": "vk_3f9c1a7b2d4e6f80",
"streamKey": "9f2c41d07ab35e886c01d4f2",
"state": "live",
"playback": "public",
"createdAt": 1791048388384,
"viewers": 37,
"peakViewers": 52,
"playbackUrls": {
"llhls": "https://cdn.example.com/app/9f2c41d07ab35e886c01d4f2/llhls.m3u8",
"webrtc": "wss://live.example.com/app/9f2c41d07ab35e886c01d4f2"
},
"startedAt": 1791048400000
}
Errors:
Status and error | When |
|---|---|
404 no-stream | No such stream, or not yours |
REST: usage
Usage so far
| HTTP | GET /v1/usage |
| SDK | rtc.getUsage() |
Today, this month and all time, with the balance. minutesRemaining is -1 on an unlimited plan; on a metered project that uses the account balance, it includes what the project can still use from it. allowance breaks the figure down; see Reading your usage.
Response, 200 OK:
{
"tenantId": "vk_3f9c1a7b2d4e6f80",
"plan": "metered",
"status": "active",
"minutesLimit": 6000,
"usedMinutes": 90,
"minutesRemaining": 5910,
"today": {
"participantSeconds": 5400,
"publisherSeconds": 3600,
"roomSeconds": 3600,
"roomsCreated": 2,
"peakParticipants": 14,
"chatMessages": 212,
"chatConnectionSeconds": 9000,
"broadcastSeconds": 0,
"broadcastViewerSeconds": 0,
"streamsStarted": 0
},
"month": {
"participantSeconds": 5400,
"publisherSeconds": 3600,
"roomSeconds": 3600,
"roomsCreated": 2,
"peakParticipants": 14,
"chatMessages": 212,
"chatConnectionSeconds": 9000,
"broadcastSeconds": 0,
"broadcastViewerSeconds": 0,
"streamsStarted": 0
},
"lifetime": {
"participantSeconds": 5400,
"publisherSeconds": 3600,
"roomSeconds": 3600,
"roomsCreated": 2,
"peakParticipants": 14,
"chatMessages": 212,
"chatConnectionSeconds": 9000,
"broadcastSeconds": 0,
"broadcastViewerSeconds": 0,
"streamsStarted": 0
}
}
Usage by day
| HTTP | GET /v1/usage/series?days=30 |
| SDK | rtc.getUsageSeries(days) |
One entry per day, oldest first, ending today. Days with no usage are present and zero. days is capped at 90.
Response, 200 OK:
{
"tenantId": "vk_3f9c1a7b2d4e6f80",
"days": 2,
"series": [
{
"day": "2026-10-02",
"participantSeconds": 5400,
"publisherSeconds": 3600,
"roomSeconds": 3600,
"roomsCreated": 2,
"peakParticipants": 14,
"chatMessages": 212,
"chatConnectionSeconds": 9000,
"broadcastSeconds": 0,
"broadcastViewerSeconds": 0,
"streamsStarted": 0
},
{
"day": "2026-10-03",
"participantSeconds": 5400,
"publisherSeconds": 3600,
"roomSeconds": 3600,
"roomsCreated": 2,
"peakParticipants": 14,
"chatMessages": 212,
"chatConnectionSeconds": 9000,
"broadcastSeconds": 0,
"broadcastViewerSeconds": 0,
"streamsStarted": 0
}
]
}
Realtime: connecting
Clients connect to the wsUrl returned with their token:
| Connection | URL | Token from |
|---|---|---|
| Room: media and chat | wss://.../ws?token=... | POST /v1/rooms/{roomId}/tokens |
| Chat only | wss://.../ws/chat?token=... | POST /v1/chat/{channel}/tokens |
Messages are JSON. A request carries an id, which the reply repeats:
{ "id": 1, "method": "join", "params": { "token": "eyJ..." } }
{ "id": 1, "ok": true, "result": { "roomId": "show-42", "role": "viewer" } }
{ "id": 1, "ok": false, "error": { "code": "forbidden", "message": "role viewer may not publish" } }
The server also sends events, which have no id:
{ "event": "participantJoined", "data": { "identity": "user-99", "role": "viewer" } }
join must be the first request, within 15 seconds of connecting.
Realtime: room methods
| Method | Params | Who |
|---|---|---|
join | token, metadata | Everyone, first |
leave | Everyone | |
getRoomState | Everyone. Unlike join, its producers include your own; skip them when subscribing | |
raiseHand | metadata | Everyone |
lowerHand | identity | Yourself; host/moderator may handle another hand. Role requests use metadata such as { "requestRole": "cohost" } |
setMetadata | metadata | Yourself |
promote | identity, role | Host grants other roles. Non-hosts may step themselves down to viewer. |
forceMute | identity, kind, paused | Host may manage non-hosts; moderator may manage viewers only. Release permits the owner to turn their device back on. |
kick | identity, reason | Host may remove non-hosts; moderator may remove viewers only. Removed identities cannot reuse the old room token. |
setRoomMetadata | metadata, replace | host, moderator |
endRoom | host |
Media setup uses createTransport, connectTransport, produce, consume, resumeConsumer, pauseProducer, closeProducer and setPreferredLayers. The SDKs call these for you; if you are writing your own client, ask support for the protocol guide.
A publisher may have one producer per source, allowing camera, screen, microphone, music and screen audio together. videoLayers contains each spatial index and its width/height in produce, consume, room state and producer events. Joined videoPolicy defaults to 1080p/60 fps for every plan. Role, pending-hand and mute state survive reconnects; explicit operator restrictions still apply. See Video quality and Screen sharing.
Realtime: chat methods
Available on both connections.
| Method | Params | Notes |
|---|---|---|
sendMessage | text, type, to, metadata, clientMsgId, ephemeral | Needs text or metadata |
getMessages | limit, before | Oldest first |
deleteMessage | messageId | Your own, or any as a moderator |
muteChat | identity, muted | host, moderator |
setTyping | typing | Limited to one a second |
getChatState | Member count, and whether you are muted |
Realtime: events
| Event | Data | When |
|---|---|---|
participantJoined | identity, role, metadata | Someone joined |
participantLeft | identity, reason | Someone left or was removed |
newProducer | producerId, identity, kind, source, videoLayers | A track is available. Sources: mic, music, camera, screen, screen-audio. Video layers describe actual dimensions for receive presets |
producerClosed | producerId, identity | A track ended |
producerPaused | producerId, identity, kind, paused | Someone paused or resumed a track: muted their mic, switched their music off |
roleChanged | identity, role, by | A promotion or demotion |
handRaised | identity, handRaised, metadata, reason | Raised, withdrawn, declined or granted. The roster includes handMetadata while pending |
forceMuted | by, kind, paused | Your device was blocked or released |
participantMuted | identity, kind, muted, by | Roster moderation changed. SDK 0.5.0 normalizes wire muted to public paused |
participantUpdated | identity, metadata | Someone's metadata changed |
roomMetadataChanged | metadata, by | The room's metadata changed |
message | The message | A chat message, including your own |
messageDeleted | id, by | A message was removed |
chatMuted | identity, muted, by | Someone was muted in chat |
typing | identity, typing | Someone else is typing |
tokenRefreshed | token, role | A fresh token; use it if you reconnect |
mustReconnect | reason, token, retry | With retry: true, close the socket yourself and reconnect, with the new token if one is given (promoted, relay-lost, origin-lost, origin-moved). replaced-by-new-session has retry: false and is followed by close code 4001 |
kicked | by, reason | You were removed |
roomClosed | reason | The room ended. reason is closed, or why the project stopped: quota-exhausted, daily-limit-reached, monthly-limit-reached, suspended. The server then closes the connection (code 4003) |
usageLimit | reason, graceSeconds, endsAt | The project ran out; the session ends at endsAt unless it is topped up. Also sent on chat-only connections |
Realtime errors
A refused request is answered with ok: false and an error object holding code and message. The SDKs raise it as RpcError (rooms) or ChatRpcError (chat-only connections). The full list, with what to do about each, is on the Errors page.
code | Meaning |
|---|---|
no-token, bad-token | The token is missing, expired or not valid for this connection |
no-room, room-ended | The room does not exist or is over |
not-joined | A request was sent before join |
forbidden | Your role does not allow that |
no-capacity | No capacity to place you right now. Retry shortly. |
chat-muted | A moderator has muted you in this chat |
rate-limited | Too many chat messages. Slow down. |
no-message | That message does not exist, or is not yours to see |
bad-params | The request was malformed. The message says how. |
unknown-method | No such method on this connection |
Webhooks
Event types, payloads and signature verification are on the Webhooks page.
Last updated . Something unclear or wrong? Tell us.