For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inSign up
Support
GuidesReferenceClick-to-Call
GuidesReferenceClick-to-Call
  • Getting Started
    • Overview
    • Authentication
    • RxJS Primer
    • Migrate from v3
  • Web Components
    • Overview
    • Click-to-Call
    • Theming
    • Customization
  • Build Voice & Video Apps
    • Overview
    • Outbound Calls
    • Inbound Calls
    • Device Management
    • Screen Sharing
    • Call Controls
    • Layouts
    • Messaging & Chat
  • Manage Resources
    • Overview
    • Users
    • Address Book
    • Client Preferences
    • Capabilities
  • Deploy
    • Overview
    • Framework Integration
    • SSR & Next.js
    • Troubleshooting
LogoLogoSignalWire Docs
Log inSign up
Support
On this page
  • At a glance
  • Feature compatibility
  • Migration checklist
  • Installation
  • Authentication
  • Client Bound SAT (DPoP)
  • Client initialization
  • Connection state
  • Outbound calls
  • Full call lifecycle
  • Inbound calls
  • RoomSession → Call
  • Self participant
  • Participants
  • Screen sharing
  • Layouts
  • Recording and streaming
  • Device management
  • User info
  • Directory
  • Messaging
  • Removed namespaces
  • Event-to-observable reference
  • API quick reference
  • Cleanup
  • Web components
  • Common migration issues
Getting Started

Migrate from v3

|View as Markdown|Open in Claude|
Was this page helpful?
Edit this page
Previous

Web Components

Next
Built with

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:

1<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:

1<!-- v3: CDN global -->
2<script src="https://unpkg.com/@signalwire/client@dev"></script>
3
4<!-- v4: bundled ES module -->
5<script type="module" src="/src/main.js"></script>
1import { 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:

1class UserCredentialProvider {
2 async authenticate(context) {
3 const response = await fetch("/api/subscriber/token", {
4 method: "POST",
5 headers: { "Content-Type": "application/json" },
6 body: JSON.stringify({ fingerprint: context?.fingerprint }),
7 });
8 const { token, expiresAt } = await response.json();
9 return { token, expiry_at: expiresAt };
10 }
11
12 async refresh() {
13 return this.authenticate();
14 }
15}

Client initialization

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

Before (v3):

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

After (v4):

1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2
3const credentials = new StaticCredentialProvider({ token: "<TOKEN>" });
4
5// Second argument carries v3-style options (debug, logLevel, custom logger, etc.)
6const client = new SignalWire(credentials, {
7 logLevel: "debug",
8 debug: { logWsTraffic: true },
9});
10
11client.ready$.subscribe((ready) => {
12 if (ready) console.log("Client connected and authenticated");
13});
14
15client.errors$.subscribe((error) => {
16 console.error("Client error:", error);
17});

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:

1client.isConnected$.subscribe((connected) => { /* ... */ });
2client.isRegistered$.subscribe((registered) => { /* ... */ });
3client.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):

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

After (v4):

1const call = await client.dial("/private/user1", {
2 audio: true,
3 video: true,
4});
5// No rootElement, no nodeId, no start() — routing is internal
6
7call.remoteStream$.subscribe((stream) => {
8 if (stream) document.getElementById("remoteVideo").srcObject = stream;
9});
10
11call.localStream$.subscribe((stream) => {
12 if (stream) document.getElementById("localVideo").srcObject = stream;
13});

Full call lifecycle

1// v3
2const call = await client.dial({ to, rootElement, nodeId, userVariables });
3await call.start();
4roomObj.on("room.joined", handler);
5roomObj.on("media.connected", handler);
6roomObj.hangup();
7
8// v4
9const call = await client.dial(address, { audio, video });
10call.status$.subscribe(handler); // replaces on('room.joined')
11call.localStream$.subscribe(/* ... */); // replaces rootElement auto-render
12call.remoteStream$.subscribe(/* ... */);
13call.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):

1await client.online({
2 incomingCallHandlers: {
3 all: (notification) => {
4 window.__invite = notification.invite;
5 },
6 },
7});
8
9const call = await window.__invite.accept({
10 rootElement: document.getElementById("container"),
11});
12await window.__invite.reject();
13await client.offline();

After (v4):

1// Registration happens automatically on client construction.
2client.session.incomingCalls$.subscribe((calls) => {
3 const ringing = calls.filter((c) => c.status === "ringing");
4 if (ringing.length > 0) showIncomingCallUI(ringing[0]);
5});
6
7function acceptCall(call) {
8 call.answer(); // synchronous
9 call.remoteStream$.subscribe((stream) => {
10 document.getElementById("remoteVideo").srcObject = stream;
11 });
12}
13
14function rejectCall(call) {
15 call.reject(); // synchronous
16}
17
18// Equivalent to v3's client.offline() / client.online()
19await client.unregister();
20await 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.

1const self = call.self;
2
3await self.mute();
4await self.unmute();
5await self.toggleMute();
6
7await self.muteVideo();
8await self.unmuteVideo();
9await self.toggleMuteVideo();
10
11await self.toggleDeaf();
12
13// Sync access
14const isMuted = call.self?.audioMuted;
15const isVideoMuted = call.self?.videoMuted;
16
17// Reactive
18call.self$.subscribe((self) => {
19 if (self) {
20 self.audioMuted$.subscribe((muted) => updateMuteButton(muted));
21 }
22});

Participants

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

Before (v3):

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

After (v4):

1// Full list (re-emits on every change)
2call.participants$.subscribe((participants) => {
3 renderParticipantList(participants);
4});
5
6// Individual events
7call.memberJoined$.subscribe((event) => addParticipantToUI(event.member));
8call.memberLeft$.subscribe((event) => removeParticipantFromUI(event.member_id));
9
10// Per-participant observables for fine-grained UI updates:
11// participant.name$
12// participant.audioMuted$
13// participant.videoMuted$
14// participant.isTalking$
15// participant.handraised$
16// participant.deaf$
17// participant.visible$
18// participant.position$
19
20const participants = call.participants;

Screen sharing

Screen sharing moves from the room to call.self.

1await call.self.startScreenShare();
2await call.self.stopScreenShare();
3
4call.self$.subscribe((self) => {
5 if (self) {
6 self.screenShareStatus$.subscribe((status) => {
7 console.log("Screen share:", status);
8 });
9 }
10});

Layouts

1// v3
2roomObj.getLayoutList();
3roomObj.setLayout({ name: layoutName });
4roomObj.on("layout.changed", (event) => console.log(event.layout));
5
6// v4
7call.layouts$.subscribe((layouts) => console.log("Available:", layouts));
8call.layout$.subscribe((layout) => console.log("Current:", layout));
9
10await call.setLayout("grid", {});
11
12await call.setLayout("highlight-1-active-4", {
13 "participant-id": "reserved-1",
14});

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.

1// State observable (for server-initiated recordings)
2call.recording$.subscribe((isRecording) => updateRecordingIndicator(isRecording));
3
4const 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):

1import { WebRTC } from "@signalwire/js";
2
3enumerateDevices();
4getCameraDevicesWithPermissions();
5createDeviceWatcher(); // for change detection
6
7await WebRTC.getCameras();
8await WebRTC.getMicrophones();
9await WebRTC.getSpeakers();
10await WebRTC.checkCameraPermissions();
11
12roomObj.updateMicrophone({ deviceId });
13roomObj.updateCamera({ deviceId });

After (v4):

1client.videoInputDevices$.subscribe((cameras) => populateCameraSelect(cameras));
2client.audioInputDevices$.subscribe((mics) => populateMicSelect(mics));
3client.audioOutputDevices$.subscribe((speakers) => populateSpeakerSelect(speakers));
4
5// Pass the full MediaDeviceInfo, not just deviceId
6call.self.selectVideoInputDevice(deviceInfo);
7call.self.selectAudioInputDevice(deviceInfo);
8call.self.selectAudioOutputDevice(deviceInfo);
9
10// Sync access
11const cameras = client.videoInputDevices;

User info

Subscriber is renamed to User.

Before (v3):

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

After (v4):

1const user = client.user;
2
3user.fetched$.subscribe((fetched) => {
4 if (fetched) {
5 console.log("User ID:", user.id);
6 console.log("Display name:", user.displayName);
7 }
8});

Directory

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

Before (v3):

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

After (v4):

1const directory = client.directory;
2
3directory.addresses$.subscribe((addresses) => {
4 // Reactive list — accumulates as loadMore() is called
5 addresses.forEach((addr) => console.log(addr.displayName, addr.type));
6});
7
8directory.hasMore$.subscribe((hasMore) => toggleLoadMoreButton(hasMore));
9directory.loading$.subscribe((loading) => showSpinner(loading));
10
11directory.loadMore(); // fetches and appends the next page

You can dial an address directly:

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

Messaging

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

Before (v3):

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

After (v4):

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

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:

1import { 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.

1await call.hangup();
2
3await client.disconnect(); // closes the WebSocket
4client.destroy(); // releases subscriptions and resources
5
6// Manual subscription cleanup, if needed
7const sub = call.status$.subscribe((status) => console.log(status));
8sub.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.

1<script type="module">
2 import "@signalwire/web-components";
3</script>
4
5<sw-call-media id="call-media">
6 <sw-self-media mirror></sw-self-media>
7 <sw-call-controls></sw-call-controls>
8 <sw-call-status></sw-call-status>
9</sw-call-media>
1const call = await client.dial("/public/room");
2
3const callMedia = document.getElementById("call-media");
4callMedia.call = call;
5// 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.