Your call screen doesn't ask the room "what changed?" over and over. Instead, the room tells your app with events: someone joined, a video is ready, the network dropped. Your code listens and updates the screen.
This page lists every event a Room can fire. There are 28 in total.
They're the same on @livqeno/rtc and @livqeno/react-native,
because both use the same Room class. These are in-app events. To tell
your backend what happened (for logs or billing your own users), use
webhooks instead.
A typical call, as events
Here's what alice's app sees when bob joins, turns on his camera, mutes, and leaves:
connectionStateChanged('connected') alice is in the room
participantJoined(bob) bob arrives → add a tile for bob
trackPublished('camera', bob) bob's camera exists
trackSubscribed(track, bob) bob's video is ready → attach it to bob's tile
trackMuted('microphone', bob) bob muted → show a muted icon
participantLeft(bob) bob leaves → remove bob's tile
Listen and stop listening
function onJoin(participant: RemoteParticipant) {
console.log(`${participant.identity} joined`);
}
room.on('participantJoined', onJoin);
room.off('participantJoined', onJoin); // keep the reference to unsubscriberoom.on() returns the room, so you can chain calls:
room.on('connected', render).on('participantJoined', render).on('participantLeft', render);Connection events
Use these to show the call's status: "Connecting…", "Reconnecting…", or "Call ended".
| Event | Sends | Fires when |
|---|---|---|
connectionStateChanged | state: ConnectionState | The state changes. Use this one for your UI. |
connected | — | The room finished joining. |
disconnected | reason: DisconnectReason | The connection ended and the SDK will not reconnect it: CLIENT_INITIATED, REMOVED, ROOM_CLOSED, TOKEN_EXPIRED or CONNECTION_FAILED. Never rejoin automatically after REMOVED or ROOM_CLOSED. |
reconnecting | — | A lost connection is coming back. |
reconnected | — | It's back. Your tracks are already sent again. |
reconnected sends nothing because there's nothing for you to redo.
See Reconnection.
| Event | Sends | Fires when |
|---|---|---|
warning | warning: RTCWarning | Something worth knowing that didn't stop the call: TOKEN_EXPIRING (no refreshToken configured) or TOKEN_REFRESH_FAILED. |
Participant events
Use these to add and remove people's tiles.
| Event | Sends | Fires when |
|---|---|---|
participantJoined | participant: RemoteParticipant | Someone else joins. |
participantLeft | participant: RemoteParticipant | Someone else leaves. |
These are only for other people, not you.
| Event | Sends | Fires when |
|---|---|---|
participantMetadataChanged | participant: Participant, prev: { metadata, attributes } | Anyone's metadata or attributes changed, including yours. |
Other people's tracks
Use these to show other people's video and audio, and their mute icons.
| Event | Sends | Fires when |
|---|---|---|
trackPublished | kind: TrackKind, participant: RemoteParticipant | Someone starts sending a track. |
trackUnpublished | kind: TrackKind, participant: RemoteParticipant | They stop sending it. |
trackSubscribed | track: RemoteTrack, participant: RemoteParticipant | The track is ready to show. |
trackUnsubscribed | track: RemoteTrack, participant: RemoteParticipant | It's no longer available. |
trackMuted | kind: TrackKind, participant: RemoteParticipant | They muted a track (it's still published). |
trackUnmuted | kind: TrackKind, participant: RemoteParticipant | They unmuted it. |
Muted is not the same as unpublished. A muted track is still there. Keep the person's tile and show a muted icon. Don't remove and rebuild it.
Speaking, quality, and paused video
The media server measures these for everyone in the room and pushes them to every app, so all participants see the same answer.
| Event | Sends | Fires when |
|---|---|---|
activeSpeakersChanged | speakers: Participant[] | The set of people talking changes. Loudest first, at most 8, you included. An empty array means silence. At most every 300 ms. |
connectionQualityChanged | participant: Participant, quality: ConnectionQuality | The server re-scores someone's connection: 'excellent', 'good', 'poor' or 'lost'. At most once a second. participant can be room.localParticipant. |
trackSubscriptionStateChanged | track: RemoteTrack, participant: RemoteParticipant | A video you receive was paused or resumed, or changed quality. Read track.subscriptionState and track.layer. |
e2eeStateChanged | participant: Participant, state: E2EEState | End-to-end encryption state changed: 'ok', 'missing_key', 'decrypt_failed' or 'encrypt_failed'. For room.localParticipant it is about what you send. Only in an E2EE room with e2ee configured. |
room.on('activeSpeakersChanged', (speakers) => {
highlight(speakers[0]); // the loudest speaker, or undefined
});
room.on('connectionQualityChanged', (participant, quality) => {
if (participant === room.localParticipant && quality === 'poor') showWeakNetworkBanner();
});
room.on('trackSubscriptionStateChanged', (track) => {
// 'paused_bandwidth': your connection can't carry this video right now
setPausedOverlay(track, track.subscriptionState === 'paused_bandwidth');
});Each participant also fires its own events, for a component that only cares about one person:
participant.on('isSpeakingChanged', (speaking) => tile.classList.toggle('speaking', speaking));
participant.on('connectionQualityChanged', (quality) => setBadge(quality));and has the current values on it: participant.isSpeaking,
participant.audioLevel (0 to 1) and participant.connectionQuality
('unknown' until the server has scored them). See
Tracks for subscriptionState.
Your own tracks
Use these to keep your own buttons and self-view correct, for example when the user stops a screen share from the browser's own button.
| Event | Sends | Fires when |
|---|---|---|
localTrackPublished | track: LocalTrack | enableCamera(), enableMicrophone(), enableScreenShare() or publish() finished. |
localTrackUnpublished | track: LocalTrack | Your track stopped being sent. |
localTrackMutedByServer | track: LocalTrack | Your backend muted one of your tracks. It's already muted (track.isMuted is true). The user can unmute it as usual. |
permissionsChanged | permissions: ParticipantPermissions | Your backend changed what you may do during the call. If it took away a publish right, the SDK has already stopped those tracks (you get localTrackUnpublished, not an error). If it granted one, enableCamera() works straight away, with no reconnect. Also readable any time as room.permissions. |
A live-stream viewer promoted to the stage is the common case for
permissionsChanged:
room.on('permissionsChanged', (permissions) => {
setCanPublish(permissions.publish);
});Captions and AI agents
These fire only when your backend has started a transcription or an AI voice agent in the room.
| Event | Sends | Fires when |
|---|---|---|
transcriptionSegment | segment: TranscriptionSegment | A caption arrived. Interim segments share a segmentId and replace each other; the final one closes it. A final with empty text means "clear the interim". An agent's own speech arrives here too. |
transcriptionStateChanged | state: TranscriptionState | The room's transcription changed status: STARTING, ACTIVE, STOPPING, ENDED or FAILED. |
agentStateChanged | participant: Participant, state: AgentState | An agent is now 'listening', 'thinking', 'speaking', or has 'ended'. Fires only on a change. Also readable as participant.agentState. |
participant.isAgent and participant.isTranscriber tell the platform's
bots apart from your users, for example to give the agent its own tile.
Rather than applying the interim and final rules yourself, fold segments
through a CaptionStore:
import { CaptionStore } from '@livqeno/rtc';
const captions = new CaptionStore();
captions.attach(room);
captions.subscribe(() => render(captions.getSnapshot())); // { segments, interim, active }Data and errors
| Event | Sends | Fires when |
|---|---|---|
dataReceived | payload: Uint8Array, participant?: RemoteParticipant, topic?: string | A data message arrived. |
dataThrottled | channel: 'reliable' | 'lossy' | You sent data faster than the rate limit, and the server dropped some. Slow down. |
error | error: RTCError | Something failed. You get a typed error, not an unhandled rejection. |
participant on dataReceived is missing for a message sent by your
server, or by an SDK too old to carry a sender. Handle the undefined
case.
import { isRTCError } from '@livqeno/rtc';
room.on('error', (error) => {
if (isRTCError(error) && error.code === 'CAMERA_PERMISSION_DENIED') {
showPermissionHelp();
}
});Things that are not events
| You want | Do this instead |
|---|---|
| Raw numbers (RTT, jitter, loss, bitrate) per track | Check room.getConnectionStats() on a timer. |
| Number of reconnect attempts | Read getDiagnostics().reconnectCount. |
| Whether this device can hold a call at all, before joining | Run the pre-call test. |
Other platforms
React and Flutter give you the same information in their own style, so you
rarely call on() directly.
The hooks listen for you and re-render when these events fire:
const state = useConnectionState();
const participants = useParticipants();
const remote = useRemoteParticipants();
const local = useLocalParticipant();
const error = useLivqenoError();
const speakers = useActiveSpeakers(); // activeSpeakersChanged
const speaking = useIsSpeaking(participant); // isSpeakingChanged
const quality = useConnectionQuality(participant); // connectionQualityChanged
const { state: videoState } = useTrackSubscriptionState(track);
const e2ee = useE2EEState(participant); // e2eeStateChanged
const { segments, interim, active } = useTranscription(); // transcriptionSegment
const agentState = useAgentState(agent); // agentStateChangedNeed on() directly? useLivqeno() gives you the Room.
Dart uses streams instead of on(). Same events, Dart style:
room.connectionStateChanges.listen((state) { });
room.participantChanges.listen((participants) { });
room.data.listen((bytes) { });
room.errors.listen((error) { });
room.grantChanges.listen((grants) { }); // permissionsChanged
room.localTracksMutedByServer.listen((kind) { }); // localTrackMutedByServer
room.warnings.listen((warning) { });
room.activeSpeakerChanges.listen((speakers) { }); // activeSpeakersChanged
room.connectionQualityChanges.listen((update) { }); // connectionQualityChanged
room.subscriptionStateChanges.listen((update) { }); // trackSubscriptionStateChanged
room.e2eeStateChanges.listen((update) { }); // e2eeStateChanged
room.transcriptionSegments.listen((segment) { }); // transcriptionSegment
room.transcriptionStateChanges.listen((state) { }); // transcriptionStateChanged
room.agentStateChanges.listen((update) { }); // agentStateChanged
// After a disconnect: room.disconnectReasonLivqenoRoom is also a ChangeNotifier, so you can use it with
ListenableBuilder.
Next steps
- Chat events · Stream events
- Event catalogue: every event, including webhooks.
- Signaling protocol: the messages underneath these events.