Migrate from v3

View as MarkdownOpen in Claude

This guide walks through moving an existing v3 (@signalwire/js@3.x) integration to v4. v3 was built around RoomSession and event emitters for video conferencing. v4 unifies calling and conferencing under a single Call API, replaces event emitters with RxJS observables, and introduces a credential-provider auth model with automatic token refresh.

At a glance

Concernv3v4
Initializationawait SignalWire({ host, token }) (async factory)new SignalWire(credentialProvider) (class constructor)
AuthenticationToken passed directlyCredentialProvider with auto-refresh (StaticCredentialProvider, custom)
StateEvent emitters — roomObj.on('event', handler)RxJS observables — call.status$.subscribe(handler)
Call controlsroomObj.audioMute(), roomObj.videoMute()call.self.toggleMute(), call.self.toggleMuteVideo()
Media renderingrootElement passed to dial() — SDK manages the DOM<sw-call-media> / <sw-self-media> components, or localStream$/remoteStream$
DevicesgetCameraDevicesWithPermissions(), roomObj.updateCamera()client.audioInputDevices$, self.selectAudioInputDevice()
DirectoryPaginated API — client.address.getAddresses({...})Observable directory — client.directory.addresses$, loadMore()
Inbound callsclient.online({ incomingCallHandlers })client.session.incomingCalls$ (always active after register)
Messagingclient.conversation.sendMessage() / subscribe()callAddress.sendText() / callAddress.textMessages$

Feature compatibility

v4 covers the bulk of v3, but some features are still in progress. Check this table before migrating.

Featurev4 StatusAlternative
Video rooms & callingImplemented
Participants & eventsImplemented
LayoutsImplemented
Screen sharingImplemented
Mute/unmuteImplemented
Device selectionImplemented
DTMFImplemented
Hold/unholdImplemented
RecordingNot implementedUse SWML or the REST API
Streaming (RTMP)Not implementedUse the server-side REST API
PlaybackNot implementedUse SWML
Room lockingNot implemented
Metadata (setMeta)Not implemented
Call transferNot implemented

If your application depends on recording, streaming, playback, room locking, metadata, or transfer, wait for these features to land before migrating.

Migration checklist

  • Update the package and import paths
  • Replace await SignalWire({ token }) with new SignalWire(credentialProvider)
  • Remove rootElement from dial() and attach media streams manually (or use web components)
  • Drop node_id / userVariables / await call.start() — handled by v4 internally
  • Convert RoomSession methods to Call / call.self equivalents
  • Replace roomObj.on('event', ...) with call.eventName$.subscribe(...)
  • Update invite.accept / invite.reject to call.answer() / call.reject()
  • Drop client.online() / client.offline() — registration is automatic (use client.unregister() to go offline)
  • Move screen share from the room to call.self
  • Swap WebRTC.getCameras() etc. for client.videoInputDevices$
  • Pass full MediaDeviceInfo objects (not bare deviceId) to device selectors
  • Replace client.address.getAddresses() with client.directory.addresses$
  • Replace client.conversation messaging with callAddress.sendText() / textMessages$
  • Add explicit cleanup: call.hangup(), client.disconnect(), client.destroy()

Installation

The package name is unchanged. Upgrade to the v4 major release:

npm install @signalwire/js@latest

For the browser build:

<script src="https://cdn.jsdelivr.net/npm/@signalwire/js/dist/browser.umd.js"></script>

v4 ships as an ES module. If you bundled v3 as a CDN global, switch to module imports:

<!-- v3: CDN global -->
<script src="https://unpkg.com/@signalwire/client@dev"></script>
<!-- v4: bundled ES module -->
<script type="module" src="/src/main.js"></script>
import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

Authentication

v3 accepted a room token directly. v4 introduces a CredentialProvider that owns the token lifecycle — including scheduled refresh before expiry. Use Subscriber Access Tokens (SAT) for authenticated users and Embed Tokens for guest access. See Authentication for the full reference.

The SDK ships with StaticCredentialProvider for pre-obtained tokens (build-time SAT, server-rendered pages). For long-running apps, implement a custom provider that fetches and refreshes a SAT from your backend.

Client Bound SAT (DPoP)

When the SDK passes an AuthenticateContext with a DPoP key fingerprint, forward it to your token endpoint to request a Client Bound SAT with automatic refresh:

class UserCredentialProvider {
async authenticate(context) {
const response = await fetch("/api/subscriber/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fingerprint: context?.fingerprint }),
});
const { token, expiresAt } = await response.json();
return { token, expiry_at: expiresAt };
}
async refresh() {
return this.authenticate();
}
}

Client initialization

v3 was an async factory. v4 is a synchronous constructor; connection happens automatically when you subscribe to the first observable.

Before (v3):

const client = await SignalWire({
host,
token: "<TOKEN>",
debug: { logWsTraffic: true },
});

After (v4):

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
const credentials = new StaticCredentialProvider({ token: "<TOKEN>" });
// Second argument carries v3-style options (debug, logLevel, custom logger, etc.)
const client = new SignalWire(credentials, {
logLevel: "debug",
debug: { logWsTraffic: true },
});
client.ready$.subscribe((ready) => {
if (ready) console.log("Client connected and authenticated");
});
client.errors$.subscribe((error) => {
console.error("Client error:", error);
});

By default, the client connects and registers automatically on construction. Pass skipConnection: true or skipRegister: true if you want to drive that lifecycle yourself.

Connection state

v3 had no separate connection observable — the factory was the connect call. v4 exposes connection state reactively:

client.isConnected$.subscribe((connected) => { /* ... */ });
client.isRegistered$.subscribe((registered) => { /* ... */ });
client.ready$.subscribe((ready) => { /* connected + authenticated */ });

Outbound calls

v3 took an options object with to, rootElement, optional nodeId for routing, and userVariables, then required await call.start(). v4 takes the destination as the first argument, handles steering internally, and does not auto-attach media — you wire streams up yourself.

Before (v3):

const call = await client.dial({
to: "/private/user1",
rootElement: document.getElementById("container"),
nodeId: steeringId,
userVariables: { /* ... */ },
});
await call.start();

After (v4):

const call = await client.dial("/private/user1", {
audio: true,
video: true,
});
// No rootElement, no nodeId, no start() — routing is internal
call.remoteStream$.subscribe((stream) => {
if (stream) document.getElementById("remoteVideo").srcObject = stream;
});
call.localStream$.subscribe((stream) => {
if (stream) document.getElementById("localVideo").srcObject = stream;
});

Full call lifecycle

// v3
const call = await client.dial({ to, rootElement, nodeId, userVariables });
await call.start();
roomObj.on("room.joined", handler);
roomObj.on("media.connected", handler);
roomObj.hangup();
// v4
const call = await client.dial(address, { audio, video });
call.status$.subscribe(handler); // replaces on('room.joined')
call.localStream$.subscribe(/* ... */); // replaces rootElement auto-render
call.remoteStream$.subscribe(/* ... */);
call.hangup();

Inbound calls

v3 used client.online({ incomingCallHandlers }) with callbacks and an explicit offline(). v4 registers the user automatically on construction and exposes incoming calls as an observable — no online/offline toggle. To go offline for inbound calls, call client.unregister() (and client.register() to come back online). answer() and reject() are synchronous in v4 — no await needed.

Before (v3):

await client.online({
incomingCallHandlers: {
all: (notification) => {
window.__invite = notification.invite;
},
},
});
const call = await window.__invite.accept({
rootElement: document.getElementById("container"),
});
await window.__invite.reject();
await client.offline();

After (v4):

// Registration happens automatically on client construction.
client.session.incomingCalls$.subscribe((calls) => {
const ringing = calls.filter((c) => c.status === "ringing");
if (ringing.length > 0) showIncomingCallUI(ringing[0]);
});
function acceptCall(call) {
call.answer(); // synchronous
call.remoteStream$.subscribe((stream) => {
document.getElementById("remoteVideo").srcObject = stream;
});
}
function rejectCall(call) {
call.reject(); // synchronous
}
// Equivalent to v3's client.offline() / client.online()
await client.unregister();
await client.register();

RoomSession → Call

v3 distinguished between CallFabricRoomSession and RoomSession. v4 collapses both into a single Call, with self-participant controls moved off the room object onto call.self.

v3v4
roomSession.audioMute()call.self.mute()
roomSession.audioUnmute()call.self.unmute()
roomSession.videoMute()call.self.muteVideo()
roomSession.videoUnmute()call.self.unmuteVideo()
roomSession.deaf()call.self.toggleDeaf()
roomSession.startScreenShare()call.self.startScreenShare()
roomSession.stopScreenShare()call.self.stopScreenShare()
roomSession.setMicrophoneVolume({ volume })call.self.setAudioInputVolume(value)
roomSession.setSpeakerVolume({ volume })call.self.setAudioOutputVolume(value)

Self participant

call.self is a full participant object with reactive state.

const self = call.self;
await self.mute();
await self.unmute();
await self.toggleMute();
await self.muteVideo();
await self.unmuteVideo();
await self.toggleMuteVideo();
await self.toggleDeaf();
// Sync access
const isMuted = call.self?.audioMuted;
const isVideoMuted = call.self?.videoMuted;
// Reactive
call.self$.subscribe((self) => {
if (self) {
self.audioMuted$.subscribe((muted) => updateMuteButton(muted));
}
});

Participants

Event emitters are gone — participants are an observable list. Each participant also exposes individual observables for granular updates.

Before (v3):

roomSession.on("member.joined", (member) => addParticipantToUI(member));
roomSession.on("member.left", (member) => removeParticipantFromUI(member));
roomSession.on("member.updated", handler);
const members = roomSession.members; // flat objects with properties

After (v4):

// Full list (re-emits on every change)
call.participants$.subscribe((participants) => {
renderParticipantList(participants);
});
// Individual events
call.memberJoined$.subscribe((event) => addParticipantToUI(event.member));
call.memberLeft$.subscribe((event) => removeParticipantFromUI(event.member_id));
// Per-participant observables for fine-grained UI updates:
// participant.name$
// participant.audioMuted$
// participant.videoMuted$
// participant.isTalking$
// participant.handraised$
// participant.deaf$
// participant.visible$
// participant.position$
const participants = call.participants;

Screen sharing

Screen sharing moves from the room to call.self.

await call.self.startScreenShare();
await call.self.stopScreenShare();
call.self$.subscribe((self) => {
if (self) {
self.screenShareStatus$.subscribe((status) => {
console.log("Screen share:", status);
});
}
});

Layouts

// v3
roomObj.getLayoutList();
roomObj.setLayout({ name: layoutName });
roomObj.on("layout.changed", (event) => console.log(event.layout));
// v4
call.layouts$.subscribe((layouts) => console.log("Available:", layouts));
call.layout$.subscribe((layout) => console.log("Current:", layout));
await call.setLayout("grid", {});
await call.setLayout("highlight-1-active-4", {
"participant-id": "reserved-1",
});

Recording and streaming

Recording and streaming APIs are not yet implemented in v4. The observables exist for monitoring server-initiated state, but startRecording() and startStreaming() will throw. Drive these from SWML or the server-side REST API in the meantime.

// State observable (for server-initiated recordings)
call.recording$.subscribe((isRecording) => updateRecordingIndicator(isRecording));
const isRecording = call.recording;

Device management

The standalone WebRTC namespace and roomObj.updateCamera()-style methods are removed. Devices live on the client as reactive lists that auto-update when devices are plugged in or removed.

Heads up: v4 device selectors take the full MediaDeviceInfo object, not just a deviceId string.

Before (v3):

import { WebRTC } from "@signalwire/js";
enumerateDevices();
getCameraDevicesWithPermissions();
createDeviceWatcher(); // for change detection
await WebRTC.getCameras();
await WebRTC.getMicrophones();
await WebRTC.getSpeakers();
await WebRTC.checkCameraPermissions();
roomObj.updateMicrophone({ deviceId });
roomObj.updateCamera({ deviceId });

After (v4):

client.videoInputDevices$.subscribe((cameras) => populateCameraSelect(cameras));
client.audioInputDevices$.subscribe((mics) => populateMicSelect(mics));
client.audioOutputDevices$.subscribe((speakers) => populateSpeakerSelect(speakers));
// Pass the full MediaDeviceInfo, not just deviceId
call.self.selectVideoInputDevice(deviceInfo);
call.self.selectAudioInputDevice(deviceInfo);
call.self.selectAudioOutputDevice(deviceInfo);
// Sync access
const cameras = client.videoInputDevices;

User info

Subscriber is renamed to User.

Before (v3):

const info = await client.getSubscriberInfo();
console.log("Logged in as:", info.name);

After (v4):

const user = client.user;
user.fetched$.subscribe((fetched) => {
if (fetched) {
console.log("User ID:", user.id);
console.log("Display name:", user.displayName);
}
});

Directory

v3’s paginated client.address.getAddresses() is replaced by a reactive directory that accumulates entries on loadMore().

Before (v3):

const data = await client.address.getAddresses({
type,
displayName,
pageSize: 10,
});
// data.data, data.hasNext, data.hasPrev, data.nextPage(), data.prevPage()

After (v4):

const directory = client.directory;
directory.addresses$.subscribe((addresses) => {
// Reactive list — accumulates as loadMore() is called
addresses.forEach((addr) => console.log(addr.displayName, addr.type));
});
directory.hasMore$.subscribe((hasMore) => toggleLoadMoreButton(hasMore));
directory.loading$.subscribe((loading) => showSpinner(loading));
directory.loadMore(); // fetches and appends the next page

You can dial an address directly:

const address = client.directory.addresses.find((a) => a.name === "user1");
const call = await client.dial(address.defaultChannel, { video: true, audio: true });
// URI strings still work
const call2 = await client.dial("/private/user1");

Messaging

v3’s client.conversation API is replaced by per-address messaging on the call.

Before (v3):

client.conversation.sendMessage({ addressId, text });
client.conversation.subscribe((newMsg) => { /* ... */ });
client.conversation.getConversationMessages({ addressId, pageSize });

After (v4):

callAddress.sendText(text); // scoped to the call's address
callAddress.textMessages$.subscribe((textMessagesCollection) => {
textMessagesCollection.values$.subscribe((messages) => renderMessages(messages));
textMessagesCollection.hasMore$.subscribe((hasMore) => {});
textMessagesCollection.loadMore();
});

Messages are scoped to the current call’s address — there is no global conversation client in v4.

Removed namespaces

The standalone Chat, PubSub, and WebRTC clients from v3 are removed. Device APIs move onto the client (see Device management). Chat/PubSub equivalents are not part of the v4 browser SDK.

Event-to-observable reference

When using RxJS operators like filter, map, or pipe, import them from rxjs:

import { filter, map } from "rxjs";

See the RxJS primer for a quick orientation.

v3 Eventv4 Observable
member.joinedcall.memberJoined$
member.leftcall.memberLeft$
member.updatedcall.memberUpdated$
member.talkingcall.memberTalking$
layout.changedcall.layout$, call.layoutLayers$
recording.started/endedcall.recording$ (state observable)
playback.started/endedNot available in the browser SDK (server-side only)
room.updatedcall.meta$, call.locked$
room.joinedcall.status$.pipe(filter(s => s === 'connected'))
room.leftcall.status$.pipe(filter(s => s === 'disconnected'))

API quick reference

v3v4
SignalWire({ token })new SignalWire(credentialProvider)
Ready callbackclient.ready$ (emits true when connected + authenticated)
client.dial({ to, rootElement })client.dial(destination, options)
client.online({ handlers })Automatic — subscribe to client.session.incomingCalls$
client.offline()client.unregister() (re-enable with client.register())
invite.accept() (async)call.answer() (sync)
invite.reject() (async)call.reject() (sync)
roomSession.audioMute()call.self.mute()
roomSession.deaf()call.self.toggleDeaf()
roomSession.setMicrophoneVolume({ volume })call.self.setAudioInputVolume(value)
roomSession.setLayout(name)call.setLayout(name, positions)
roomSession.getLayoutList()call.layouts$
roomSession.memberscall.participants / call.participants$
roomSession.on('event', fn)call.eventName$.subscribe(fn)
client.updateToken(token)Handled by credential provider’s refresh()
client.address.getAddresses()client.directory.addresses$ + directory.loadMore()
client.conversation.sendMessage()callAddress.sendText()
roomSession.leave()call.hangup()
Disconnectclient.disconnect() + client.destroy()

Cleanup

v4 requires explicit cleanup. End calls with hangup(), then disconnect and destroy the client to release all subscriptions.

await call.hangup();
await client.disconnect(); // closes the WebSocket
client.destroy(); // releases subscriptions and resources
// Manual subscription cleanup, if needed
const sub = call.status$.subscribe((status) => console.log(status));
sub.unsubscribe();

Web components

v4 ships @signalwire/web-components, composable around the new reactive Call API. <sw-call-media> is the root container — nest media, controls, and status components inside, then assign the call.

<script type="module">
import "@signalwire/web-components";
</script>
<sw-call-media id="call-media">
<sw-self-media mirror></sw-self-media>
<sw-call-controls></sw-call-controls>
<sw-call-status></sw-call-status>
</sw-call-media>
const call = await client.dial("/public/room");
const callMedia = document.getElementById("call-media");
callMedia.call = call;
// Child components receive the call automatically via Lit context

<sw-participants> renders participant overlays driven by the same context.

Common migration issues

  1. No video displays. v4 does not auto-attach to the DOM. Subscribe to remoteStream$ (and localStream$) and assign the stream to a <video>’s srcObject — or use <sw-call-media> / <sw-self-media>.
  2. call.self is null. self is populated only after joining. Use call.self$ for reactive access, or optional chaining (call.self?.audioMuted) for sync reads.
  3. Events seem to be missing. Subscribe to observables before the events fire — and avoid unsubscribing prematurely. participants$ re-emits the full list on any change, so wire it up early in your component lifecycle.
  4. startRecording() throws. Recording is not yet implemented in v4. Trigger recording server-side via SWML or the REST API; use call.recording$ to reflect state in the UI.
  5. Device selection has no effect. v4 expects a full MediaDeviceInfo object, not a bare deviceId string.
  6. Token expired errors after a while. v3’s client.updateToken() is gone. Implement refresh() on your credential provider and return { token, expiry_at } — the SDK will refresh on schedule.