Client SDKs/Web
Web
@usevelo/client is two halves in one package. VeloClient talks to the REST API with your project key and runs on a server. connectToRoom takes a room token and runs in a browser. The package will tell you when you have mixed them up.
The package
@usevelo/client is published on npm. Media handling stays in livekit-client, which is a peer dependency, so upgrading the media stack is a version bump on your side rather than a release on ours. Node 18 or newer for the server half, a browser with WebRTC for the call.
npm install @usevelo/client livekit-clientA Velo project API key starts with vk_ and must never ship in a client. It creates rooms, mints a token for any identity, removes participants and reads usage. A browser gets a room token minted by your own backend, or a room code exchanged for one. This package is the only Velo SDK that can hold a key at all, and that exists for Node, not for browsers. connectToRoom throws on any string beginning with vk_ before it opens a socket.
The package re-exports the livekit-client values you need, so Room, RoomEvent, Track, ConnectionState and the participant classes all come from @usevelo/client.
Getting a token
A browser needs two strings to join: a room token and the url that came with it. There are two ways to produce them, and both run on your server.
import { exchangeRoomCode } from "@usevelo/client";
const token = await exchangeRoomCode({
baseUrl: "https://api.usevelo.xyz",
code: "abcdefghjkmnpqrs",
identity: "patient-1187",
});
return Response.json({ token: token.token, url: token.url });Both handlers return only token and url to the browser.
exchangeRoomCode is a standalone function rather than a VeloClient method because it carries no credential at all. Redeem it from your server: the API CORS allowlist admits only the Velo console origin, so a browser call is rejected before it reaches the handler. The code itself is created with your key.
const code = await velo.createRoomCode("consultation-42", {
role: "patient",
ttlSeconds: 3600,
maxUses: 1,
});Both paths return the same VeloToken: token, url, expiresAt, and optionally role and publishProfile. Send the first two to the browser and nothing else.
| Field | Type | Required | Description |
|---|---|---|---|
| baseUrl | string | Yes | The API origin, https://api.usevelo.xyz. Trailing slashes are stripped. |
| code | string | Yes | The room code. It is url encoded into POST /v1/codes/{code}/exchange. |
| identity | string | No | Who the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise. |
| timeoutMs | number | No | Defaults to 30000. The request aborts after it. |
| fetch | typeof fetch | No | Your own fetch implementation, for tests or a proxy. |
| defaultHeaders | Record<string, string> | No | Extra headers on the exchange request. |
| signal | AbortSignal | No | Cancels the exchange. |
Connecting
connectToRoom takes the token and the url, builds a Room and returns it connected. Room options sit flat on the same object, so anything RoomOptions accepts can go alongside token and url. Connect options go under connectOptions.
import { connectToRoom, RoomEvent } from "@usevelo/client";
const { token, url } = await fetch("/api/velo-token").then((response) => response.json());
const room = await connectToRoom({
token,
url,
adaptiveStream: true,
dynacast: true,
});
room.on(RoomEvent.TrackSubscribed, (track) => {
document.querySelector("#stage")?.append(track.attach());
});If the handshake fails the room is disconnected for you and the error is rethrown, so a failed join leaves nothing running. track.attach() returns a media element you can put anywhere in the page.
The guard runs before the socket opens. assertRoomToken is exported if you want the same check somewhere else.
import { connectToRoom } from "@usevelo/client";
await connectToRoom({ token: "vk_live_7Qm2Xb...", url });TypeError: connectToRoom was given a Velo project API key instead of a room token.
Project keys must never reach a browser: mint a room token on your server with
VeloClient.createToken() and send that instead.Call controls
Everything past the join is livekit-client, unchanged. The microphone and camera are toggled on the local participant, participants are read off the room, and connection state arrives as an event.
import { ConnectionState, RoomEvent } from "@usevelo/client";
await room.localParticipant.setMicrophoneEnabled(true);
await room.localParticipant.setCameraEnabled(true);
room.on(RoomEvent.ConnectionStateChanged, (state) => {
if (state === ConnectionState.Reconnecting) showBanner("Reconnecting");
if (state === ConnectionState.Connected) hideBanner();
});
room.on(RoomEvent.ParticipantConnected, () => render([...room.remoteParticipants.values()]));
room.on(RoomEvent.ParticipantDisconnected, () => render([...room.remoteParticipants.values()]));
await room.disconnect();ConnectionStateisDisconnected,Connecting,Connected,ReconnectingorSignalReconnecting.room.remoteParticipantsis a map keyed by identity.room.localParticipantis separate and is not in it.- A participant carries
identity,sid,name,isSpeaking,isMicrophoneEnabled,isCameraEnabledandconnectionQuality. room.disconnect()stops local tracks unless you passfalse.
Chat and reactions
Chat rides the room's own data channel, so there is no second connection to open and no message store to run. createChatChannel wraps the room, keeps the last messages in memory, and hands you the history and a subscription.
import { createChatChannel } from "@usevelo/client";
const chat = createChatChannel(room);
const stop = chat.onMessage((record) => {
render(chat.messages);
if (record.kind === "reaction" && !record.local) flash(record.body);
});
await chat.sendMessage("Can you hear me?");
await chat.sendReaction("clap");
await chat.sendMessage("Joining from the car", { to: "therapist-42" });
stop();
chat.close();- A record carries
id,kind,body,sentAt,senderIdentity,senderName,localanddirect.kindismessageorreaction, so both share one stream and one handler. localmarks your own sends, which are delivered to your handler too. Render from the stream alone rather than appending on send, or your own messages will appear twice.- Pass
toa participant identity to send privately. Those records arrive withdirectset. - A message is capped at 2000 characters and a reaction at 64, exported as
MAX_MESSAGE_LENGTHandMAX_REACTION_LENGTH. The channel keeps the last 200 records unless you sethistoryLimit. onMessagereturns its own unsubscribe.close()detaches the channel from the room; call it when the call screen unmounts.
History lives in the browser tab, not on Velo. Whoever joins late sees only what arrives after they join, and a reload starts empty. If a transcript has to outlive the call, write each record to your own store as it arrives.
Errors
Every API failure arrives in one envelope, and the SDK turns it into a typed error rather than handing back a raw response.
{
"error": {
"code": "quota_exceeded",
"message": "monthly participant minutes exhausted"
}
}import {
VeloApiError,
VeloConnectionError,
VeloPermissionError,
VeloQuotaError,
} from "@usevelo/client";
try {
await velo.removeParticipant("consultation-42", "patient-1187");
} catch (error) {
if (error instanceof VeloQuotaError) {
showUpgradePrompt(error.message);
} else if (error instanceof VeloPermissionError) {
disableControl(error.permission);
} else if (error instanceof VeloApiError) {
report(error.status, error.code, error.message);
} else if (error instanceof VeloConnectionError) {
retryLater(error.cause);
}
}VeloApiErrorcarriesstatus,code,message,fieldand the parsedbody.VeloQuotaErroris any 402. A plan limit is an expected state, not a bug, so it gets its own class. Recording and live streaming are not on the free plan, so on a free project the calls that start a recording, start a broadcast, create an ingress or write a template destination throw it with the codefeature_not_in_planand the refused feature infield. Listing, reading, stopping and deleting are never refused for that reason.VeloPermissionErroris a 403 whose code ispermission_denied. Itspermissionfield names the grant that was missing, so a UI can disable that one control.VeloConnectionErroris a transport failure and does not extendVeloApiError. It carries the original throw ascause.
When a response is not a Velo envelope the code becomes http_{status} and the message is the first 512 characters of the body. Full status meanings are in Authentication.
Data usage
Mobile data is a real cost to the person in the call. DataUsageMonitor samples the room transport and reports what the call is actually consuming, so you can show it or act on it.
import { DataUsageMonitor, setAudioOnly, suggestAudioOnly } from "@usevelo/client";
const monitor = new DataUsageMonitor(room, { intervalMs: 5000, pricePerMbUgx: 120 });
monitor.on("update", (snapshot) => {
showBanner(`${(snapshot.totalBytes / 1_000_000).toFixed(1)} MB`);
if (suggestAudioOnly(snapshot)) void setAudioOnly(room, true);
});Each snapshot carries bytesSent, bytesReceived, totalBytes, sendBitrateBps, recvBitrateBps, estimatedCostUgx and durationMs. Sampling starts on construction and defaults to every 3000 ms at 40 UGX per MB. Pass autoStart: false to hold it back.
Holding a key
VeloClient is the only part of any Velo SDK that takes a project key, and it is meant for Node. If it finds window and document at construction it writes a warning to the console. dangerouslyAllowBrowser: true silences that warning and nothing else: it is an acknowledgement, not a protection. The key is still in the bundle, and the bundle is still downloadable.
VeloClientwraps the control plane: rooms, tokens, participants, recordings, streaming, templates and roles, room codes, sessions, usage and webhooks.VeloAdminClientadds project and plan administration and takes an admin token. Server only, on the same terms.- Every method maps to one endpoint in the REST API reference.
Was this page useful?