Skip to content

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.

bash
npm install @usevelo/client livekit-client

A 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.

typescript
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.

typescriptserver
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.

exchangeRoomCode options
FieldTypeRequiredDescription
baseUrlstringYesThe API origin, https://api.usevelo.xyz. Trailing slashes are stripped.
codestringYesThe room code. It is url encoded into POST /v1/codes/{code}/exchange.
identitystringNoWho the resulting token admits. Ignored when the code was created with a pinned identity, required otherwise.
timeoutMsnumberNoDefaults to 30000. The request aborts after it.
fetchtypeof fetchNoYour own fetch implementation, for tests or a proxy.
defaultHeadersRecord<string, string>NoExtra headers on the exchange request.
signalAbortSignalNoCancels 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.

typescriptbrowser
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.

typescript
import { connectToRoom } from "@usevelo/client";

await connectToRoom({ token: "vk_live_7Qm2Xb...", url });
textthrown synchronously
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.

typescriptbrowser
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();
  • ConnectionState is Disconnected, Connecting, Connected, Reconnecting or SignalReconnecting.
  • room.remoteParticipants is a map keyed by identity. room.localParticipant is separate and is not in it.
  • A participant carries identity, sid, name, isSpeaking, isMicrophoneEnabled, isCameraEnabled and connectionQuality.
  • room.disconnect() stops local tracks unless you pass false.

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.

typescriptbrowser
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, local and direct. kind is message or reaction, so both share one stream and one handler.
  • local marks 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 to a participant identity to send privately. Those records arrive with direct set.
  • A message is capped at 2000 characters and a reaction at 64, exported as MAX_MESSAGE_LENGTH and MAX_REACTION_LENGTH. The channel keeps the last 200 records unless you set historyLimit.
  • onMessage returns 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.

json402 Payment Required
{
  "error": {
    "code": "quota_exceeded",
    "message": "monthly participant minutes exhausted"
  }
}
typescript
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);
  }
}
  • VeloApiError carries status, code, message, field and the parsed body.
  • VeloQuotaError is 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 code feature_not_in_plan and the refused feature in field. Listing, reading, stopping and deleting are never refused for that reason.
  • VeloPermissionError is a 403 whose code is permission_denied. Its permission field names the grant that was missing, so a UI can disable that one control.
  • VeloConnectionError is a transport failure and does not extend VeloApiError. It carries the original throw as cause.

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.

typescriptbrowser
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.

  • VeloClient wraps the control plane: rooms, tokens, participants, recordings, streaming, templates and roles, room codes, sessions, usage and webhooks.
  • VeloAdminClient adds 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?

Edit this page on GitHub