Client SDKs/React Native
React Native
@usevelo/react-native is the join half of Velo and nothing else. It exchanges a room code, connects with a room token, and gives a screen one hook to render from. It cannot create a room, mint a token for an arbitrary identity, or hold a project key, because an app binary cannot hold a secret.
The package
@usevelo/react-native is published on npm. Media handling stays in @livekit/react-native, @livekit/react-native-webrtc and livekit-client, all peer dependencies, so the native side stays under your control and upgrading the media stack is a version bump on your side rather than a release on ours.
- React Native 0.73 or newer, React 18 or newer.
- A development build. The package needs native modules, so it does not run in Expo Go. On Expo, use a development build with the LiveKit config plugin.
- Android API 24 or newer and iOS 12 or newer, the minimums declared by
@livekit/react-native-webrtc. - A physical device to publish camera or microphone. The iOS Simulator can capture neither.
There is no project-key client here, on purpose. A Velo project API key starts with vk_, and anyone can unzip an APK or an IPA and read every string in it, so a key placed in mobile code is a published key. The web SDK allows one behind dangerouslyAllowBrowser for Node; this package has no equivalent escape hatch. A client gets into a room exactly two ways: your backend mints a room token with POST /v1/tokens and sends it to the app, or the app exchanges a room code with POST /v1/codes/{code}/exchange. Both connectToRoom and useVeloRoom refuse a string beginning with vk_ before they open a socket.
The package re-exports the livekit-client values you need, so Room, RoomEvent, Track, ConnectionState and the participant classes come from @usevelo/react-native. It ships no UI: for the video surface itself use VideoTrack and the track hooks from @livekit/react-native.
Install
The native packages are peer dependencies, so install them alongside this one.
npm install @usevelo/react-native @livekit/react-native @livekit/react-native-webrtc livekit-clientThen link the native side.
cd ios && pod install| Field | Type | Required | Description |
|---|---|---|---|
| @livekit/react-native | ^2.12.0 | Yes | The React Native media stack, the video components and the audio session. |
| @livekit/react-native-webrtc | ^144.1.2 | Yes | The WebRTC native module. It sets the Android and iOS floor. |
| livekit-client | ^2.19.0 | Yes | The room, the participants and the events. @usevelo/react-native re-exports from it. |
| react | >=18.0.0 | Yes | The hook uses effects and state, nothing newer. |
| react-native | >=0.73.0 | Yes | Older versions predate the native modules this depends on. |
Native setup
@livekit/react-native needs one native call per platform, placed above any other React Native initialization. This is the media stack setting up audio routing, not Velo.
import com.livekit.reactnative.LiveKitReactNative
import com.livekit.reactnative.audio.AudioType
LiveKitReactNative.setup(this, AudioType.CommunicationAudioType())MainApplication.kt and AppDelegate.swift. The Objective-C and Java variants are in the @livekit/react-native README.
Then call registerGlobals() once, at the top of index.js, before your app renders. It installs the WebRTC objects, the URL polyfill and the stream shims the media stack expects. Skipping it produces confusing failures deep inside the connection rather than a clear error.
import { registerGlobals } from "@usevelo/react-native";
registerGlobals();registerGlobals and AudioSession are re-exported from @usevelo/react-native, so a single import works if you prefer one. On iOS, registerGlobals() also configures and activates the audio session for you; pass { autoConfigureAudioSession: false } and drive AudioSession yourself if you need custom routing.
Permissions
@livekit/react-native merges INTERNET, ACCESS_NETWORK_STATE, CAMERA, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, WAKE_LOCK, BLUETOOTH, BLUETOOTH_ADMIN and FOREGROUND_SERVICE into your Android manifest, so you do not repeat those. A manifest entry is not consent, though: camera and microphone are dangerous permissions and must be requested at runtime before you publish.
import { PermissionsAndroid } from "react-native";
await PermissionsAndroid.requestMultiple([
PermissionsAndroid.PERMISSIONS.CAMERA,
PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
]);Runtime request on Android, then the manifest and plist entries the merge does not cover.
BLUETOOTH_CONNECTis what Android 12, API 31, and above needs for headset routing, and it is also requested at runtime.FOREGROUND_SERVICE_MEDIA_PROJECTIONis required for screen sharing. The otherFOREGROUND_SERVICE_types matter only if you keep a call alive in the background, which needs a foreground service of your own.- iOS terminates the app on first capture when a usage description is missing. For background audio, enable the
audioandvoipbackground modes in Xcode.
Getting a token
A screen needs two strings to join: a room token and the url that came with it. A room code is the path that puts nothing secret on the device at all. It is a short string bound to one room and one role, created once on your server, redeemed by the app against the public, unauthenticated exchange endpoint.
import { exchangeRoomCode } from "@usevelo/react-native";
const token = await exchangeRoomCode({
baseUrl: "https://api.usevelo.xyz",
code: "abcdefghjkmnpqrs",
identity: "patient-1187",
});Either way, only token and url reach the media server.
Unlike a browser, a React Native app is not subject to CORS, and the API CORS allowlist admits only the console origin. Redeeming a code from the device is the intended mobile path; redeeming one from a browser is not. Disabled, expired, exhausted and unknown codes all fail identically, so a caller learns nothing about the code space.
exchangeRoomCode resolves to a VeloToken.
- token
- The room token to dial with.
- url
- The Velo media server websocket url.
- expiresAt
- When the token stops working, RFC 3339.
- role
- Optional. The template role the code carries.
- publishProfile
- Optional. The capture and subscribe hints for that role.
- roomName
- Optional. Not on the wire: decoded on the device from the token claim
video.room, with theprj_namespace prefix stripped. - identity
- Optional. Decoded on the device from the token claim
sub. This is how you learn the identity an identity-locked code pinned for you.
Nothing is verified by that decode. It only reads back what the server put in the token it just handed you. decodeRoomTokenClaims is exported if you want the same claims, including the unstripped namespacedRoom, from a token you fetched yourself.
| 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
useVeloRoom is what most screens want. It builds the Room, dials it, tracks the connection state and the participants, and tears everything down when the screen unmounts.
import { useEffect, useState } from "react";
import { FlatList, Text, View } from "react-native";
import { ConnectionState, exchangeRoomCode, useVeloRoom } from "@usevelo/react-native";
import type { VeloToken } from "@usevelo/react-native";
export function CallScreen({ code }: { code: string }) {
const [credentials, setCredentials] = useState<VeloToken | undefined>();
useEffect(() => {
let cancelled = false;
exchangeRoomCode({ baseUrl: "https://api.usevelo.xyz", code, identity: "patient-1187" })
.then((result) => {
if (!cancelled) setCredentials(result);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [code]);
const { state, participants, error } = useVeloRoom({
token: credentials?.token,
url: credentials?.url,
roomOptions: { adaptiveStream: true, dynacast: true },
});
if (error) return <Text>{error.message}</Text>;
return (
<View style={{ flex: 1 }}>
<Text>{state === ConnectionState.Connected ? "Live" : state}</Text>
<FlatList
data={participants}
keyExtractor={(participant) => participant.identity}
renderItem={({ item }) => <Text>{item.identity}</Text>}
/>
</View>
);
}- It dials only once
tokenandurlare both present andconnectis notfalse, so passingundefinedwhile the token request is in flight is the expected usage. - It returns
room,state,participants,localParticipantanderror.participantsis the local participant followed by the remote ones, refreshed on every participant, track and speaker change. - A bad token is reported through
errorandonErrorrather than thrown, so a render does not crash on a stale credential. - Changing the token or the url tears down the previous room before building a new one. On unmount every listener is removed and the room is disconnected, so backing out of a screen leaves no live socket behind.
When you want the room without the hook, connectToRoom 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. If the handshake fails the room is disconnected for you and the error is rethrown, so a failed join leaves nothing running.
import { connectToRoom } from "@usevelo/react-native";
const room = await connectToRoom({
token,
url,
adaptiveStream: true,
dynacast: true,
connectOptions: { autoSubscribe: true },
});The project-key guard runs before the socket opens, and it is a synchronous throw. assertRoomToken is exported if you want the same check somewhere else.
TypeError: connectToRoom was given a Velo project API key instead of a room token.
Project keys must never ship inside an app bundle: mint a room token on your server,
or exchange a room code with exchangeRoomCode(), and pass 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.
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();
});
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. - Screen share, data channels and end to end encryption are all reachable through the same
Room. This package wraps none of them and hides none of them.
Chat and reactions
useVeloChat attaches to a room that is already connected and never owns it. It rides the data channel the room already has, under the topic velo.chat, so there is no extra endpoint and no server round trip. Messages and reactions share one bounded in-memory history, oldest first, historyLimit entries deep and 200 by default, deduplicated by payload id. Sending takes an optional target identity, and with one set the payload is published only to that participant and arrives marked direct. A reaction body is a short name your application chooses, such as clap or raise_hand; the SDK defines no vocabulary and ships no emoji.
import { useVeloChat, useVeloRoom } from "@usevelo/react-native";
const { room } = useVeloRoom({ token, url });
const { chat, messages } = useVeloChat(room, { historyLimit: 200 });
await chat?.sendMessage("The nurse will join in a moment");
await chat?.sendReaction("raise_hand");
await chat?.sendMessage("Your results are ready", { to: "patient-1187" });The hook returns chat, undefined until the room exists, and messages, which is the whole history and re-renders on every arrival. Outside a component, createChatChannel is the same object without React: messages, sendMessage, sendReaction, onMessage, clear and close. onMessage returns the function that unsubscribes, and calling it twice is safe.
import { createChatChannel } from "@usevelo/react-native";
const chat = createChatChannel(room, { historyLimit: 200 });
const stop = chat.onMessage((record) => {
if (record.kind === "reaction") flash(record.senderIdentity, record.body);
else append(record.senderIdentity, record.body, record.local);
});
stop();
chat.close();Each record carries id, kind, body, sentAt, senderIdentity, senderName, local and direct. The sender identity is taken from the participant on the data event rather than from the payload, so a peer cannot claim to be someone else. What you send is echoed into your own history immediately, so a message renders without a round trip. A message body is 1 to 2000 characters after trimming and a reaction 1 to 64, rejected locally before publishing; a malformed or unknown payload from a remote peer is dropped silently rather than raised, because a peer you do not control decides what arrives.
Errors and disconnects
Every API failure arrives in one envelope, { "error": { "code", "message", "field" } }, and the SDK turns it into a typed error rather than handing back a raw response.
import {
VeloError,
VeloQuotaError,
VeloRateLimitError,
VeloPermissionError,
VeloConnectionError,
exchangeRoomCode,
} from "@usevelo/react-native";
try {
await exchangeRoomCode({ baseUrl, code, identity });
} catch (error) {
if (error instanceof VeloQuotaError) {
showUpgradePrompt(error.message);
} else if (error instanceof VeloRateLimitError) {
showRetryLater();
} else if (error instanceof VeloPermissionError) {
disableControl(error.permission);
} else if (error instanceof VeloError) {
report(error.status, error.code, error.message);
} else if (error instanceof VeloConnectionError) {
showOffline(error.cause);
}
}VeloErroris the base and carriesstatus,code,message,fieldand the parsedbody. It is namedVeloErrorhere, notVeloApiErroras in the web SDK.VeloQuotaErroris any 402,VeloRateLimitErrorany 429, andVeloPermissionErrora 403 whose code ispermission_denied, withpermissionnaming the missing grant. A plan limit, a throttle and a missing permission are expected states, not bugs, so each gets its own class.VeloConnectionErroris a transport failure and does not extendVeloError. It carries the original throw ascause, and means the request never reached the API.- When a response is not a Velo envelope the code becomes
http_{status}and the message is the first 512 characters of the body.
A drop after a successful join is not an error to catch. It arrives as RoomEvent.ConnectionStateChanged moving to Reconnecting, and as state changing on the hook. The media stack retries on its own; show a banner and leave it alone. A room token expires, so a session longer than its TTL needs a fresh token and a fresh join, which on the hook is a new value for the token prop.
Where to go next
- Your first room walks the four requests that get a call running, from the server side you have to build anyway.
- The REST API is where room creation, token minting and room codes live. Every one of them belongs on your backend.
- Templates and roles explains what the
roleon a token actually fixes: publish sources, the subscribe graph, permissions, priority and capacity. - The web SDK is the package your Node backend uses to mint the tokens and create the codes this one consumes.
Was this page useful?