Skip to content

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.

bash
npm install @usevelo/react-native @livekit/react-native @livekit/react-native-webrtc livekit-client

Then link the native side.

bash
cd ios && pod install
Peer dependency ranges
FieldTypeRequiredDescription
@livekit/react-native^2.12.0YesThe React Native media stack, the video components and the audio session.
@livekit/react-native-webrtc^144.1.2YesThe WebRTC native module. It sets the Android and iOS floor.
livekit-client^2.19.0YesThe room, the participants and the events. @usevelo/react-native re-exports from it.
react>=18.0.0YesThe hook uses effects and state, nothing newer.
react-native>=0.73.0YesOlder 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.

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

javascriptindex.js
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.

typescript
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_CONNECT is what Android 12, API 31, and above needs for headset routing, and it is also requested at runtime.
  • FOREGROUND_SERVICE_MEDIA_PROJECTION is required for screen sharing. The other FOREGROUND_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 audio and voip background 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.

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

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

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.

tsxCallScreen.tsx
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 token and url are both present and connect is not false, so passing undefined while the token request is in flight is the expected usage.
  • It returns room, state, participants, localParticipant and error. participants is the local participant followed by the remote ones, refreshed on every participant, track and speaker change.
  • A bad token is reported through error and onError rather 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.

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

textthrown synchronously
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.

typescript
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();
  • 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.
  • 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.

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

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

typescript
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);
  }
}
  • VeloError is the base and carries status, code, message, field and the parsed body. It is named VeloError here, not VeloApiError as in the web SDK.
  • VeloQuotaError is any 402, VeloRateLimitError any 429, and VeloPermissionError a 403 whose code is permission_denied, with permission naming the missing grant. A plan limit, a throttle and a missing permission are expected states, not bugs, so each gets its own class.
  • VeloConnectionError is a transport failure and does not extend VeloError. It carries the original throw as cause, 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 role on 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?

Edit this page on GitHub