SDKs
JavaScript SDK for the Browser
Use @voltkit-rtc/client in a web app: join a VoltKit RTC room, publish camera and microphone, add chat, and publish a live broadcast over WHIP.
@voltkit-rtc/client wraps the realtime protocol so your app deals in rooms, tracks and messages rather than transports and reconnection. It is one of five SDKs:
| SDK | Runs in | Install |
|---|---|---|
| JavaScript (this page) | Browsers | npm install @voltkit-rtc/client |
| Flutter | iOS and Android apps | flutter pub add voltkit_rtc |
| Node.js | Your backend | npm install @voltkit-rtc/server |
| Android (Kotlin) | Android apps | Preview, not released yet |
| iOS (Swift) | iPhone and iPad apps | Preview, not released yet |
All of them are MIT licensed. Nothing here is required: the REST API needs only HTTPS, and the realtime protocol is documented in the API reference.
Install
npm install @voltkit-rtc/client
It ships both ES modules and CommonJS, each with its own TypeScript types, so it works in TypeScript or plain JavaScript, with any bundler or framework:
import { StreamClient } from "@voltkit-rtc/client"; // ES modules / TypeScript
const { StreamClient } = require("@voltkit-rtc/client"); // CommonJS
Join a room
Your backend mints a token and sends the page token and wsUrl (see Authentication). The client turns those into a live session. The API secret never reaches the browser.
import { StreamClient } from "@voltkit-rtc/client";
const client = new StreamClient(wsUrl, token);
// Wire events before connecting: tracks that are already
// being published arrive during connect().
client.on("track", ({ identity, kind, track }) => {
const element = kind === "video" ? videoFor(identity) : audioFor(identity);
element.srcObject = new MediaStream([track]);
});
client.on("trackEnded", ({ identity }) => clearMedia(identity));
client.on("participantJoined", (p) => addToRoster(p));
client.on("participantLeft", ({ identity }) => removeFromRoster(identity));
client.on("closed", (reason) => leaveScreen(reason));
const joined = await client.connect();
console.log(joined.role, joined.participants);
on() returns a function that removes the listener.
Publish camera and microphone
Hosts and co-hosts publish:
const constraints = { video: { width: 1280, height: 720 }, audio: true }; // 720p
const media = await navigator.mediaDevices.getUserMedia(constraints);
await client.publish(media, constraints);
await client.setMicEnabled(false); // mute yourself
await client.setCamEnabled(false); // camera off
await client.unpublish(); // stop and release the devices
Video is sent at several quality levels at once (simulcast), so each viewer receives the one their connection can carry. A viewer cannot publish: that is enforced by the service, not by this package.
Video quality
Choose 1080p, 720p or 480p on every plan. SDK 0.5.0 defaults to 30 fps and supports 60 fps on capable devices. A project can have an explicit administrative restriction; client.videoPolicy describes it.
await client.publish(media, undefined, { quality: "480p" });
await client.setVideoQuality("720p"); // while live; resolves to the quality used
client.videoPolicy.maxQuality; // what this project may publish
publish() takes these options. The defaults suit phones and computers alike, so most apps pass only quality, if anything.
| Option | Values | Default | From |
|---|---|---|---|
quality | "1080p", "720p", "480p" | The highest the project allows | 0.3.0 |
frameRate | 30 or 60; only the top layer uses 60 | 30 | 0.5.0 |
layers | 1, 2, 3: the most copies (simulcast layers) to send | 2 on phones and tablets, 3 elsewhere | 0.4.0 |
codec | "auto", "vp8", "h264" | "auto": H.264 on phones and tablets that encode it in hardware, VP8 elsewhere | 0.4.0 |
degradationPreference | "maintain-resolution", "maintain-framerate", "balanced" | The browser's own | 0.4.0 |
layers. Each copy is a separate encode. When a busy phone shrinks its picture, the browser drops the top copy first; with two copies the full-size one survives. A picture never gets more copies than its size allows: three from 960×540, two from 480×270, otherwise one.codec. A forced"h264"that the browser cannot send falls back to VP8 and fireserror; publishing never fails because of the codec. Viewers need a browser that decodes the codec, so force"vp8"if yours may not decode H.264.degradationPreferenceis what the encoder gives up first when the device cannot keep up:"maintain-resolution"lowers the frame rate and keeps the picture sharp. Chrome also reads it from the camera track'scontentHint:"detail"keeps the resolution,"motion"the frame rate. Browsers that do not support it ignore it.
With SDK 0.5.0, 720p30 from a computer is 320×180, 640×360 and 1280×720 at 0.2, 0.8 and 3 Mbps: about 4 Mbps video upload, or 3.8 Mbps from a phone. 1080p30 uses 0.4, 2 and 6 Mbps (8.4 desktop / 8 phone); 480p30 uses 0.4 and 1.2 Mbps (1.6 total). The options are kept for a reconnect.
At 60 fps the top caps rise to 9 Mbps (1080p), 4.5 Mbps (720p) or 1.8 Mbps (480p), while smaller copies remain at 30 fps. Capture at 60 and pass { frameRate: 60 }. This uses roughly 1.5× the top layer data and twice the encoding work; phones can heat up and many front cameras only capture 30. Low camera fps or sustained device/upload limitations fall back to 30 and emit error with code: "frame-rate-fallback". Change it while live with await client.setVideoFrameRate(30) or 60; it returns the actual frame rate and preserves camera mute, microphone, music and screen sharing.
Video quality covers what each quality sends, data use and troubleshooting.
Swap the camera track
From version 0.4.0, replaceVideoTrack() changes what the camera publishes without unpublishing: from the camera to a canvas that draws effects on it, and back, or to another camera.
const drawn = canvas.captureStream().getVideoTracks()[0];
await client.replaceVideoTrack(drawn); // effects on: send the canvas
await client.replaceVideoTrack(cameraTrack); // effects off: send the camera as it is
When the new track has the same dimensions and copies as the old one, viewers see no pause; otherwise the camera producer is replaced, as setVideoQuality does, and a paused camera stays paused. Make the canvas the camera's own size, and draw a frame on it before you swap: the SDK reads the new track's size from the browser, which reports a canvas's new size only once it has had a frame of it. The swap then needs the same copies, and viewers see no pause. Sending the camera's own track whenever nothing is drawn saves a phone the canvas work and gives viewers the sharpest picture.
Your stream is left as it is: the client keeps its own stream with the new track and your microphone (client.local, announced by localStreamChanged), and a reconnect republishes that. The track you replaced is not stopped. replaceVideoTrack rejects when no camera is being published.
Stats and video health
From version 0.4.0 the client reports what video really goes out and comes in, and says when the device or the network holds the picture back.
const sent = await client.getVideoStats(); // your camera; null when not publishing one
// { codec: "VP8", quality: "720p", width: 1280, height: 720, fps: 30, bitrate: 2430000,
// qualityLimitation: "none", availableOutgoingBitrate: 4100000, layers: [ ... ] }
const got = await client.getReceiveStats(producerId); // a remote video, by its producerId from `track`
// { producerId, codec: "VP8", width: 1280, height: 720, fps: 30, bitrate: 2380000,
// packetsLost: 0, jitter: 0.004, freezeCount: 0 }
getVideoStats()describes the biggest copy going out at the top level, and every copy inlayers(rid,width,height,fps,bitrate,active,qualityLimitation).getReceiveStats()returns null for a video this client is not receiving.- Bitrates are bits per second, averaged since your previous call for the same video, so call every second or two. The first call reports 0.
qualityLimitationsays why the picture is smaller or slower than asked:cpu(this device is too busy to encode it),bandwidth(the upload is too slow) ornone. Not every browser reports it.
The videoLimited event turns that into a notice you can show the person publishing:
// e: { reason, width, height, fps, bitrate }, the numbers of the biggest copy going out
const off = client.on("videoLimited", (e) => {
if (e.reason === "cpu") showNotice("Your device is busy, so your video is softer. Turn off effects or try 480p.");
else if (e.reason === "bandwidth") showNotice("Your connection is slow, so viewers get a lighter picture.");
else if (e.reason === "none") hideNotice();
});
reason is cpu, bandwidth, other (held back for another reason) or none (it has cleared). The event fires when the reason changes and the new one has held for about 4 seconds, so brief spikes stay quiet, and it never starts with none. The client checks every 2 seconds, while it publishes a camera and something listens, or while 60 fps fallback monitoring is active, so call off() when you no longer show the notice.
What to tell people for each reason, and how to tell a device problem from a network one, is on Troubleshooting: the device is overloaded, the upload is too slow.
Small tiles
Each viewer receives the biggest copy of a video their connection can carry, about 3.1 Mbps at 720p including audio. A thumbnail or a row of co-host tiles does not need it. From version 0.4.0, ask for a smaller copy by the producerId from track:
client.on("track", ({ kind, producerId, track }) => {
if (kind !== "video") return;
showVideo(producerId, track);
if (isSmallTile(producerId)) client.setPreferredLayers(producerId, 0).catch(console.warn); // the smallest copy
});
await client.setPreferredLayers(producerId, 2); // the tile grew: the biggest copy again
Copy 0 is the smallest the publisher sends. At 720p a computer sends three copies (320×180, 640×360 and 1280×720) and a phone two (640×360 and 1280×720); asking for more than there are gives the biggest. The 640×360 copy needs about 0.9 Mbps including audio. An optional third argument also lowers the frame rate; without it, the full frame rate is kept. It is the most this viewer receives: a connection that cannot carry it still gets a smaller copy. The choice survives reconnects and is forgotten when the track ends, and setPreferredLayers rejects for a video this client is not receiving.
Receive quality menu
SDK 0.5.0 offers a simpler alternative to numeric layers:
await client.setReceiveQuality(producerId, "480p");
await client.setReceiveQuality(producerId, "auto"); // recommended; also clears a temporal limit
Offer Auto (recommended) / 1080p / 720p / 480p. Presets follow actual producer videoLayers dimensions: two-layer phones, portrait cameras and screen shares are mapped correctly. The SDK chooses the largest copy within the requested ceiling, or the smallest available if none fits. It never manufactures a missing resolution. A slow connection can still receive less. Preferences survive reconnects while that producer exists. Older publishers without layer metadata reject explicit presets with quality-unavailable; Auto remains available.
Screen sharing
Hosts and co-hosts can share a screen beside their camera in SDK 0.5.0:
client.on("screenShareStarted", ({ stream, mode }) => showLocalScreen(stream, mode));
client.on("screenShareEnded", ({ reason }) => hideLocalScreen(reason));
await client.publishScreen({ mode: "detail", audio: true }); // from a button click
await client.unpublishScreen(); // camera, mic and music continue
publishScreen returns the captured MediaStream; pass stream to use one you already captured. detail is 15 fps, 2.5 Mbps plus a 0.6 Mbps smaller copy; motion is 30 fps, 6 Mbps plus 0.6 Mbps. Neither enlarges the native picture. Shared audio, if granted, arrives separately as source: "screen-audio"; play it like music. Video uses source: "screen".
The browser's Stop sharing button ends both producers automatically. Reconnect restores a still-live screen without another permission prompt. unpublish() and close() stop all local media. Screen sharing covers permissions, modes, audio, mobile support and errors.
New in 0.7.0
@voltkit-rtc/client 0.7.0 is on npm (npm install @voltkit-rtc/client@0.7.0). Every 0.6 call works unchanged in it, and it works with older services too. It pairs with the VoltKit Wallet: project media settings, the billing mode and walletLow.
Audio quality
The microphone goes out as standard audio unless you ask for HD:
audioQuality | Sent as | Billed as, with the VoltKit Wallet |
|---|---|---|
"std" (default) | mono Opus, declared at 64 kbps, sent at up to 60 kbps, with silence suppression and error correction | standard audio |
"hd" | stereo Opus at up to 128 kbps | HD audio |
await client.publish(media, undefined, { audioQuality: "hd" });
await client.setAudioQuality("std"); // while live; resolves to the quality in use
client.audioQuality; // "std" | "hd"
The service bills what it can verify: HD only when the microphone declares more than 64 kbps or is measured above 72 kbps, which is why standard audio is sent a little under its 64 kbps. Switching re-creates the microphone's track on the server: listeners hear a gap of a fraction of a second, and a muted microphone stays muted. For HD, capture stereo with echo cancellation, noise suppression and automatic gain on, unless the source is headphones or a line-in. Music (publishMusic) is a separate 128 kbps stereo track and is not affected: it is free, allowed in a project limited to standard audio, and never changes the rate. In 0.6 the microphone was stereo with no cap.
Project media settings
A project's settings say what it may publish: audio and video on or off, the largest video quality, 30 or 60 fps, standard or HD audio (see Video quality). The join result carries them:
client.mediaPermissions;
// { audio: true, video: true, maxVideoQuality: "1080p", maxFrameRate: 60,
// maxVideoQuality60: "720p", audioQualityMax: "hd", maxVideoSubscriptions: 25, source: "media" }
client.billingMode; // "wallet" | "legacy", or null from an older service
Offer only these in your interface. The SDK never sends what the project refuses: a kind that is switched off is refused with media-disabled; HD audio, 60 fps or a 60 fps size the project does not allow goes out as the most it allows, with a warning on error carrying the server's code. A session billed from the wallet receives at most maxVideoSubscriptions videos at once; further ones wait. The codes are on Errors.
Network quality
client.on("networkQuality", ({ level, rttMs, lossPct, availableKbps }) => showBars(level));
About every 3 seconds, only while something listens and the session is connected, from the media connection's own statistics: rttMs is the round trip to the media server, lossPct the packets lost over the last interval in the worse direction, availableKbps the browser's bandwidth estimate. Each is null until known, and an unknown value does not lower the level.
level | Round trip under | and loss under |
|---|---|---|
excellent | 150 ms | 1 % |
good | 300 ms | 3 % |
poor | 500 ms | 8 % |
bad | anything worse |
Tokens
The service still pushes a fresh token before the one the client holds expires. If your backend controls token lifetimes, hand a new one over:
client.on("tokenExpiring", async ({ secondsLeft }) => {
const { token } = await fetch("/api/rtc/token", { method: "POST" }).then((r) => r.json());
await client.updateToken(token); // resolves to { expiresAt }
});
updateToken sends the token on the live connection, where it is checked like a join token: bad-token for one that would be refused, token-mismatch for another room or identity; the session keeps its token either way. While reconnecting, or before connect(), the token is kept for the next join. tokenExpiring fires about a minute before the held token expires, only when nobody refreshed it.
Wallet notices
client.on("walletLow", ({ remainingSeconds, cleared }) => {
if (cleared) hideBanner();
else showBanner(`Credits run out in about ${remainingSeconds} s. Recharge to keep the call going.`);
});
With the VoltKit Wallet, walletLow warns while the credits the project may spend cover less than about a minute at the current rate (usageLimit is sent beside it for older apps). A recharge sends walletLow with cleared: true, which also cancels the usageLimit countdown. At zero the room closes with roomClosed and the reason quota-exhausted.
Connection state
client.connectionState; // "idle" | "connecting" | "connected" | "reconnecting" | "closed"
client.on("connectionStateChanged", ({ state, previous }) => render(state));
client.participants; // who is in the room, kept current
client.remoteTracks; // [{ producerId, identity, kind, source, track, paused }]
client.info; // the join result, kept current: roster, slots, room metadata
Slots and room metadata
A slot is a named place in the room that one participant holds at a time, such as a seat on a board. The keys are yours.
await client.claim("seat-3"); // take it; claim("seat-3", identity) grants it (moderators)
await client.release("seat-3");
await client.blockSlot("seat-9"); // moderators: lock it; refused with slot-occupied while held
await client.unblockSlot("seat-9");
await client.setMetadata({ name: "Ana" }); // your display metadata; the room hears participantUpdated
await client.setRoomMetadata({ seats: 10 }, { requireFree: ["seat-11", "seat-12"] }); // moderators
client.info.claims lists { key, identity, state }. Draw a slot whose state is away as taken, never as free: its holder's connection dropped and it is kept for them for about a minute.
Also changed in 0.7.0
- A project that does not allow 60 fps now warns
frame-rate-not-allowed;frame-rate-fallbackmeans only that the camera, device or upload cannot keep 60 fps. - Every error the SDK raises is an
RpcErrorwith a code from the registry shared by all VoltKit SDKs:ERROR_CODES,errorInfo(code)andisTerminalError(code)say what each means and whether a retry can help.kickedandbilling-unavailablenow end a reconnect at once. See Errors.
Moderate
await client.raiseHand();
await client.lowerHand();
await client.raiseHand({ requestRole: "cohost" }); // or "moderator"
await client.lowerHand(identity); // host/moderator: decline the target request
await client.muteParticipant(identity); // hosts and moderators: asks them to mute
await client.forceMute(identity, "video"); // hosts and moderators: camera off, locked
await client.forceMute(identity, "video", false); // release; they switch it back on
await client.promote(identity, "cohost");
await client.kick(identity, "spam");
await client.endRoom(); // host only
See Rooms for what each role may do.
Chat
Every room has a chat channel of the same name, carried on the connection the client already holds:
client.on("message", (m) => appendToList(m));
client.on("messageDeleted", ({ id }) => removeFromList(id));
await client.sendMessage("hello everyone");
await client.sendMessage("psst", { to: "user-42" }); // direct message
const older = await client.getMessages({ limit: 50, before: oldest.id });
For an audience that only watches, ChatClient opens a connection that carries chat and nothing else:
import { ChatClient } from "@voltkit-rtc/client";
const chat = new ChatClient(wsUrl, token); // from POST /v1/chat/{channel}/tokens
chat.on("message", appendToList);
const { messages } = await chat.connect(); // recent history, oldest first
await chat.sendMessage("first!");
Direct messages, moderation and history are covered in Chat.
Play music
A host can play music from inside the page, sent as its own track rather than through the microphone, with a switch that is separate from the mic:
await client.publishMusic(audioElement, { volume: 0.8 });
await client.setMusicEnabled(false); // music off, host still talking
await client.setMicEnabled(false); // mic off, music still playing
Viewers get it on track with source: "music". The full guide, including music on a broadcast, is on the Music page.
Publish a broadcast
A broadcast is one publisher and an audience of any size. BroadcastPublisher sends camera and microphone to the whipUrl your backend got when it created the stream:
import { BroadcastPublisher } from "@voltkit-rtc/client";
const publisher = new BroadcastPublisher(whipUrl);
publisher.on("state", (state) => showState(state)); // connecting, live, interrupted, failed, stopped
publisher.on("warning", console.warn);
preview.srcObject = await publisher.start(); // or start({ stream }) for a screen share
// ...
await publisher.stop(); // off air, camera released
Video is offered as H.264 only, which is what keeps a broadcast inexpensive to deliver. start() rejects with a WhipError; when isRefused is true the URL was not accepted and you need a new one from your backend.
Events
| Event | Fires when |
|---|---|
track | A remote track is ready to play. source is mic, music, camera, screen or screen-audio |
trackEnded | A remote track stopped |
participantJoined, participantLeft | Someone joined or left |
roleChanged | Someone was promoted or demoted |
handRaised | Someone raised or lowered their hand, with optional metadata and reason; reconnect rosters include handMetadata |
participantMuted | A moderation lock changed: { identity, kind, paused }; reconnect roster muted and videoBlocked describe current locks |
screenShareStarted, screenShareEnded | Local screen capture started (stream, mode) or stopped (reason) |
trackMuted | Someone paused or resumed a track: muted their mic, switched their music off |
forceMuted | A moderator muted you |
streamInterrupted | Media stopped but the connection is fine: the publisher is reconnecting |
message, messageDeleted, chatMuted, typing | Chat |
roomClosed | The room ended |
kicked | You were removed |
usageLimit | Your project ran out of minutes or hit a usage limit; the session ends at endsAt. Tell the user |
videoLimited | From 0.4.0: your camera video is held back by this device (cpu), the upload (bandwidth) or something else (other), or no longer is (none). See Stats and video health |
localStreamChanged | The client's own stream changed: the devices were reacquired on a reconnect, replaceVideoTrack swapped the video (0.4.0), or null after unpublish() |
error | A failure or a warning that stops nothing, such as frame-rate-fallback; switch on its code |
walletLow | From 0.7.0: the credits the project may spend cover less than about a minute (remainingSeconds), or that is over (cleared: true). See Wallet notices |
tokenExpiring | From 0.7.0: the token the client would reconnect with expires in about a minute and nobody refreshed it |
networkQuality | From 0.7.0: about every 3 s while listened to, level, rttMs, lossPct, availableKbps |
connectionStateChanged | From 0.7.0: idle, connecting, connected, reconnecting or closed, with the previous state |
participantUpdated, claimChanged, roomMetadataChanged | From 0.7.0: someone's display metadata, a slot, or the room metadata changed |
closed | The session is over and will not recover |
Reconnection
Networks drop. The client reconnects on its own, with backoff, and republishes your camera, microphone, music and still-live screen when it is back. It stops retrying when retrying cannot help: the room has ended, or the token is invalid.
The service also sends the client a fresh token before the current one expires, so a reconnect late in a long session still works. You do not need to mint one.
closed with the reason replaced means the same identity joined the same room from somewhere else. Do not reconnect on it, or the two sessions will keep evicting each other.
Always call client.close() when the user leaves. It releases the camera and microphone.
Errors
A refusal from the service rejects with an error that carries a machine-readable code, such as forbidden, chat-muted or rate-limited. From 0.7.0 the SDK's own failures carry a code too, from the registry every VoltKit SDK shares (ERROR_CODES, errorInfo, isTerminalError). The codes are listed in the API reference and on Errors.
Blurry or frozen video, echo, or a camera that will not start are rarely errors from the service: Troubleshooting shows how to tell a device or network problem from one of ours, and what to do about each. If you are stuck, write to contact@voltkit.in.
Last updated . Something unclear or wrong? Tell us.