Skip to content

Address Book & Directory

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

client.directory is the runtime view of every Address the authenticated user can reach, other Users, video rooms, AI agents, SWML scripts, anything the platform has surfaced into this user’s scope. Each entry is an Address instance that you can read identity from, dial, message, and inspect for call history.

The directory is paginated, observable, and lazily loaded: subscribe to addresses$ and pages stream in as you call loadMore().

Getting the directory

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(
  new StaticCredentialProvider({ token: "YOUR_SAT" })
);

client.directory$.subscribe((directory) => {
  if (!directory) return; // not yet connected

  directory.addresses$.subscribe((addresses) => {
    renderList(addresses);
  });

  directory.loadMore(); // trigger the first page
});

client.directory$ emits once the client is connected; subscribe to it instead of reading client.directory synchronously to avoid the “not yet authenticated” race. The directory itself outlives any single addresses$ subscription, it’s the manager that owns the state.

Paging

The directory does not load on its own, addresses$ emits [] until you call loadMore(). Subscribe first, then trigger the first page; every subsequent page works the same way.

const directory = await firstValueFrom(client.directory$.pipe(filterNull()));

// Subscribe to the full, growing list
directory.addresses$.subscribe((addresses) => {
  console.log(`now have ${addresses.length} addresses`);
});

// Track whether more pages exist
directory.hasMore$.subscribe((hasMore) => {
  loadMoreButton.disabled = !hasMore;
});

// Track loading state to disable the button mid-fetch
directory.loading$.subscribe((loading) => {
  spinner.hidden = !loading;
});

loadMoreButton.onclick = () => directory.loadMore();

// Kick off the first page.
directory.loadMore();

Reading directory.addresses synchronously before loadMore() has resolved gives you an empty array, it’s the snapshot of state the SDK currently holds, not a promise that fetches. Always drive your UI from addresses$.

The collection is reactive, when a server-side update lands (e.g. a new contact added in the background), new entries appear in the existing addresses$ stream without you re-fetching.

What an Address gives you

Identity (name, displayName, type, resourceId), visuals (preview / cover URLs), communication channels (audio / video / messaging URIs), room state, and the conversation handle (sendText, textMessages$, history$). Like everything in the SDK, mutable state is exposed twice, as a synchronous getter and as a $ observable. The full shape is on the Address reference page; this guide covers the fields you’ll actually drive UI off of.

Resource type

Address.type tells you what kind of Resource is on the other end:

typeWhat it is
'subscriber'Another user. Direct peer-to-peer.
'room'A video room. Multi-party.
'app'A SWML script or AI agent.
'call'A platform call resource (gateway, queue, etc.).

Use it to drive UI affordances, show a video icon for rooms, a phone icon for users, an avatar for AI agents:

function iconFor(address) {
  switch (address.type) {
    case "room":       return "video";
    case "subscriber": return "user";
    case "app":        return "robot";
    case "call":       return "phone";
  }
}

Channels

address.channels reports which communication modes the resource supports. A video room exposes { audio, video, messaging }; a phone address might be { audio } only. The defaultChannel getter picks the right one for a one-click dial (video for rooms, audio otherwise).

const call = await client.dial(address.defaultChannel ?? address.name, {
  audio: true,
  video: address.type === "room",
});

Looking up an address by URI

When you know the URI (/public/support, /private/jane) and need the Address instance, to inspect channels, send a message, or hand to client.dial(), use findAddressIdByURI:

const id = await directory.findAddressIdByURI("/public/support");
if (id) {
  const address = directory.get(id);
  // address is now usable
}

findAddressIdByURI checks the local cache first, then queries the server. directory.get(id) is a pure local lookup, call it only after the id is known to exist.

For the reactive equivalent, directory.get$(id) returns an Observable<Address> that emits whenever the entry’s state changes.

Dialing

client.dial() accepts either the URI directly or an Address instance:

// by URI
await client.dial("/public/support", { audio: true });

// by Address, equivalent
const address = directory.get(addressId);
await client.dial(address, { audio: true });

Passing the Address lets the SDK pick the right channel automatically when one isn’t pinned in the URI.

Messaging and call history

Each address owns its own conversation: address.sendText(), address.textMessages$, and address.history$. See Messaging & Chat for the patterns, same pagination shape as the directory, lazy-loaded on first subscribe.

Room state

For room-type addresses, locked$ reports whether the room is currently accepting new joins. Lock state changes mid-call propagate through the same observable:

address.locked$.subscribe((locked) => {
  joinButton.disabled = locked;
  joinButton.textContent = locked ? "Room locked" : "Join";
});

previewUrl$ and coverUrl$ carry the room’s thumbnail and banner images when the platform has them.

A complete directory UI

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
import { filter, firstValueFrom } from "rxjs";

const client = new SignalWire(
  new StaticCredentialProvider({ token: "YOUR_SAT" })
);

const directory = await firstValueFrom(
  client.directory$.pipe(filter((d) => !!d))
);

directory.addresses$.subscribe(renderList);
directory.hasMore$.subscribe((more) => (loadMoreBtn.hidden = !more));
loadMoreBtn.onclick = () => directory.loadMore();

directory.loadMore(); // trigger the first page

function renderList(addresses) {
  list.innerHTML = "";
  for (const address of addresses) {
    const li = document.createElement("li");
    li.textContent = `${address.displayName}  (${address.name})`;
    li.onclick = () =>
      client.dial(address, {
        audio: true,
        video: address.type === "room",
      });
    list.appendChild(li);
  }
}

This is the same shape <sw-directory> builds on top of in the web components, see the Web Components reference if you’d rather drop in a pre-styled list.

Reference

  • SignalWire.directory$ / directory, the directory manager
  • Directory interface, addresses$, loadMore(), hasMore$, loading$, get(), get$(), findAddressIdByURI()
  • Address, the per-entry class (name, displayName, type, channels, sendText, textMessages,history, history,history, locked,previewUrl, previewUrl,previewUrl, coverUrl$)
  • SignalWire.dial(), accepts an Address or URI

Authentication

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The SDK acts on behalf of a user, whether that’s a full-access Subscriber or a guest with limited access. It authenticates with a Subscriber Access Token (SAT), a short-lived credential that identifies that user and carries the capabilities granted to them. Your backend creates the SAT using your Project API Token, then hands it to the browser, where the SDK uses it to open a WebSocket session with SignalWire.

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(
  new StaticCredentialProvider({ token: "<your SAT>" })
);

The sections below cover which kind of SAT to create, how to deliver it to the browser, and how to keep the session alive past the SAT’s expiry.

Before you start. You need a SignalWire space, a Project ID, and an API token with at least one of the Voice / Messaging / Fax / Video scopes. All three are in the API Credentials section of the dashboard. The Project API Token is what creates SATs. Keep it server-side only.

Authentication patterns

The SDK supports four authentication patterns, each shaped by who is holding the credential and what they need to do with it.

Authenticated users

For apps where users sign in with an account. Each user can place and receive calls.

Guest users

For users without an account who need limited calling, typically to a short list of destinations you allow.

Public usage

For embedding a “call us” button on a public webpage. Anyone visiting can dial one preset destination.

Call invite

For giving a specific recipient a way to connect to a call through a shareable invite.

Each pattern uses a different credential: three Subscriber Access Token (SAT) flavors issued for a user, guest, or invitee, and a separate Embed token for public widgets. The credential’s capabilities determine what the holder can do:

PatternCredentialInbound callsOutbound callsDestinationsAudience
Authenticated usersSubscriber Access TokenAnywhere the user can reachOne signed-in user
Guest usersGuest SATA list of allowed destinations you set (max 10)One guest user with scoped capabilities
Call inviteInvite SATThe inviting user’s addressOne invitee
Public usageEmbed tokenTied to a single resourceAnyone visiting a public page

Match the credential’s reach to the trust level of whoever holds it. If a credential can dial anyone, then anyone who can read it can dial anyone, so use the delivery model below that keeps the credential out of untrusted hands.

How the SDK gets its credential

Credentials reach the SDK one of two ways. Embed tokens live in the page itself. Every other variant is created server-side and handed to the browser; the only thing that differs is whether the SDK should keep the session alive past the credential’s first expiry.

Embed tokens (in-page)

Embed tokens are the only credential designed to sit in a public page. They are pinned to one Click-to-Call resource: anyone who reads the page can only dial the resource the embed token was created for. That fixed scope is what makes them safe to expose to every visitor.

Getting an embed token is a two-step setup:

  1. Create a Click-to-Call resource in your SignalWire dashboard. The dashboard issues a Click-to-Call (C2C) token (with a c2c_ prefix) tied to that resource.
  2. Exchange the C2C token for an embed token by calling POST /api/embeds/tokens. The embed token is what the SDK is built to consume for public widgets.

The SDK also accepts a C2C token directly as a shortcut (convenient for testing), but production widgets should pass the exchanged embed token. The examples below use the shortcut form so they can run with only the dashboard value.

Shortcut for a single call. embeddableCall() handles credential exchange, client construction, and dial in one call:

Browser

import { embeddableCall } from "@signalwire/js";

const call = await embeddableCall({
  host: "yourspace.signalwire.com",
  embedToken: "c2c_7acc0e5e968706a032983cd80cdca219",
  to: "/public/support",
});

Full SDK setup for multiple calls or client-level subscriptions. Pass EmbedTokenCredentialProvider to the SDK directly. You keep a long-lived SignalWire client that can dial repeatedly and exposes observables you can subscribe to. The provider exchanges the embed token for a Guest SAT and refreshes automatically:

Browser

import { SignalWire, EmbedTokenCredentialProvider } from "@signalwire/js";

const client = new SignalWire(
  new EmbedTokenCredentialProvider(
    "yourspace.signalwire.com",
    "c2c_7acc0e5e968706a032983cd80cdca219"
  )
);

const call = await client.dial("/public/support");

Server-fetched SATs

The browser asks for a SAT, your backend creates one using the Project API Token, and the SDK uses it for the session.

The browser never talks to SignalWire directly here. Creating any SAT requires the Project API Token, which can issue a SAT for any user in your project. That is why it stays server-side. The hop through your backend is what enforces “this browser session can only get the SAT it’s authorized for.”

Three SAT variants come through this path:

  • Subscriber Access Token ( POST /api/fabric/subscribers/tokens): full user identity for a signed-in user; can receive inbound calls. Also called a default-scope SAT when you need to distinguish it from the variants below.
  • Guest SAT ( POST /api/fabric/guests/tokens): outbound-only, pinned to up to 10 allowed_addresses.
  • Invite SAT ( POST /api/fabric/subscriber/invites): outbound-only, pinned to one address; created client-side by a signed-in user and delivered out-of-band (URL, email, QR code) to one recipient.

Once the SAT is in the browser, the next decision is whether the session needs to outlive a single SAT. For one-shot sessions (typical for Guest and Invite SATs), use StaticCredentialProvider. The SDK uses the fetched SAT until it expires, then the session ends. For sessions that must outlive a single SAT, pick a refresh strategy below.

Refreshing SATs

SATs are short-lived (two hours by default), which limits the damage if one ever leaks. When a SAT expires, the SDK’s WebSocket session ends with it unless a fresh SAT is supplied first. Refreshing is the process of swapping in a fresh SAT before the current one expires, so the session continues uninterrupted: the user stays connected, ongoing calls aren’t dropped, and they don’t need to re-authenticate.

There are two ways to refresh a SAT, depending on where the rotation logic should live.

Server-side refresh

The backend rotates the SAT. Your CredentialProvider exposes a refresh() method that fetches a fresh SAT from your backend; the SDK calls it shortly before the current SAT’s expiry_at. Every rotation roundtrips through your backend.

Browser

import { SignalWire } from "@signalwire/js";
import type { CredentialProvider } from "@signalwire/js";

class BackendSAT implements CredentialProvider {
  async authenticate() {
    const r = await fetch("/api/signalwire-token", {
      method: "POST",
      // `credentials: "include"` tells fetch to send the browser's cookies with
      // the request, so your backend reads its own session cookie and knows
      // which signed-in user is asking for a token.
      credentials: "include",
    });
    const { token, expiresAt } = await r.json();
    // expiry_at is a Date.now()-style millisecond timestamp.
    return { token, expiry_at: expiresAt };
  }

  refresh() {
    return this.authenticate();
  }
}

const client = new SignalWire(new BackendSAT());

Inside /api/signalwire-token, your backend produces the fresh SAT one of two ways:

Re-issue against the user's session

Every SAT comes back with a companion refresh_token. Your backend stores it and swaps it for a new SAT/refresh-token pair via POST /api/fabric/subscribers/tokens/refresh. This keeps the session going without re-checking the user’s app session on every rollover.

Server (Node.js)

app.post("/api/signalwire-token", async (req, res) => {
  // Look up the refresh_token you stored when this user was created.
  const stored = await getRefreshTokenForUser(req.user.id);

  // Swap that refresh_token for a new SAT + new refresh_token pair.
  const r = await fetch(`https://${SPACE}/api/fabric/subscribers/tokens/refresh`, {
    method: "POST",
    headers: { Authorization: BASIC_AUTH, "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: stored }),
  });
  const { token, refresh_token } = await r.json();

  // Save the rotated refresh_token so the next call can swap it too.
  await saveRefreshTokenForUser(req.user.id, refresh_token);

  // expiresAt assumes the 2h default; if you set `expire_at` when you created the SAT, compute from that.
  res.json({ token, expiresAt: Date.now() + 2 * 60 * 60 * 1000 });
});

The new access token carries the standard SAT lifetime; the new refresh token outlives it by five minutes so the swap has slack. Store refresh tokens encrypted, server-side only.

Client-side refresh

The SDK rotates the SAT directly with SignalWire after it is first issued. Your backend is involved only at startup.

This path binds the SAT to the browser session that requested it. The SDK provides a public fingerprint at authentication time; the backend includes that fingerprint plus scope: "sat:refresh" on the create request. Refresh calls are then signed against the matching private key the browser holds, so a SAT lifted off the wire can’t be rotated from anywhere else.

Browser

import { SignalWire } from "@signalwire/js";
import type { CredentialProvider, AuthenticateContext } from "@signalwire/js";

class BackendSAT implements CredentialProvider {
  async authenticate(context?: AuthenticateContext) {
    const r = await fetch("/api/signalwire-token", {
      method: "POST",
      // `credentials: "include"` tells fetch to send the browser's cookies with
      // the request, so your backend reads its own session cookie and knows
      // which signed-in user is asking for a token.
      credentials: "include",
      headers: { "content-type": "application/json" },
      // Forward the SDK's fingerprint so the backend can issue a SAT bound to this browser.
      body: JSON.stringify({ fingerprint: context?.fingerprint }),
    });
    const { token, expiresAt } = await r.json();
    return { token, expiry_at: expiresAt };
  }

  // No refresh(), rotation happens directly between the SDK and SignalWire after the SAT is first issued.
}

const client = new SignalWire(new BackendSAT());

On the backend side, forward the fingerprint and request the refresh scope when creating the SAT:

Server (Node.js)

app.post("/api/signalwire-token", requireUserAuth, async (req, res) => {
  // `requireUserAuth` reads the session cookie and populates `req.user` with
  // the signed-in app user; `req.body.fingerprint` was forwarded by the SDK.
  const r = await fetch(`https://${SPACE}/api/fabric/subscribers/tokens`, {
    method: "POST",
    headers: { Authorization: BASIC_AUTH, "Content-Type": "application/json" },
    body: JSON.stringify({
      reference: req.user.email,         // identifies the SignalWire user
      fingerprint: req.body.fingerprint, // binds the SAT to this browser
      scope: "sat:refresh",              // lets the SDK refresh without your backend
    }),
  });
  const { token } = await r.json();
  res.json({ token, expiresAt: Date.now() + 2 * 60 * 60 * 1000 });
});

For the rotation endpoints, refresh events you can subscribe to, and failure modes, see CredentialProvider.

When both paths are configured

You can provide a refresh() method and request sat:refresh scope. The SDK picks one mechanism per session: if the SAT carries sat:refresh scope, the client-side (Client Bound SAT) path wins and your refresh() is never called; otherwise it falls back to your refresh(). This makes refresh() a safe backstop, if the backend ever drops the scope, the session keeps rotating instead of dying at expiry.

That fallback is silent by default. To observe it, for example, to alert when a deployment expected to use bound tokens has downgraded to a developer-managed refresh, subscribe to client.warnings$:

Browser

client.warnings$.subscribe((warning) => {
  if (warning.code === "credential_refresh_fallback") {
    // e.g. reason: "no-scope", the SAT was minted without sat:refresh
    console.warn("Refresh fell back to developer refresh():", warning.reason);
  }
  if (warning.code === "credential_no_refresh_handler") {
    // Token has an expiry but no refresh path, session ends at expiry.
    console.warn("Session will end at", new Date(warning.expiresAt));
  }
});

Connection lifecycle

Constructing SignalWire runs three steps in sequence: authenticate the SAT, open the WebSocket, and register the user as online. Each step runs by default, and each can be deferred with a constructor option in SignalWireOptions so your UI can drive it later.

StepDefaultDefer withRun later with
Open the WebSocketrunsskipConnection: trueclient.connect()
Register as onlinerunsskipRegister: trueclient.register()
Persist across reloadsoffpersist: true(constructor only)

Browser

const client = new SignalWire(credentialProvider, {
  skipConnection: true,
  skipRegister: true,
});

await client.connect();    // open the WebSocket when the user opts in
await client.register();   // come online for inbound calls

Going online and offline

register() tells SignalWire the user is online on this session, so inbound calls and presence events route here. It runs automatically when the client constructs unless skipRegister: true is set, defer it when the user needs to opt in (microphone prompt, “Go online” toggle, permissions step) before they start receiving calls.

unregister() is the opposite: the user goes offline for inbound calls, but the WebSocket stays open so outbound calls and observable subscriptions keep working. Use it for Do Not Disturb, app-background, or “available / away” toggles.

Browser

await client.unregister();   // go offline; socket stays open
await client.register();     // come back online later

Closing the session entirely is a separate step. disconnect() closes the WebSocket, and destroy() wipes persisted state on explicit logout.

Only credentials issued with full user (Subscriber) identity can register. Guest SATs, Invite SATs, and embed-derived Guest SATs are outbound-only, so register() is a no-op on those clients, inbound calls require a full Subscriber Access Token.

Try it: create a SAT and connect

Create a Subscriber Access Token (SAT) for your project using the request snippet below. Have your space name and an API token ready, with at least one of the Voice / Messaging / Fax / Video scopes. Both come from the API Credentials section of the SignalWire dashboard. Open the Create Subscriber Token reference to send the request with your space and credentials filled in.

The Project API Token can issue a SAT for any user in your project. Use a development project, or rotate the API token afterward.

POST

/api/fabric/subscribers/tokens

cURL

curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
     -H "Content-Type: application/json" \
     -u "<project_id>:<api_token>" \
     -d '{
  "reference": "john.doe@example.com"
}'

Try it

Copy the returned token, save the page below as auth-demo.html, and open it in a browser. Paste the SAT into the input, click Authenticate, and watch the log. It reports whether the SDK was able to open a session with the SAT.

auth-demo.html, full source
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SignalWire SDK auth demo</title>
    <style>
      body { font: 14px/1.5 system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
      label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
      input { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
      button { margin-top: 0.75rem; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
      button[disabled] { opacity: 0.5; cursor: wait; }
      #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 5rem; white-space: pre-wrap; border-radius: 4px; }
    </style>
  </head>
  <body>
    <h1>SignalWire SDK auth demo</h1>

    <label for="token">Subscriber Access Token</label>
    <input id="token" type="password" placeholder="Paste your SAT here" />
    <button id="connect">Authenticate</button>

    <pre id="log"></pre>

    <script type="module">
      import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";

      const log = (msg) =>
        (document.getElementById("log").textContent += msg + "\n");

      document.getElementById("connect").addEventListener("click", () => {
        const token = document.getElementById("token").value.trim();
        if (!token) return log("Paste a token first.");

        const button = document.getElementById("connect");
        button.disabled = true;
        log("Connecting...");

        const provider = new StaticCredentialProvider({ token });
        const client = new SignalWire(provider);

        const readySub = client.ready$.subscribe((ready) => {
          if (ready) {
            log("Authenticated, WebSocket open.");
            readySub.unsubscribe(); // one-shot: stop after the first ready signal
          }
        });

        const errorsSub = client.errors$.subscribe((err) => {
          log("Failed: " + (err.name || "Error") + ", " + err.message);
          button.disabled = false;
          errorsSub.unsubscribe(); // when you're done
        });
      });
    </script>
  </body>
</html>

You should see Authenticated, WebSocket open. in the log. If you see Failed: InvalidCredentialsError, the SAT is expired, malformed, or issued for a different SignalWire space than the SDK is connecting to. Create a fresh one and try again.

Next steps

Inbound Calls\ \ Receive incoming calls in a signed-in user session. Outbound Calls\ \ Dial users, rooms, or PSTN destinations. Subscribers\ \ Platform concept: who a credential represents and what addresses they can reach.


Call Controls

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Every control on a call follows the same shape: call a mutator,subscribe to the matching $ observable for state. The server is the source of truth, a moderator can mute you, the room can lock itself, the platform can disconnect. Local state (let isMuted = …) will drift; the observable won’t.

This page covers the pattern. Per-method details live in the reference.

You’ll need an active call. Call controls operate on a Call instance, get one of these going first.

Set up the client\ \ Install the SDK and create a SignalWire client with a credential provider. Place an outbound call\ \ Dial a destination with client.dial() to get a Call instance. Answer an inbound call\ \ Subscribe to client.session.incomingCalls$ and call.answer().

The pattern

call.self$.subscribe((self) => {
  if (!self) return;

  // Trigger
  muteBtn.onclick = () => self.toggleMute();

  // Reflect, fires once with the current state, then on every change
  self.audioMuted$.subscribe((muted) => {
    muteBtn.classList.toggle("muted", muted);
    muteBtn.textContent = muted ? "Unmute" : "Mute";
  });
});

Every other control is the same shape with different names, toggleMuteVideo / videoMuted$, toggleDeaf / deaf$, toggleHandraise / handraised$, etc.

Three properties make this work:

  • Toggles are idempotent. Calling toggleMute mid-flight is safe, the SDK serializes.
  • $ observables emit on subscribe. The current value arrives immediately; no wait-for-event step.
  • The mutator only triggers the change; the observable closes theloop. A moderator-initiated mute that never went through the button still updates the UI, because audioMuted$ emits.

Where each control lives

Three objects own the controls:

  • Call, session-level: hangup, send DTMF, lock, hold, transfer, layout (see Layouts).
  • SelfParticipant (call.self), your own state: mute, deaf, hand raise, screen share, audio processing, your volume.
  • Participant (entries in call.participants$), moderation actions on other members. Gated by capabilities, see below.

The split mirrors server-side authorization: ending a call needs the end capability, kicking someone needs member.remove, muting yourself is unconditional.

Mute vs. deaf vs. hold

Mute vs. deaf

Mute silences what you send. Deaf silences what you hear. They’re independent, you can be deaf without being muted (you keep talking, but you can’t hear responses). Useful when the user steps away briefly without leaving the room.

Mute vs. hold vs. push-to-talk

Three ways to stop transmitting audio, and they’re not interchangeable:

ActionWhat it doesLatencyUse for
toggleMuteDisables the audio track server-sideRound-tripStandard mute button
toggleHoldPauses media transmission for the whole callRound-trip”Be right back” / call park
Push-to-talk (local pipeline)Sets local mic gain to 0, track stays aliveInstant (no round-trip)Walkie-talkie UIs

For instant talk/silence transitions (e.g. holding spacebar), use push-to-talk, mute would feel laggy because the round-trip is visible to the user:

call.enablePushToTalk();
document.addEventListener("keydown", (e) => {
  if (e.code === "Space") call.setPushToTalkActive(true);
});
document.addEventListener("keyup", (e) => {
  if (e.code === "Space") call.setPushToTalkActive(false);
});

The local audio pipeline also gives you localAudioLevel$ for a real-time meter and localSpeaking$ for VAD-based speaking detection, both are observables of the local mic, computed client-side, fast enough for ~30fps UI updates.

DTMF, timing matters

sendDigits only succeeds once status$ is 'connected'. Sending before media is negotiated will fail or be dropped:

import { filter, take } from "rxjs";

call.status$
  .pipe(filter((s) => s === "connected"), take(1))
  .subscribe(async () => {
    await call.sendDigits("1234#");
  });

For interactive dialpads (digits sent as the user presses), wire the button click directly, by that point the call is connected.

Moderation, check the capability first

Methods on other participants exist (participant.mute(), participant.remove(), participant.setPosition()), but calling them without the corresponding capability throws server-side. Drive the UI off SelfCapabilities.member$:

call.self?.capabilities.member$.subscribe((member) => {
  kickButton.hidden = !member.remove;
  muteOthersButton.disabled = !member.muteAudio.on;
});

If the flag is false, hide the button. See Capabilities for the full model.

Handling unsuccessful attempts

Most toggles resolve cleanly, but three categories can throw, handle them, don’t let an unhandled rejection bubble up:

SourceWhen it throwsWhat to do
Browser permissionunmuteVideo() or selectVideoInputDevice() after the user denied camera access at the OS levelCatch NotAllowedError and prompt the user to re-enable in settings
Server capabilityModeration methods (participant.mute(), participant.remove()) without the right capabilityGate the button on SelfCapabilities, don’t catch, just hide it
Connection statesendDigits() before status$ is 'connected', anything after the call has endedSubscribe to status$ and gate calls on 'connected'

Only browser-permission errors need a try/catch on each click. Capability errors shouldn’t be reachable, gate the button instead. Connection-state errors are prevented by waiting on status$ (see DTMF, timing matters).

muteVideoBtn.onclick = async () => {
  try {
    await call.self.toggleMuteVideo();
  } catch (err) {
    if (err?.name === "NotAllowedError") {
      showToast("Camera access is blocked. Allow camera in your browser settings.");
      return;
    }
    console.error("Failed to toggle video:", err);
  }
};

The pure-mute toggles ( toggleMute, toggleDeaf) don’t require any browser permission, they only flip server-side state, so they won’t throw on permission. They can still throw on capability or connection state.

Example: control bar

Every button uses the same mutator + observable shape:

call.self$.subscribe((self) => {
  if (!self) return;

  // Triggers
  muteBtn.onclick   = () => self.toggleMute();
  videoBtn.onclick  = () => self.toggleMuteVideo();
  deafBtn.onclick   = () => self.toggleDeaf();
  handBtn.onclick   = () => self.toggleHandraise();
  hangupBtn.onclick = () => call.hangup();

  // Reflections
  self.audioMuted$.subscribe((m)   => muteBtn.classList.toggle("muted", m));
  self.videoMuted$.subscribe((m)   => videoBtn.classList.toggle("muted", m));
  self.deaf$.subscribe((d)         => deafBtn.classList.toggle("active", d));
  self.handraised$.subscribe((h)   => handBtn.classList.toggle("active", h));
});

Volume sliders, audio-processing toggles, the screen-share button, and moderation actions all follow the same shape.

Reference

For the full surface, every method and every observable, see the per-class reference:

  • Participant, mute, deaf, hand raise, audio processing, server-mixed volumes; also the surface for moderation actions on other members ( remove, end, setPosition).
  • SelfParticipant, adds enableStudioAudio / disableStudioAudio on top of Participant.
  • WebRTCCall, session-level controls: hangup, sendDigits, toggleLock, toggleHold, transfer, local-mic pipeline ( setLocalMicrophoneGain, localAudioLevel$, localSpeaking$, enablePushToTalk, setPushToTalkActive).
  • SelfCapabilities, server-authoritative flags for gating moderation UI; see Capabilities.

Capabilities

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Capabilities are the permissions the server granted this participant in this call. Different across rooms, roles, and token scopes, and they’re the single source of truth for what your UI should let theuser do. The SDK surfaces them as call.self.capabilities, a SelfCapabilities instance with both synchronous getters and observable streams.

Use capability flags to decide which UI affordances to render. Don’t guess from token type or hard-code “agents can lock rooms”, the server already knows, and the capability stream reflects what it decided for this specific call.

When capabilities are populated

Once a call reaches joined, the server sends a call.joined event carrying the participant’s capability flags. The SDK decodes them into a structured SelfCapabilities object on call.self:

const call = await client.dial("/private/team", { audio: true, video: true });

call.self$.subscribe((self) => {
  if (!self) return;

  // Synchronous read, current state at this moment
  console.log(self.capabilities.end);                // can end the call?
  console.log(self.capabilities.self.muteAudio.on);  // can mute my own audio?
  console.log(self.capabilities.member.remove);      // can remove others?
});

SelfCapabilities exposes both synchronous getters (end, screenshare, setLayout, …) and $-suffixed observables that re-emit when capabilities change. State updates are fullreplacements, a new call.joined swaps the entire state object, not a partial merge.

Mid-call changes

Capabilities only re-emit when the server sends a fresh call.joined event. If your application supports role promotions (guest → host, attendee → moderator) and you need the UI to react, the server has to re-emit call.joined for that participant. The SDK supports nested call.joined events and will update the capability state when one arrives, but it won’t synthesize updates on its own.

The shape of SelfCapabilities

SelfCapabilities groups flags into two families. Each capability is exposed in two forms, an observable (e.g. end$) for reactive bindings and a synchronous getter (e.g. end) for snapshot reads.

  • Member-level: what can be done to a member. The same eleven fields ( MemberCapabilities) apply twice:

    • self$ / self, what I can do to myself.
    • member$ / member, what I can do to other members.

On self, flags like remove and position read as self-actions (can I leave the call, can I move my own tile); on member the same flags read as moderation (can I kick others, can I move their tile).

  • Call-level: end$, setLayout$, sendDigit$, screenshare$, device$, plus on/off-split lock$ and vmutedHide$.

The eleven member-level fields:

FieldTypeWhat it gates
muteAudioOnOffCapabilityMute / unmute the member’s audio.
muteVideoOnOffCapabilityMute / unmute the member’s video.
deafOnOffCapabilityDeafen / un-deafen the member (stop receiving audio from others).
raisehandOnOffCapabilityRaise / lower the member’s hand.
microphoneVolumebooleanAdjust the member’s microphone volume.
microphoneSensitivitybooleanAdjust the member’s microphone sensitivity.
speakerVolumebooleanAdjust the member’s speaker volume.
positionbooleanChange the member’s position in the layout.
metabooleanSet arbitrary metadata on the member.
removebooleanRemove the member from the call.
audioFlagsbooleanChange audio-related flags (mute, deaf) for the member.

Each member flag is either a boolean (the action is allowed or not) or an OnOffCapability, which separates “can turn this on” from “can turn this off” because some roles can do one but not both (e.g. a moderator who can lock a room while only the host can unlock it).

Driving UI from capabilities

The pattern: subscribe once to the observable you care about, toggle the affordance, let the stream update it forever.

const self = call.self; // SelfParticipant

self.capabilities.end$.subscribe((canEnd) => {
  endCallButton.hidden = !canEnd;
});

self.capabilities.screenshare$.subscribe((canShare) => {
  shareScreenButton.disabled = !canShare;
});

self.capabilities.setLayout$.subscribe((canLayout) => {
  layoutMenu.hidden = !canLayout;
});

// Self-mute flags split on/off
self.capabilities.self$.subscribe((self) => {
  muteAudioButton.disabled = !self.muteAudio.on && !self.muteAudio.off;
});

// Moderation actions on other members
self.capabilities.member$.subscribe((member) => {
  kickButton.hidden = !member.remove;
  moveButton.hidden = !member.position;
});

If you’re using the web components, <sw-call-controls> already does this internally, buttons hide themselves when the corresponding capability isn’t granted. You only need the manual wiring when building a custom UI.

Reading the full state

state$ emits the entire CallCapabilitiesState on every change. Useful if you serialize the capability set into your own store:

self.capabilities.state$.subscribe((state) => {
  uiStore.setCapabilities(state);
});

Why not just gate on token type?

It’s tempting to skip the capability stream and say “guests can’t end calls” in the UI. Two reasons not to:

  1. The same token can have different capabilities in differentrooms. Rooms can override permissions per-resource. The capability stream reflects the resolved permission for this call.
  2. Capabilities can be re-evaluated mid-call. When the server re-emits call.joined after a permission change, the capability stream reflects the new state. Token-based gating would be stuck on the value the token was minted with.

The local capability stream and the server are always in sync because they come from the same call.joined event. Trust it.

Server-side enforcement

Capabilities you see locally are exactly what the platform enforces, calling participant.remove() without the member.remove capability will fail server-side. The local checks are UX, not security: the server is the authority, the flags exist so your UI doesn’t show buttons that would error out.

Presence

Per-user presence isn’t currently exposed through the SDK. Derive online state from your own application telemetry, a last-seen heartbeat from your backend, or “currently in a call” tracked from your own call lifecycle webhooks.

Reference

  • SelfCapabilities, the class
  • self$ / self, self capabilities
  • member$ / member, other-member capabilities
  • end$, setLayout$, sendDigit$, screenshare$, device$, lock$, vmutedHide$, call-level capabilities
  • MemberCapabilities, OnOffCapability, CallCapabilitiesState, the data shapes

Client Preferences

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

client.preferences holds per-client defaults the SDK reads when no per-call options override them: which mic / camera to use, whether to receive video by default, ICE / recovery tuning, codec ordering, and custom userVariables attached to every call. Preferences live in the browser, optionally persist to localStorage, and are distinct from per-User configuration (which lives on the platform, see Users).

This page covers how preferences fit into the SDK lifecycle. For the full property list, see ClientPreferences.

Defaults vs. per-call overrides

client.preferences  ←  defaults

client.dial(dest, options)  ←  per-call overrides win

Anything set on preferences applies to every subsequent dial() that doesn’t pass a competing field. Per-call options always win:

client.preferences.receiveVideo = true;      // receive remote video by default

// This one call stays audio-only:
await client.dial("/private/team", { receiveVideo: false });

Use preferences for app-wide defaults (codec ordering, a tier-wide userVariables payload). Use per-call options for situational values.

For example, codec ordering is an app-wide default, the array is a priority list of codec names, and it’s overridable per call:

// Prefer Opus, fall back to G.711
client.preferences.preferredAudioCodecs = ["opus", "PCMU"];

// One call insists on G.711:
await client.dial("/private/team", { preferredAudioCodecs: ["PCMU"] });

Common preferences

The full surface is documented in the ClientPreferences reference. These are the ones most apps touch, with their code defaults:

PreferenceDefaultControls
receiveVideofalsewhether to accept inbound video on a call
preferredAudioCodecs[]audio codec priority order
connectionTimeout10 (s)WebSocket connect timeout
degradationBitrateThreshold150 (kbps)bitrate below which video auto-disables
degradationRecoveryThreshold300 (kbps)bitrate above which video re-enables

Persistence

By default, preferences live in memory only. Set savePreferences: true to hydrate from localStorage on startup and write back on every setter:

const client = new SignalWire(provider, { savePreferences: true });

The following details are persisted: timeouts, ICE / recovery tuning, codec preferences, stats and device-management flags, and userVariables.

Device selections persist separately, and are on by default. Independent of savePreferences, the device controller writes your mic / camera / speaker selections to localStorage (keyed by deviceId, keeping label / groupId to re-match when IDs rotate across sessions) and restores them next time. This is governed by the persistDeviceSelection preference (default true); set it to false to opt out.

For a different storage backend (IndexedDB, server-side per user), leave savePreferences off and mirror manually:

function setReceiveVideo(value: boolean) {
  client.preferences.receiveVideo = value;
  myStore.set("receiveVideo", value);
}

ClientPreferences is a synchronous object, there is no update$ observable. Preferences are read at dial time.

userVariables

userVariables is a free-form payload attached to every outbound call. The receiving side (an AI agent, a SWML script, a backend) reads it.

client.preferences.userVariables = {
  plan:   user.plan,
  locale: navigator.language,
};

Set on preferences for app-wide values; pass to dial() for per-call attribution.

Time units

Timeouts on the preferences surface are exposed in seconds (stored as milliseconds internally):

client.preferences.connectionTimeout = 30;   // 30 seconds
client.preferences.iceRestartTimeout = 10;   // 10 seconds

Other fields use the unit of the underlying API (kbps, integer levels, etc.).

Keyframe recovery

A video stream consists of occasional keyframes, complete, self-contained frames, each followed by delta frames that encode only the change from the previous frame. A lost or corrupted delta frame corrupts every frame after it until the next keyframe arrives. The receiver can request one early via an RTCP feedback message:

  • PLI, Picture Loss Indication: standard picture-loss recovery.
  • FIR, Full Intra Request: forces a full intra frame from scratch, e.g. when a new participant or recorder joins mid-stream with no reference frame.

Keyframes are large, so the SDK rate-limits these requests as a burst with cooldown:

PreferenceDefaultRole
keyframeMaxBurst3max requests per window
keyframeBurstWindow3000 mslength of the counting window
keyframeCooldown10000 mspause once the burst is spent

Up to keyframeMaxBurst requests are allowed per keyframeBurstWindow; once that limit is hit, requests pause for keyframeCooldown. Defaults: three requests per three-second window, then a ten-second cooldown.

Reference

  • ClientPreferences, the property surface
  • SignalWire.preferences, the instance
  • SignalWireOptions, savePreferences, skipDeviceMonitoring, reconnectAttachedCalls, persistSession
  • SignalWire.dial(), per-call overrides

Overview

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The Browser SDK runs entirely in the browser, there’s no server-side runtime to deploy. What you do deploy is the host application that loads it, plus the small backend surface that mints tokens for your users. This section covers the four things that consistently come up between “it works on my laptop” and “it works in production”:

Framework integration\ \ Idiomatic patterns for wrapping the SDK in React, Vue, Svelte, and\ Angular, managing client lifetimes, subscribing observables into\ component state, and avoiding double-mount pitfalls in dev mode. SSR & Next.js\ \ The SDK is browser-only. Cover dynamic imports, "use client"\ boundaries, and route handlers that mint tokens without leaking\ your project credentials. Troubleshooting\ \ Symptom → cause → fix for the issues that show up most often:\ expired tokens, black video, autoplay-blocked audio, ICE\ failures, and browser quirks.

What ships where

A Browser SDK app is split between two runtimes:

Lives in the browserLives on your backend
@signalwire/js (or @signalwire/web-components)Endpoint that mints SATs / embed tokens
Your UIYour auth / user system
WebRTC peer connection(Optionally) webhook handlers for incoming calls

The SDK never sees your SignalWire API credentials. Project ID and auth token live exclusively on the backend; the browser only ever holds a short-lived JWT it received from your token endpoint. This is the most important invariant to preserve across every framework, deployment target, and CDN setup discussed in this section.

A minimal production topology

   1. fetch("/api/sw-token")    
  Browser                    Your backend            
   • @signalwire/js                                         • POST /api/sw-token   
   • Your UI                  • mints SAT via REST   
                            2. { token, expiry_at }         • uses PROJECT creds   
                                                              (env var, secret)    
                                                         
                            3. WebSocket → SignalWire 
                                                           SignalWire

Everything else (CDN choice, framework, SSR strategy) is a variation on this shape. The remaining pages in this section walk through the practical details of each layer.


Device Management

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

To let users pick which microphone, camera, and speaker a call uses, subscribe to the device lists, render a picker, and apply the user’s choice, either as a preference for the next call or as a live swap during one. The same APIs cover hot-plug events, so a new headset shows up in the list as soon as it’s connected.

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));

// 1. Prompt for permission so devices come back with real labels.
await client.requestMediaPermissions({ audio: true, video: true });

// 2. Render the lists; they re-emit when devices are plugged or unplugged.
client.audioInputDevices$.subscribe((mics) => renderMicOptions(mics));
client.videoInputDevices$.subscribe((cams) => renderCamOptions(cams));
client.audioOutputDevices$.subscribe((speakers) => renderSpeakerOptions(speakers));

// 3. Apply the user's choice. Before a call, set the preference on the client;
//    during a call, switch the live track on `call.self`.
micSelect.onchange = () => {
  const mic = client.audioInputDevices.find((d) => d.deviceId === micSelect.value);
  if (activeCall?.self) activeCall.self.selectAudioInputDevice(mic);
  else client.selectAudioInputDevice(mic);
};

The sections below cover requesting permission, reading the device lists, applying the user’s pick before a call and again mid-call, routing remote audio to the chosen speaker, and reacting when a device disappears.

Before you start. Devices live behind the browser’s permission gate. getUserMedia only runs on secure origins (HTTPS or localhost), and device labels are empty strings until the user has granted access at least once. Plan the UX so picking a device comes after the prompt, not the other way around.

The three device kinds

Each kind comes with its own pair of observables: one for the list of available devices, and one for the current selection. Subscribe to both whenever you render a picker so the <select> reflects what’s actually in use, including changes that happen without an explicit user pick, such as a recovery after the active device was unplugged.

Microphones

audioInputDevices$ lists every mic; selectedAudioInputDevice$ tracks the current pick.

Cameras

videoInputDevices$ lists every camera; selectedVideoInputDevice$ tracks the current pick.

Speakers

audioOutputDevices$ lists every speaker; selectedAudioOutputDevice$ tracks the current pick.

When you only need the current snapshot, for example, populating a dropdown once on click, read the non-$ accessor instead: client.audioInputDevices, client.videoInputDevices, client.audioOutputDevices. Use the observable when the UI should keep up with hot-plugs and SDK-driven switches.

Prompt for permission

Each list populates from navigator.mediaDevices.enumerateDevices(). Until the user grants permission, that call returns devices with empty label strings, which makes a picker UI useless. Call requestMediaPermissions() once at startup to drive the prompt and re-enumerate with labels filled in:

Browser

const result = await client.requestMediaPermissions({ audio: true, video: true });

if (!result.audio || !result.video) {
  showPermissionBanner("Allow microphone and camera in your browser to continue.");
}

The returned PermissionResult reports which scopes were granted and which device the browser handed back for each kind. Those devices become the initial selection unless you’ve already set a preference. The browser only shows the prompt the first time per origin; later calls resolve immediately with the existing grant.

If the user denies the prompt, enumerateDevices() still returns the device list, but with empty labels. Show a “permission needed” banner instead of an empty dropdown, and link to the browser’s lock-icon controls so they can grant it later.

Read the device lists

Each list is an observable of MediaDeviceInfo[]. Subscribe once at startup, every subscription receives the current list immediately and then again whenever the OS reports a change. Pair the list with the matching selected…$ observable so a <select> reflects the right value when the SDK switches devices on its own:

Browser

let mics = [];
let selectedMic = null;

function renderMics() {
  micSelect.innerHTML = "";
  for (const mic of mics) {
    const option = new Option(mic.label || `Microphone ${mic.deviceId.slice(0, 6)}`, mic.deviceId);
    option.selected = mic.deviceId === selectedMic?.deviceId;
    micSelect.append(option);
  }
}

client.audioInputDevices$.subscribe((list) => {
  mics = list;
  renderMics();
});

client.selectedAudioInputDevice$.subscribe((device) => {
  selectedMic = device;
  renderMics();
});

The same pattern fits videoInputDevices$ and audioOutputDevices$, only the property names change. Caching the latest of each list in module-scope state keeps the rendering pure and avoids pulling in observable combinators.

Apply the user’s choice

There are two scopes for “use this device.” Match the scope to the moment the user picks:

Before a call
During a call

Set a preference on the client. The selection sticks for every future dial() and answer(), so the next outbound call is captured from the right mic and camera without any extra wiring. Wire the picker’s change event to look up the chosen MediaDeviceInfo from the live list and pass it to selectAudioInputDevice() or selectVideoInputDevice():

Browser

// <select id="mic-select"> populated from client.audioInputDevices$
micSelect.onchange = () => {
  const mic = client.audioInputDevices.find(
    (d) => d.deviceId === micSelect.value,
  );
  // null clears the preference and falls back to the system default.
  client.selectAudioInputDevice(mic ?? null);
};

camSelect.onchange = () => {
  const camera = client.videoInputDevices.find(
    (d) => d.deviceId === camSelect.value,
  );
  client.selectVideoInputDevice(camera ?? null);
};

// Subsequent dials use the preference automatically.
const call = await client.dial("/private/alice", { audio: true, video: true });

The next dial() captures from the chosen mic and camera with no extra arguments. To change the default back later, look up a new device the same way, or pass null to clear the preference.

In practice, a single picker handler covers both scopes, fall through to the client-level preference when there’s no active call, replace the live track when there is:

Browser

let activeCall = null;

function pickMicrophone(): void {
  const mic = client.audioInputDevices.find(
    (d) => d.deviceId === micSelect.value,
  );
  if (!mic) return;

  if (activeCall?.self) {
    // Live swap; mid-call only.
    activeCall.self.selectAudioInputDevice(mic, { savePreference: true });
  } else {
    // Preference for the next dial()/answer().
    client.selectAudioInputDevice(mic);
  }
}

micSelect.onchange = pickMicrophone;

// Keep `activeCall` in sync with whatever call the user is on right now.
client.dial("/private/alice").then((call) => {
  activeCall = call;
  call.status$.subscribe((s) => {
    if (s === "destroyed") activeCall = null;
  });
});

Speakers follow a slightly different path, see the next section.

Route remote audio to the chosen speaker

Selecting a speaker takes an extra step the other devices don’t, most browsers route audio output through the <video> or <audio> element the remote stream is attached to, and switching the sink is an element-level call. applySelectedAudioOutputDevice() wraps HTMLMediaElement.setSinkId() so you don’t have to:

Browser

client.selectAudioOutputDevice(speaker);
const applied = await client.applySelectedAudioOutputDevice(remoteVideo);
if (!applied) {
  // Browser doesn't support setSinkId, fall through to the system default.
}

The method returns true when the sink was changed and false when no speaker is selected or the browser doesn’t support setSinkId. Call it once after binding the remote stream to the element, and again any time the user picks a new speaker.

Firefox doesn’t ship setSinkId yet.applySelectedAudioOutputDevice() returns false on Firefox and audio plays through the system default speaker. Check the return value and surface a “speaker selection unavailable” notice rather than silently swallowing the choice, see the MDN compatibility table.

React to device changes

Hot-plug events are already covered by the list observables, adding a USB headset re-emits audioInputDevices$ with the new entry. What you usually want on top of that is a notification when the SDK automatically switches a device because the previous one was unplugged. Subscribe to deviceRecovered$:

Browser

client.deviceRecovered$.subscribe((event) => {
  toast(`${event.kind} switched to ${event.newDevice?.label ?? "system default"}`);
});

The DeviceRecoveryEvent carries everything you need to compose that notification:

FieldWhat it tells you
kindWhich kind switched: audioinput, videoinput, or audiooutput
previousDeviceThe device that was active before the swap (may be null if it was unplugged)
newDeviceThe device the SDK switched to (may be null if it fell back to the system default)
reasonWhy: device_disconnected, device_reconnected, fallback_to_default, and a few more

Reach for it when the user should know “we lost your AirPods and put you on the built-in mic” rather than discovering it mid-sentence.

Device monitoring is on by default. Use disableDeviceMonitoring() and enableDeviceMonitoring() to pause and resume it, handy on mobile when the page goes into the background and you don’t want hot-plug events firing while the user can’t see the UI.

Try it: enumerate and switch devices

The fastest way to see the device APIs end-to-end is a single page that wires the three observable lists to three <select> elements, dials a test destination on demand, and switches the mic, camera, or speaker on the live call from the same picker. The handler routes preference changes through client.* before the call and live-track swaps through activeCall.self.* while the call is up, so the same dropdowns exercise both branches of the Apply the user’s choice section.

1

Issue a Subscriber Access Token

Create a SAT for your project, the Authentication guide covers the production version of this flow; the Create Subscriber Token reference sends the request for you.

POST

/api/fabric/subscribers/tokens

cURL

curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
     -H "Content-Type: application/json" \
     -u "<project_id>:<api_token>" \
     -d '{
  "reference": "john.doe@example.com"
}'

Try it

2

Open the demo and request permission

Save the page below as devices-demo.html and open it over HTTPS (or localhost). Paste the SAT and click Connect, the log reports the WebSocket coming up. Click Request permission to drive the browser’s prompt; the three dropdowns populate with labelled devices.

Pick a different mic, camera, or speaker before dialing. The log records each pick as preference: …, that’s the client.select* branch in action. Plug or unplug a USB headset to see deviceRecovered$ fire.

devices-demo.html, full source
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SignalWire SDK device-management demo</title>
    <style>
      /* Shared demo shell, identical across the inbound, outbound, and
         device-management guides. Per-demo extras go below this block. */
      body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
      label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
      input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
      button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
      button[disabled] { opacity: 0.5; cursor: not-allowed; }
      .videos { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 1rem; }
      video { width: 100%; background: #000; border-radius: 4px; aspect-ratio: 4/3; }
      #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 6rem; white-space: pre-wrap; border-radius: 4px; }
      /* Device-management-specific */
      .devices { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem 1rem; margin-top: 1rem; }
      .devices > div { display: flex; flex-direction: column; }
    </style>
  </head>
  <body>
    <h1>SignalWire SDK device-management demo</h1>

    <label for="token">Subscriber Access Token</label>
    <input id="token" type="password" placeholder="Paste your SAT here" />

    <button id="connect">Connect</button>
    <button id="perms" disabled>Request permission</button>

    <div class="devices">
      <div>
        <label for="mic">Microphone</label>
        <select id="mic"><option>(none)</option></select>
      </div>
      <div>
        <label for="cam">Camera</label>
        <select id="cam"><option>(none)</option></select>
      </div>
      <div>
        <label for="speaker">Speaker</label>
        <select id="speaker"><option>(none)</option></select>
      </div>
    </div>

    <label for="destination">Destination</label>
    <input id="destination" type="text" placeholder="/public/test-room" />

    <button id="dial" disabled>Dial</button>
    <button id="hangup" disabled>Hang up</button>

    <div class="videos">
      <video id="local" autoplay muted playsinline></video>
      <video id="remote" autoplay playsinline></video>
    </div>

    <pre id="log"></pre>

    <script type="module">
      import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";

      const log = (msg) =>
        (document.getElementById("log").textContent += msg + "\n");
      const $ = (id) => document.getElementById(id);

      let client = null;
      let activeCall = null;

      // Cache the latest list + selection per kind so each subscription can
      // rerender the picker without pulling in observable combinators.
      const state = {
        mic: { list: [], selected: null },
        cam: { list: [], selected: null },
        speaker: { list: [], selected: null },
      };

      function populate(kind) {
        const selectEl = $(kind);
        const { list, selected } = state[kind];
        selectEl.innerHTML = "";
        for (const d of list) {
          const opt = new Option(d.label || `Device ${d.deviceId.slice(0, 6)}`, d.deviceId);
          if (selected && d.deviceId === selected.deviceId) opt.selected = true;
          selectEl.append(opt);
        }
        if (!list.length) selectEl.innerHTML = "<option>(none)</option>";
      }

      $("connect").onclick = () => {
        const token = $("token").value.trim();
        if (!token) return log("Paste a token first.");

        $("connect").disabled = true;
        log("Connecting...");

        client = new SignalWire(new StaticCredentialProvider({ token }));

        client.errors$.subscribe((err) =>
          log("Error: " + (err.name || "Error") + ", " + err.message),
        );

        client.audioInputDevices$.subscribe((list) => { state.mic.list = list; populate("mic"); });
        client.selectedAudioInputDevice$.subscribe((d) => { state.mic.selected = d; populate("mic"); });
        client.videoInputDevices$.subscribe((list) => { state.cam.list = list; populate("cam"); });
        client.selectedVideoInputDevice$.subscribe((d) => { state.cam.selected = d; populate("cam"); });
        client.audioOutputDevices$.subscribe((list) => { state.speaker.list = list; populate("speaker"); });
        client.selectedAudioOutputDevice$.subscribe((d) => { state.speaker.selected = d; populate("speaker"); });

        client.deviceRecovered$.subscribe((e) =>
          log(`auto-switch: ${e.kind} (${e.reason}) → ${e.newDevice?.label ?? "system default"}`),
        );

        $("perms").disabled = false;
        $("dial").disabled = false;
        log("Connected. Click Request permission to populate labels.");
      };

      $("perms").onclick = async () => {
        const result = await client.requestMediaPermissions({ audio: true, video: true });
        log(`permission: audio=${result.audio} video=${result.video}`);
      };

      // Single picker handler used by all three <select> elements.
      // Routes through call.self when a call is up, client.* otherwise, // matches the combined handler from the "Apply the user's choice" section.
      function pick(kind) {
        const list =
          kind === "mic" ? client.audioInputDevices :
          kind === "cam" ? client.videoInputDevices :
          client.audioOutputDevices;
        const device = list.find((d) => d.deviceId === $(kind).value);
        if (!device) return;

        if (activeCall?.self && kind !== "speaker") {
          if (kind === "mic") activeCall.self.selectAudioInputDevice(device, { savePreference: true });
          if (kind === "cam") activeCall.self.selectVideoInputDevice(device, { savePreference: true });
          log(`live: ${kind} → ${device.label}`);
        } else if (kind === "speaker") {
          client.selectAudioOutputDevice(device);
          client.applySelectedAudioOutputDevice($("remote"));
          log(`speaker → ${device.label}`);
        } else {
          if (kind === "mic") client.selectAudioInputDevice(device);
          if (kind === "cam") client.selectVideoInputDevice(device);
          log(`preference: ${kind} → ${device.label}`);
        }
      }

      $("mic").onchange = () => pick("mic");
      $("cam").onchange = () => pick("cam");
      $("speaker").onchange = () => pick("speaker");

      $("dial").onclick = async () => {
        const to = $("destination").value.trim();
        if (!to) return log("Enter a destination first.");
        $("dial").disabled = true;
        log("Dialing " + to + "...");
        try {
          activeCall = await client.dial(to, { audio: true, video: true });
          $("hangup").disabled = false;

          activeCall.localStream$.subscribe((s) => ($("local").srcObject = s));
          activeCall.remoteStream$.subscribe(async (s) => {
            $("remote").srcObject = s;
            if (client.selectedAudioOutputDevice) {
              await client.applySelectedAudioOutputDevice($("remote"));
            }
          });
          activeCall.status$.subscribe((status) => {
            log("Status: " + status);
            if (status === "destroyed") {
              activeCall = null;
              $("dial").disabled = false;
              $("hangup").disabled = true;
            }
          });
        } catch (err) {
          log("dial() rejected: " + (err.name || "Error") + ", " + err.message);
          $("dial").disabled = false;
        }
      };

      $("hangup").onclick = () => { if (activeCall) activeCall.hangup(); };
    </script>
  </body>
</html>

3

Switch a device mid-call

Enter a destination in the Destination field (a /public/<resource>, /private/<user>, PSTN number, or SIP URI the token can reach, see Outbound Calls for the destination shapes) and click Dial. The local and remote tiles populate once the call connects.

With the call connected, change the Microphone or Camera dropdown again. The log now records the change as live: …, the picker is routing through activeCall.self.selectAudioInputDevice() instead of the client-level preference, and the remote side hears or sees the new device immediately without renegotiation. Picking a new Speaker triggers applySelectedAudioOutputDevice() on the remote <video> element so the sink swaps too.

Click Hang up and try the dropdowns again, the log goes back to preference: … because activeCall is null. That’s the combined handler from the Apply the user’s choice section, end to end.

Next steps

Troubleshooting\ \ Black video, missing audio, denied permissions. DeviceController reference\ \ Every device-related property and method on the client.


Framework Integration

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The Browser SDK is framework-agnostic, every public surface is either a plain class (SignalWire, StaticCredentialProvider), a method returning a Promise, or an RxJS observable. Integration with React, Vue, Svelte, or Angular comes down to two questions:

  1. Lifecycle, when to construct the SignalWire client, and when to disconnect it.
  2. State, how to fold an observable into the framework’s reactivity system so your UI re-renders when call state changes.

The patterns below cover React. If you’re using the web components instead of the JS SDK directly, you only need to worry about lifecycle, the components manage their own state through context.

React

One client per app

Construct the client once at app start, share it through context, and disconnect it on unmount. Do not put new SignalWire(...) inside a render, it would re-run on every state change.

// src/signalwire-context.tsx
import { createContext, useContext, useEffect, useState } from "react";
import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const Ctx = createContext<SignalWire | null>(null);

export function SignalWireProvider({
  token,
  children,
}: {
  token: string;
  children: React.ReactNode;
}) {
  const [client, setClient] = useState<SignalWire | null>(null);

  useEffect(() => {
    const c = new SignalWire(new StaticCredentialProvider({ token }));
    setClient(c);
    return () => {
      c.disconnect();
    };
  }, [token]);

  return <Ctx.Provider value={client}>{children}</Ctx.Provider>;
}

export const useSignalWire = () => useContext(Ctx);

Keeping one client for the lifetime of the app doesn’t mean the user is always reachable. Use register() and unregister() to opt in and out of inbound calls without tearing down the WebSocket, and observe isRegistered$ to drive an “available / away” toggle in your UI.

Subscribing to observables

Wrap an observable in a hook that subscribes on mount and unsubscribes on unmount. The SDK uses BehaviorSubjects, so the current value emits synchronously on subscribe.

// src/hooks/use-observable.ts
import { useEffect, useState } from "react";
import type { Observable } from "rxjs";

export function useObservable<T>(obs: Observable<T> | undefined, initial: T) {
  const [value, setValue] = useState<T>(initial);
  useEffect(() => {
    if (!obs) return;
    const sub = obs.subscribe(setValue);
    return () => sub.unsubscribe();
  }, [obs]);
  return value;
}
function CallStatus({ call }: { call: WebRTCCall }) {
  const status = useObservable(call.status$, "idle");
  return <span>{status}</span>;
}

If you’re deriving an observable on the fly with operators like pipe, wrap it in useMemo so its identity is stable across renders. Otherwise the useEffect dependency in useObservable sees a new observable every render and resubscribes on each one.

Correct
Incorrect
const doubled$ = useMemo(
  () => source$.pipe(map((x) => x * 2)),
  [source$]
);

const value = useObservable(doubled$, 0);

Stable identity, one subscription for the life of the component.

Strict Mode and double mounting

React 18 Strict Mode mounts components twice in development to surface unsafe lifecycle effects. The pattern above is safe because the cleanup function disconnects the client, but if you await client.connect() outside useEffect, you’ll get a duplicate connection. Always put side effects inside useEffect.

If you’d rather not roll your own subscription hook, React 18+ ships useSyncExternalStore, which is designed for exactly this, subscribing to an external store in a way that’s safe under concurrent rendering and Strict Mode. You can adapt the useObservable hook above to call it instead of useState + useEffect.

Typed refs for web components

If you’re using @signalwire/web-components, the package ships a JSX type declaration so useRef<SwCallWidget> is fully typed:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["@signalwire/web-components/react"]
  }
}
import type { SwCallWidget } from "@signalwire/web-components";

function Dialer() {
  const widget = useRef<SwCallWidget>(null);
  return (
    <>
      <sw-call-widget ref={widget} token="…" destination="/public/sales" />
      <button onClick={() => widget.current?.dial()}>Dial</button>
    </>
  );
}

Patterns that apply everywhere

Subscribe immediately

The SDK uses BehaviorSubjects throughout. They emit their current value synchronously on subscribe, but only if you actually subscribe. A common bug is awaiting client.ready$.pipe(filter(Boolean), take(1)) after the client has already become ready, then waiting forever. Subscribe early; you’ll get the cached value.

Always unsubscribe

Memory leaks in long-lived sessions are almost always missing unsubscribes. Use the framework’s lifecycle hook (useEffect cleanup, onUnmounted, onDestroy, the async pipe) and resist the urge to “just keep it simple.” See the RxJS Primer for the patterns the SDK relies on.

One client per session, not one per component

Constructing a SignalWire opens a WebSocket. Sharing the client through context (React), provide/inject (Vue), getContext (Svelte), or a singleton service (Angular) avoids “why am I seeing two connections in the network panel” debugging.

Disconnect on unmount

The SDK doesn’t tear down its WebSocket when the host page is hot- reloaded, your cleanup hook has to call client.disconnect(). This matters most in dev mode (Vite, Next.js fast refresh) where unmount / mount cycles are frequent.

Snapshot getters for one-off reads

Most observables in the SDK come with a matching snapshot getter that returns the current value synchronously, for example audioMuted alongside audioMuted$. When you just need the value at a single point in time (inside an event handler, before issuing a command, in a one-shot log line), read the snapshot directly instead of subscribing:

if (selfParticipant.audioMuted) {
  await selfParticipant.unmute();
}

Reach for the $ observable only when you actually want your UI to react to changes over time.


Inbound Calls

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

To receive calls in a web app, bring a signed-in user online, show a ringing UI when someone calls them, and let them accept or decline. The result is a receiver you can call from any phone, SIP endpoint, or another browser tab.

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
import { filter, take } from "rxjs";

// Constructing the client authenticates and registers the user
// automatically. `await register()` here gives a sync point before we
// subscribe, see the Authentication guide for the credential lifecycle.
const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
await client.register();

// `showIncomingCall` / `hideIncomingCall` are your own UI helpers; `localVideo`
// and `remoteVideo` are references to your `<video>` elements. The SDK doesn't
// ship a UI, render the ringing state however fits your app.
client.session.incomingCalls$.subscribe((calls) => {
  const ringing = calls.find((c) => c.status === "ringing");
  if (!ringing) return;

  // Use fromName when it's a real display name; otherwise fall back to
  // from. SignalWire sends "_undef_" as a placeholder when the
  // originating leg didn't supply a name.
  const callerName =
    ringing.fromName && ringing.fromName !== "_undef_"
      ? ringing.fromName
      : ringing.from;

  showIncomingCall({
    from: callerName,
    onAccept: () => ringing.answer({ audio: true, video: true }),
    onDecline: () => ringing.reject(),
  });

  // Tear the UI down when the call leaves the "ringing" state.
  ringing.status$
    .pipe(filter((s) => s !== "ringing"), take(1))
    .subscribe(() => hideIncomingCall());

  // Attach media once the call connects (these only emit after accept).
  ringing.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
  ringing.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
});

Before you start. Inbound calls require a Subscriber Access Token (SAT) issued for a specific user. Embed tokens and guest tokens are outbound-only and can’t receive calls.

Listen for incoming calls

Subscribe to client.session.incomingCalls$. The stream emits the current list of inbound calls every time it changes, not one event per call, filter by status === "ringing" to find calls that still need a decision.

Browser

client.session.incomingCalls$.subscribe((calls) => {
  const ringing = calls.find((c) => c.status === "ringing");
  if (ringing) showIncomingCall(ringing); // your UI helper for the ringing state
});

Each entry is a Call with direction: "inbound". Display the caller from these properties:

PropertyWhat it is
fromThe caller’s address (e.g. /private/alice)
fromNameDisplay name, if the caller supplied one
toThe address that was dialed (useful when one user has aliases)
directionAlways "inbound" here

The SDK hands you the raw list, it doesn’t queue, dedupe, or pick a call for you. Two simultaneous callers land in the same emission, and calls stay in the array through every status transition (only dropping out when destroyed), so the status === "ringing" filter is what tells you which entries still need a decision.

Accept or decline

A ringing call ends one of three ways: the user accepts, the user declines, or the caller gives up. Use answer() to accept and reject() to decline; subscribe to status$ to detect any of the three so the ringing UI tears down from a single place.

Accept the call.answer() takes a MediaOptions object that controls which tracks the user sends back, audio defaults to true, video defaults to false:

Audio + video
Audio only
Video, mic muted

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
await client.register();

client.session.incomingCalls$.subscribe((calls) => {
  const ringing = calls.find((c) => c.status === "ringing");
  if (!ringing) return;
  ringing.answer({ audio: true, video: true });
});

Standard video call. Both tracks acquired from the selected mic and camera.

Decline the call.reject() declines before any media negotiates, the caller sees a normal decline; the session never picks up:

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
await client.register();

client.session.incomingCalls$.subscribe((calls) => {
  const ringing = calls.find((c) => c.status === "ringing");
  if (!ringing) return;
  ringing.reject();
});

Dismiss the ringing UI. Subscribe to the call’s status$ and dismiss on the first emission that isn’t "ringing". That single handler covers all three outcomes, accepted, declined, or caller-gave-up:

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
import { filter, take } from "rxjs";

const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
await client.register();

client.session.incomingCalls$.subscribe((calls) => {
  const ringing = calls.find((c) => c.status === "ringing");
  if (!ringing) return;

  ringing.status$
    .pipe(filter((s) => s !== "ringing"), take(1))
    .subscribe(() => hideIncomingCall()); // your UI helper to dismiss the ringing UI
});

After ringing, the call walks connectingconnecteddisconnected. React to connected for the in-call UI, and disconnected / destroyed for the final cleanup.

Attach the streams

Once the call is connected, attach the local and remote media to <video> elements. The shape is identical to an outbound call, bind the localStream$ and remoteStream$ observables to each element’s srcObject:

Browser

ringing.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
ringing.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
<video id="localVideo" autoplay muted playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>

The local element needs muted so the user doesn’t echo their own voice; the remote element must not be muted or no one is heard. Both need playsinline for mobile Safari.

End the call

Call hangup() when the user clicks the hang-up button or navigates away. The call transitions through disconnectingdisconnecteddestroyed; any subscriptions on the call complete naturally.

Browser

hangupButton.onclick = () => ringing.hangup();

If the user closes the tab without calling hangup(), the SDK still tears the call down when the page unloads. Calling hangup() explicitly gives you a clean point to dismiss the in-call UI before the connection drops.

To leave the page but keep the call alive on the platform, a transfer-and-disappear flow, use transfer() instead.

Try it: receive a call

The fastest way to verify inbound calls end-to-end is to issue a SAT, load the demo as your user, then place the call yourself with a server-side dial that runs inline SWML. The demo surfaces a ringing UI you can accept with Answer, then the SWML script plays a public test MP4 into the call, you see real audio and video on the receiving side without needing a second browser or a phone.

1

Issue a Subscriber Access Token

Pick a reference for the user the call will arrive on, usually an email, but any stable ID works. If a user (Subscriber) with that reference doesn’t exist yet, this endpoint creates one (set first_name, last_name, or password in the same body if you want); otherwise it returns a fresh token for the existing user.

The response token is the SAT you’ll plug into the demo. For the production version of this flow, where your backend issues SATs to clients, see the Authentication guide.

POST

/api/fabric/subscribers/tokens

cURL

curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
     -H "Content-Type: application/json" \
     -u "<project_id>:<api_token>" \
     -d '{
  "reference": "john.doe@example.com"
}'

Try it

2

Open the demo and come online

Save the page below as inbound-demo.html and open it over HTTPS (or localhost). Paste the SAT and click Come online.

Once registered, the log prints the user’s dialable /private/<name> address(es), copy one for the next step. (You can also grab it from the Dashboard Resources page if you prefer the UI.)

Leave the page open, when the call arrives, the Caller line populates and the Answer / Decline buttons enable. After you accept, Hang up enables so you can end the call.

inbound-demo.html, full source
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SignalWire SDK inbound demo</title>
    <style>
      /* Shared demo shell, identical across the inbound, outbound, and
         device-management guides. Per-demo extras go below this block. */
      body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
      label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
      input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
      button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
      button[disabled] { opacity: 0.5; cursor: not-allowed; }
      .videos { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 1rem; }
      video { width: 100%; background: #000; border-radius: 4px; aspect-ratio: 4/3; }
      #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 6rem; white-space: pre-wrap; border-radius: 4px; }
      /* Inbound-specific */
      .caller { margin: 1rem 0 0.25rem; font-weight: 600; }
    </style>
  </head>
  <body>
    <h1>SignalWire SDK inbound demo</h1>

    <label for="token">Subscriber Access Token</label>
    <input id="token" type="password" placeholder="Paste your SAT here" />
    <button id="online">Come online</button>

    <p class="caller">Caller: <span id="caller">, </span></p>
    <button id="answer" disabled>Answer</button>
    <button id="decline" disabled>Decline</button>
    <button id="hangup" disabled>Hang up</button>

    <div class="videos">
      <video id="local" autoplay muted playsinline></video>
      <video id="remote" autoplay playsinline></video>
    </div>

    <pre id="log"></pre>

    <script type="module">
      import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";

      const $ = (id) => document.getElementById(id);
      const log = (msg) => ($("log").textContent += msg + "\n");

      let currentCall = null;

      $("online").addEventListener("click", async () => {
        const token = $("token").value.trim();
        if (!token) return log("Paste a token first.");

        $("online").disabled = true;
        log("Coming online...");

        const client = new SignalWire(new StaticCredentialProvider({ token }));

        try {
          await client.register();
          log("Online, ready for inbound calls.");
        } catch (err) {
          log("Failed: " + (err.name || "Error") + ", " + err.message);
          $("online").disabled = false;
          return;
        }

        // The SDK exposes the authenticated user on `client.user$`.
        // The User object carries `.addresses`.
        //
        // Each `address.channels` value is the full dialable URI for that
        // channel (e.g. "/private/john-doe?channel=video" for a user,
        // "/user/<name>?channel=audio" for an app). Don't hand-roll the
        // prefix from `address.name`, the prefix varies by `address.type`.
        client.user$.subscribe((sub) => {
          if (!sub || !sub.addresses?.length) return;
          log("Dialable address(es) for this user:");
          for (const a of sub.addresses) {
            const uris = Object.entries(a.channels || {});
            if (!uris.length) continue;
            log("  " + a.name + " (" + a.type + ")");
            for (const [channel, uri] of uris) log("    " + channel + ": " + uri);
          }
        });

        client.session.incomingCalls$.subscribe((calls) => {
          const ringing = calls.find((c) => c.status === "ringing");
          if (!ringing || ringing === currentCall) return;
          currentCall = ringing;

          // Use fromName when it's a real display name; otherwise fall back
          // to from. SignalWire sends "_undef_" as a placeholder when the
          // originating leg didn't supply a name.
          const name =
            ringing.fromName && ringing.fromName !== "_undef_"
              ? ringing.fromName
              : ringing.from || "Unknown";
          $("caller").textContent = name;
          $("answer").disabled = false;
          $("decline").disabled = false;
          log("Ringing from " + name);

          ringing.status$.subscribe((s) => {
            log("Status: " + s);
            if (s !== "ringing") {
              $("answer").disabled = true;
              $("decline").disabled = true;
            }
            if (s === "connected") {
              $("hangup").disabled = false;
            }
            if (s === "disconnected" || s === "destroyed") {
              $("hangup").disabled = true;
              $("caller").textContent = ", ";
              if (currentCall === ringing) currentCall = null;
            }
          });

          ringing.localStream$.subscribe(
            (s) => ($("local").srcObject = s)
          );

          // `remoteStream$` re-emits a *new* MediaStream each time the SDK
          // adds a track (see `new MediaStream([...t, e.track])` in the SDK
          // bundle). Bind `srcObject` on every emission so the <video>
          // element always renders the latest stream, but dedupe per-track
          // log lines by tracking which MediaStreamTrack.id's we've seen.
          const seenTrackIds = new Set();
          ringing.remoteStream$.subscribe((stream) => {
            $("remote").srcObject = stream;
            for (const t of stream.getTracks()) {
              if (seenTrackIds.has(t.id)) continue;
              seenTrackIds.add(t.id);
              log("Remote " + t.kind + " track arrived");
            }
          });
        });
      });

      $("answer").addEventListener("click", () => {
        if (!currentCall) return;
        log("Answering...");
        currentCall.answer({ audio: true, video: true });
      });

      $("decline").addEventListener("click", () => {
        if (!currentCall) return;
        log("Declining...");
        currentCall.reject();
      });

      $("hangup").addEventListener("click", () => {
        if (!currentCall) return;
        log("Hanging up...");
        currentCall.hangup();
      });
    </script>
  </body>
</html>

3

Place a test call

There are several ways to dial the user, another browser tab signed in as a different user, a SIP softphone configured against the user’s SIP endpoint, a PSTN call from a phone, or a server-side dial via the Calling REST API. This guide uses the Calling API so you can verify the flow from a single terminal. The body below carries inline SWML that plays a public test MP4, click Answer in the demo when the call rings and the platform plays that file into the call so the remote <video> tile shows real audio and video.

POST

/api/calling/calls

cURL

curl -X POST https://{your_space_name}.signalwire.com/api/calling/calls \
     -H "Content-Type: application/json" \
     -u "<project_id>:<api_token>" \
     -d '{
  "command": "dial",
  "params": {
    "caller_id": "+15551234567",
    "codecs": [\
      "PCMU",\
      "PCMA"\
    ],
    "from": "+15551234567",
    "max_price_per_minute": 0.05,
    "status_events": [\
      "answered",\
      "ended"\
    ],
    "status_url": "https://example.com/status_callback",
    "timeout": 30,
    "to": "+15559876543",
    "url": "https://example.com/swml"
  }
}'

Try it

Set from to any phone number or SIP credential on your project, server-side dials originate from those. Set to to the address the demo log printed in step 2 (e.g. /private/john-doe). Paste the body below into the request above:

Request body example

{
  "command": "dial",
  "params": {
    "from": "<your-phone-or-sip-credential>",
    "to": "/private/<address-from-demo-log>",
    "swml": {
      "version": "1.0.0",
      "sections": {
        "main": [\
          { "play": { "url": "https://www.w3schools.com/html/mov_bbb.mp4" } }\
        ]
      }
    }
  }
}

The demo logs the ringing call and enables the Answer / Decline buttons. Click Answer to accept (the demo answers with audio: true, video: true), the streams attach, the local tile shows your webcam preview, and the remote tile shows whatever the SWML leg sends back. The SWML script hangs up automatically when playback finishes, or click Hang up to end the call from your side.

Next steps

Outbound Calls\ \ Dial users, rooms, or PSTN destinations with client.dial(). Device Management\ \ Choose the mic, camera, and speaker. Call interface reference\ \ Every property and method on a Call.


Layouts & Participant Views

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

For multi-party video rooms, the SignalWire platform composes every participant’s camera into a single mixed video stream, call.remoteStream$ emits that stream. A layout is the rule the server uses to composite it: grid, presenter + thumbnails, picture-in-picture, etc. Each room has a list of layouts the server allows; clients pick one, optionally pin who goes in which slot, and read back where everyone ended up.

The server composes the video. The client picks the composition and draws overlays (name tags, mute icons, click targets) on top using percentage-based layer coordinates.

Pick a layout

Wire a picker to layouts$ (available options), layout$ (current selection), and setLayout() (mutator):

call.layouts$.subscribe((names) => {
  layoutPicker.innerHTML = names
    .map((n) => `<option value="${n}">${n}</option>`)
    .join("");
});

call.layout$.subscribe((current) => {
  layoutPicker.value = current ?? "";
});

layoutPicker.onchange = () => call.setLayout(layoutPicker.value, {});

The available layout names are server-defined, they depend on the room’s configuration. setLayout rejects with InvalidParams if you pass a name that isn’t in layouts$, so either bind from the picker options (as above) or validate up front.

The empty {} second argument means “let the server place participants automatically.” To pin specific members into specific slots:

await call.setLayout("presenter", {
  [presenterId]: "reserved-0",  // big slot
  [guestId]:     "reserved-1",  // sidebar
});

Slot names (reserved-0, reserved-1, auto, standard-0, …) are defined per-layout, see VideoPosition. Members not in the map are auto-placed. The local user can request their own position too with call.self.setPosition(), gated by capabilities.self.position.

Render the layout

Attach the mixed stream to a single <video> element:

<video id="room" autoplay playsinline></video>
call.remoteStream$.subscribe((s) => roomVideo.srcObject = s);

The stream already contains every participant arranged by the current layout, don’t render per-participant <video> tags.

Draw overlays

Overlays (name tags, mute icons, click hotspots, speaking borders) go on top of the single video. layoutLayers$ emits per-participant boxes with percentage coordinates (0-100) relative to the room canvas, so overlays scale with the video element regardless of resolution.

call.layoutLayers$.subscribe((layers) => {
  for (const layer of layers) {
    overlay(layer.member_id).style.cssText = `
      left:   ${layer.x}%;
      top:    ${layer.y}%;
      width:  ${layer.width}%;
      height: ${layer.height}%;
    `;
  }
});

See LayoutLayer for the full layer shape (z-index, visibility, reservation slot, etc.).

For per-tile UI, each Participant has its own position$ scoped to that member, simpler than filtering layoutLayers$ on every emission.

Re-shuffles

layout$ and layoutLayers$ re-emit whenever the server re-composites: setLayout calls, members joining or leaving under auto-layout, the server promoting a raised hand. Subscriptions stay live; overlays follow automatically.

Capability gating

ActionCapability
Pick a different layoutSelfCapabilities.setLayout$
Set your own positioncapabilities.self.position
Set another member’s positioncapabilities.member.position

Hide the picker / position controls when the capability is false. See Capabilities.

Reference

  • layouts$ · layout$ · layoutLayers$, what to subscribe to
  • setLayout(), switch the composition / pin slots
  • Participant.position$ · Participant.setPosition(), per-member position
  • LayoutLayer · VideoPosition, data shapes
  • SelfCapabilities.setLayout$, capability gate

Overview

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The SignalWire platform models every callable thing, a person, a room, an AI agent, a SWML script, as a Resource. Each Resource is reachable via one or more Addresses in the form /<context>/<name> (e.g. /private/jane, /public/support). Users (called Subscribers on the platform) are the Resource type that represents a person in your application, with credentials, identity, and assigned phone numbers.

The Browser SDK exposes three handles for working with these platform concepts at runtime, all hanging off the connected SignalWire client:

Platform conceptSDK access pointWhat it gives you
The authenticated User (Subscriber)client.user / client.user$Identity (id, email, name, company), assigned addresses, SAT claims.
The Addresses reachable to that userclient.directory / client.directory$Paginated, observable list of Address entries, search, dial, message.
Per-client settingsclient.preferencesDevice choices, media defaults, ICE/recovery tuning, optionally persisted.
Per-call capability flagscall.self.capabilitiesWhat the current participant is allowed to do (mute, layout, screenshare, end).

This section walks through each one in turn.

Naming note: User vs “Subscriber”

The Browser SDK calls this resource a User (client.user). The platform still uses the name Subscriber in several places:

  • The credential is still called a Subscriber Access Token (SAT).
  • The REST API endpoints are still /api/fabric/subscribers/....
  • The Dashboard still shows a Subscribers tab.
  • The Address for one of these resources still has type === 'subscriber'.

User and Subscriber refer to the same thing. When you read “Subscriber” in platform docs or dashboard UI, the SDK-side equivalent is client.user.

How they fit together

                           
                              SignalWire (platform)      
                              Resources + Addresses      
                           
                                            REST mint
                                          
                           
       Backend mints SAT     Subscriber Access Token    
       on user login       for THIS user               
                           
                                            passed to SDK
                                          
        
           const client = new SignalWire(provider)                  
                                                                    
           client.user$           this user's profile              
           client.directory$      addresses they can reach        
           client.preferences     their per-client settings        
                                                                    
           const call = await client.dial(address)                  
           call.self.capabilities    what they may do in THIS call

The platform decides which Addresses a user can reach (based on context, ACLs, and the SAT’s scopes). The SDK surfaces that as an observable directory, your UI doesn’t have to know how the platform arrives at the list.

What you’ll find in this section

Users\ \ Working with client.user: profile fields, assigned addresses,\ push notification keys, and the User / Subscriber naming. Address Book & Directory\ \ client.directory and the Address entity: paginated listing,\ lookup by URI, channels, messaging, and call history. Client Preferences\ \ client.preferences: persisted device choices, media defaults,\ custom userVariables, and ICE / recovery tuning. Capabilities\ \ call.self.capabilities: drive your UI off real server-granted\ permissions instead of guessing what a participant may do.

For creating Users (Subscribers), minting tokens, or managing Resources from your backend, see the platform’s REST API reference and Subscribers overview. The Browser SDK itself never creates or destroys Resources, it only authenticates as one and consumes the Addresses the platform exposes to it.

Reference

  • SignalWire.user / SignalWire.user$, authenticated user
  • SignalWire.directory / SignalWire.directory$, paginated address book
  • SignalWire.preferences, per-client settings ( ClientPreferences)
  • SelfCapabilities, per-call capability flags
  • User, Address, Directory, the entity types

Migrate from v3

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

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.

Outbound Calls

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

To place a call from a web app, hand the SDK a destination, choose whether to send audio, video, or both, and attach the resulting media streams to the page. The same client.dial() call works for joining a room, calling another user, or reaching a phone number, only the destination string changes.

Here’s the shape of an outbound call end-to-end:

Browser

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
const call = await client.dial("/public/test-room", { audio: true, video: true });

// Attach the media to the page.
call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));

// End the call when the user clicks hang up.
hangupButton.onclick = () => call.hangup();

Before you start. You’ll need a Subscriber Access Token your backend issued for the user, a destination the token is allowed to reach, and an HTTPS page (browsers only grant mic and camera access over a secure origin, localhost is the development exception).

Pick a destination

The first argument to dial() is a URI string identifying what the call should reach. Four shapes cover the common cases:

Shared resource

/public/<resource>, a resource like a room, IVR, or app anyone in the project can dial.

Specific user

/private/<user>, a registered user (Subscriber) in your project that you can call directly.

Phone number

+15551234567, a PSTN number. The token must be allowed to dial PSTN.

SIP endpoint

sip:alice@example.com, a SIP destination reachable from your space.

Browser

const call = await client.dial("/public/test-room");

If you’re iterating addresses from the directory, an Address object exposes defaultChannel, a ready-to-dial URI for that address, so you don’t have to assemble the string yourself.

Choose audio, video, or both

dial() takes a DialOptions object, which extends MediaOptions. The four common shapes:

Audio + video
Audio only
Video, mic muted
Receive only
const call = await client.dial(destination, { audio: true, video: true });

Standard video call. Both tracks captured from the selected mic and camera.

The destination URI can also carry a ?channel=audio or ?channel=video hint that sets the matching media defaults, useful when the destination URI is what determines the call shape. Explicit options passed to dial() always win.

For pinned device constraints, codec preference, your own MediaStream, or custom invite metadata, see DialOptions. For mic, camera, or speaker selection that persists across every call, use the device management APIs instead of constraining each dial() individually.

Attach the streams

A Call exposes two media observables: localStream$ (what the user is sending) and remoteStream$ (what the user receives). Both emit a MediaStream once the track is ready, bind each one to a <video> element’s srcObject:

Browser

// These subscriptions complete on their own when the call ends.
call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
<video id="localVideo" autoplay muted playsinline></video>
<video id="remoteVideo" autoplay playsinline></video>

The local element needs muted so the user doesn’t echo their own voice; the remote element must not be muted or no one is heard. Both need playsinline for mobile Safari. Audio-only calls use the same remoteStream$, keep the <video> element and the browser plays the audio track through it.

End the call

Call hangup() when the user clicks the hang-up button or navigates away. The call transitions through disconnectingdisconnecteddestroyed; any subscriptions on the call complete naturally.

Browser

hangupButton.onclick = () => call.hangup();

If the user closes the tab without calling hangup(), the SDK still tears the call down when the page unloads. Calling hangup() explicitly gives you a clean point to dismiss the in-call UI before the connection drops.

To leave the page but keep the call alive on the platform, a transfer-and-disappear flow, use transfer() instead.

Try it: dial a destination

Create a SAT against your project, the Authentication guide covers it end-to-end; the Create Subscriber Token reference sends the request for you.

POST

/api/fabric/subscribers/tokens

cURL

curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
     -H "Content-Type: application/json" \
     -u "<project_id>:<api_token>" \
     -d '{
  "reference": "john.doe@example.com"
}'

Try it

Copy the returned token, save the page below as outbound-demo.html, and open it over HTTPS (or localhost). Paste the SAT and a destination, toggle the Send audio / Send video checkboxes to match the call shape you want, click Dial, and watch the log, it records every status the call moves through. The checkboxes map directly onto the audio and video keys of dial()’s DialOptions.

outbound-demo.html, full source
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>SignalWire SDK outbound call demo</title>
    <style>
      /* Shared demo shell, identical across the inbound, outbound, and
         device-management guides. Per-demo extras go below this block. */
      body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
      label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
      input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
      button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
      button[disabled] { opacity: 0.5; cursor: not-allowed; }
      .videos { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 1rem; }
      video { width: 100%; background: #000; border-radius: 4px; aspect-ratio: 4/3; }
      #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 6rem; white-space: pre-wrap; border-radius: 4px; }
      /* Outbound-specific */
      .media-options { margin: 0.75rem 0 0; padding: 0.5rem 0.75rem; border: 1px solid #ccc; border-radius: 4px; }
      .media-options legend { padding: 0 0.25rem; font-weight: 600; }
      .media-options label { display: inline-flex; align-items: center; gap: 0.35rem; margin: 0 1rem 0 0; font-weight: 400; }
      .media-options input[type="checkbox"] { width: auto; padding: 0; }
    </style>
  </head>
  <body>
    <h1>SignalWire SDK outbound call demo</h1>

    <label for="token">Subscriber Access Token</label>
    <input id="token" type="password" placeholder="Paste your SAT here" />

    <label for="destination">Destination</label>
    <input id="destination" type="text" placeholder="/public/test-room" />

    <fieldset class="media-options">
      <legend>Media</legend>
      <label><input type="checkbox" id="audio" checked /> Send audio</label>
      <label><input type="checkbox" id="video" checked /> Send video</label>
    </fieldset>

    <button id="dial">Dial</button>
    <button id="hangup" disabled>Hang up</button>

    <div class="videos">
      <video id="local" autoplay muted playsinline></video>
      <video id="remote" autoplay playsinline></video>
    </div>

    <pre id="log"></pre>

    <script type="module">
      import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";

      const log = (msg) =>
        (document.getElementById("log").textContent += msg + "\n");

      let activeCall = null;

      document.getElementById("dial").addEventListener("click", async () => {
        const token = document.getElementById("token").value.trim();
        const destination = document.getElementById("destination").value.trim();
        if (!token || !destination) return log("Need a token and a destination.");

        const audio = document.getElementById("audio").checked;
        const video = document.getElementById("video").checked;

        const dialBtn = document.getElementById("dial");
        const hangupBtn = document.getElementById("hangup");
        dialBtn.disabled = true;
        log("Dialing " + destination + "...");
        log("Options: audio=" + audio + ", video=" + video);

        const provider = new StaticCredentialProvider({ token });
        const client = new SignalWire(provider);

        try {
          const call = await client.dial(destination, { audio, video });
          activeCall = call;
          hangupBtn.disabled = false;

          call.localStream$.subscribe((stream) => {
            document.getElementById("local").srcObject = stream;
          });
          call.remoteStream$.subscribe((stream) => {
            document.getElementById("remote").srcObject = stream;
          });
          call.status$.subscribe((status) => {
            log("Status: " + status);
            if (status === "destroyed") {
              dialBtn.disabled = false;
              hangupBtn.disabled = true;
              activeCall = null;
            }
          });
          call.errors$.subscribe((err) => {
            log("Error: " + (err.name || "Error") + ", " + err.message);
          });
        } catch (err) {
          log("dial() rejected: " + (err.name || "Error") + ", " + err.message);
          dialBtn.disabled = false;
        }
      });

      document.getElementById("hangup").addEventListener("click", () => {
        if (activeCall) activeCall.hangup();
      });
    </script>
  </body>
</html>

You should see Status: connected once the destination picks up. If dial() rejects with CallCreateError, the token’s scope doesn’t reach the destination, re-check allowed_addresses or the token’s project.

Next steps

Inbound Calls\ \ Receive calls in a signed-in user session. Device Management\ \ Choose the mic, camera, and speaker the call should use. DialOptions reference\ \ Every option you can pass to client.dial().


Overview

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The SignalWire Browser SDK puts voice, video, and chat in a browser without plugins, downloads, or a media server you have to run. It also integrates with powerful AI agents, SWML, and all telephony and communication services SignalWire provides.

How would you like to get started?

Make a video call with JS\ \ Drive everything yourself with @signalwire/js, client.dial(),\ observables, your own UI. Best when you want full control over how\ the call looks and behaves. Drop in a web component\ \ Add <sw-call-widget> or <sw-click-to-call> to a page and you’re\ done, a styled, working call UI with one element. Best for\ marketing sites, click-to-call buttons, and quick integrations.

Prerequisites

If you have Node + npm (or you can drop a <script> tag in an HTML file), you’re set. You’ll also need a Subscriber Access Token (SAT), mint one from the SignalWire Dashboard’s Subscribers section.

For a quick experiment, or if you’re building a public widget like a chatbot or click-to-call button, you can use an embed token instead. Embed tokens are built primarily for embedded widget applications but work for many other use cases. See Trying it without a backend below.

Install

npm\ \ @signalwire/js GitHub\ \ signalwire-js

npm install @signalwire/js@latest rxjs

RxJS is a peer dependency, the SDK uses observables for all reactive state. See the RxJS Primer when you’re ready to dig in.

For a quick script-tag setup, pin a version rather than @latest:

For a no-build setup, load the SDK as an ES module from a CDN that rewrites Node built-ins for the browser (esm.sh, jspm.io, skypack):

<script type="module">
  import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";
  // ... use SignalWire and StaticCredentialProvider as below
</script>

Your first call

<video id="localVideo"  autoplay playsinline muted></video>
<video id="remoteVideo" autoplay playsinline></video>
<button id="hangup">Hang Up</button>
import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(
  new StaticCredentialProvider({ token: "YOUR_SUBSCRIBER_ACCESS_TOKEN" })
);

let activeCall;

client.ready$.subscribe(async (ready) => {
  if (!ready) return;

  activeCall = await client.dial("/public/test-room", {
    audio: true,
    video: true,
    receiveAudio: true,
    receiveVideo: true,
  });

  activeCall.localStream$.subscribe((s) => {
    document.getElementById("localVideo").srcObject = s;
  });
  activeCall.remoteStream$.subscribe((s) => {
    document.getElementById("remoteVideo").srcObject = s;
  });
  activeCall.status$.subscribe((status) => console.log("status:", status));
});

document.getElementById("hangup").onclick = () => activeCall?.hangup();

If it worked, you’ll see your own camera in localVideo and a black frame in remoteVideo, /public/test-room is empty until someone else joins. Open the same page in a second tab to see the remote stream light up. The browser console should log status: connected once media is flowing.

If your camera light isn’t on, check the Troubleshooting guide, usually permissions, HTTPS, or a denied microphone prompt.

Trying it without a backend

Embed tokens (c2c_… / c2t_…) are public tokens designed for embedded widgets, chatbots, click-to-call buttons, in-page call UIs, but they’re also the fastest way to prototype from a static HTML file without standing up a backend to mint SATs.

Create one by setting up a Click to Call resource in the dashboard (sidebar → ToolsClick to Call). From the resource you create, copy three values:

  • the resource address (e.g. /public/support), what you’ll dial
  • the C2C token (c2c_…), the embed token
  • your space name (e.g. yourspace.signalwire.com), from the API Credentials section of the dashboard

Pass them to the one-call helper:

import { embeddableCall } from "@signalwire/js";

const call = await embeddableCall({
  host: "yourspace.signalwire.com",
  embedToken: "YOUR_C2C_TOKEN",
  to: "/public/support",
});

embeddableCall builds the client, connects, and dials in a single call. Embed tokens are safe to expose in client code, see Authentication for the full token model.

Receiving inbound calls

To accept incoming calls, register the client and watch the inbound list:

await client.register();

client.session.incomingCalls$.subscribe((calls) => {
  const ringing = calls.find((c) => c.status === "ringing");
  if (!ringing) return;

  document.getElementById("accept").onclick = () => {
    ringing.answer({ audio: true, video: true });
    // Then bind `ringing.localStream$` / `ringing.remoteStream$` to your
    // <video> elements and subscribe to `ringing.status$` for UI updates.
  };
  document.getElementById("decline").onclick = () => ringing.reject();
});

Inbound calls require a Subscriber Access Token (SAT) issued for a specific user, embed tokens and guest tokens are outbound-only. The full accept-and-wire pattern, including caller name handling and ringing-UI teardown, lives in Inbound Calls.

Next steps

Build Voice & Video apps\ \ Mute, layouts, screen share, flesh out the call UI. Web Components\ \ Drop in <sw-call-widget> or <sw-click-to-call> instead. Authentication\ \ Mint SATs from your backend and pick a refresh strategy. RxJS Primer\ \ Understand the observable patterns the SDK uses.

Reference

  • SignalWire, top-level client
  • StaticCredentialProvider, EmbedTokenCredentialProvider, credential providers
  • SignalWire.dial(), place an outbound call
  • SignalWire.register() / session.incomingCalls$, receive calls
  • embeddableCall(), one-call helper for embed tokens

RxJS Primer

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Most state in the Browser SDK, devices, participants, capabilities, call status, is exposed as RxJS observables. This page covers the subset of RxJS used by the SDK. For everything else, see the RxJS docs.

Observables and subscriptions

An observable is a stream of values over time. It does nothing until .subscribe() is called; .subscribe() returns a subscription, and .unsubscribe() ends it. Subscriptions left open are the main source of memory leaks.

SDK observables behave like BehaviorSubjects: subscribing emits the current value synchronously, then every subsequent change.

Transform a stream with .pipe() and operators (filter, map, take, combineLatest, switchMap, debounceTime, etc.).

The $ suffix

A property ending in $ is the observable. The same name without $ is the current snapshot.

const status = call.status;        // current value
call.status$.subscribe(handler);   // current value + every change

Snapshots are not always populated. For state the SDK already holds in memory (call.status, call.participants, device lists), the snapshot is the current value and reading it is fine. For lazily-loaded collections, directory.addresses, address.textMessages, address.history, the snapshot starts empty until something subscribes to the corresponding $ observable (and, for paginated collections, until loadMore() is called). Reading the snapshot synchronously right after connecting will give you []. Subscribe to the $ form, then call loadMore() to trigger the first page.

Subscribing

const sub = client.audioInputDevices$.subscribe((devices) => {
  console.log("mics:", devices);
});

sub.unsubscribe();

The first emission fires synchronously with the current device list; subsequent emissions fire on device changes.

Common patterns

Wait for a value, then proceed once. take(1) ends the subscription after the first match.

import { filter, take } from "rxjs";

client.ready$
  .pipe(filter(Boolean), take(1))
  .subscribe(async () => {
    const call = await client.dial(destination);
  });

React on every match.

import { filter } from "rxjs";

call.status$.pipe(filter((s) => s === "connected")).subscribe(showCallControls);

Combine the latest of multiple streams.

import { combineLatest } from "rxjs";

combineLatest([client.audioInputDevices$, client.selectedAudioInputDevice$])
  .subscribe(([devices, selected]) => {
    const activeMic = devices.find((d) => d.deviceId === selected?.deviceId);
  });

Skip the initial value when only changes matter.

import { skip } from "rxjs";

call.status$.pipe(skip(1)).subscribe(showStatusNotification);

Throttle high-frequency streams.

import { debounceTime } from "rxjs";

call.localAudioLevel$.pipe(debounceTime(100)).subscribe(updateVolumeIndicator);

Cleanup

Two patterns:

class CallManager {
  subs = [];
  start(call) {
    this.subs.push(
      call.status$.subscribe(this.onStatus),
      call.participants$.subscribe(this.onParticipants),
    );
  }
  stop() {
    this.subs.forEach((s) => s.unsubscribe());
    this.subs = [];
  }
}
import { Subject, takeUntil } from "rxjs";

const destroy$ = new Subject();

call.status$.pipe(takeUntil(destroy$)).subscribe(updateStatus);
call.participants$.pipe(takeUntil(destroy$)).subscribe(updateParticipants);

destroy$.next();
destroy$.complete();

takeUntil scales better with many subscriptions; an array is fine for a few.

References

  • RxJS docs
  • Observables
  • Operators
  • Subjects (including BehaviorSubject)

Screen Sharing

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Screen sharing is a method on the SelfParticipant. Call startScreenShare() to add a screen-share track to the active call; call stopScreenShare() to remove it. The SDK calls getDisplayMedia() under the hood, negotiates the additional track, and pushes status changes through an observable.

There’s no separate “screen share call”, the share is added to the existing call alongside the camera, as a second video stream.

You’ll need an active call. Screen share attaches to an existing Call, start one of these first.

Set up the client\ \ Install the SDK and create a SignalWire client with a credential provider. Place an outbound call\ \ Dial a destination with client.dial() to get a Call instance. Answer an inbound call\ \ Subscribe to client.session.incomingCalls$ and call.answer().

Start / stop

call.self$.subscribe(async (self) => {
  if (!self) return;

  shareButton.onclick = async () => {
    try {
      if (self.screenShareStatus === "started") {
        await self.stopScreenShare();
      } else {
        await self.startScreenShare();
      }
    } catch (err) {
      handleShareError(err);
    }
  };
});

startScreenShare() triggers the browser’s screen-picker. The promise rejects on cancel and on permission denial, you need to tell them apart to avoid showing a scary error toast for what was a deliberate user choice.

Handling permission and cancel

The browser surfaces both outcomes as a DOMException from getDisplayMedia(). Use err.name to distinguish:

err.nameWhat happenedUX
NotAllowedErrorUser clicked Cancel in the picker, or the OS / browser denied the permission entirelySilent, no toast on cancel
NotFoundErrorNo source was available to share (rare, usually a misconfigured kiosk environment)Tell the user “no shareable screen”
NotReadableErrorOS-level capture failure (another app holds the screen, hardware error)Suggest retry / closing other apps
AbortErrorThe session ended before capture startedSilent
NotSupportedErrorgetDisplayMedia() isn’t available (iOS Safari, some embedded WebViews)Hide the share button entirely

NotAllowedError is the one to watch, its message contains "Permission denied" when the OS or browser blocked the prompt, vs "Permission denied by user" (Chromium) or an empty message (Firefox / Safari) when the user clicked Cancel. The distinction is fuzzy across browsers, so the safest UX is: silent onNotAllowedError and rely on the user re-trying, since they just made an explicit choice.

function handleShareError(err) {
  if (err?.name === "NotAllowedError") {
    // Cancel or denied, don't surface a toast either way.
    return;
  }
  if (err?.name === "NotFoundError" || err?.name === "NotReadableError") {
    showToast("Couldn't start screen share. Close other capture apps and try again.");
    return;
  }
  if (err?.name === "NotSupportedError") {
    showToast("Screen sharing isn't supported in this browser.");
    return;
  }
  console.error("Unexpected screen share error:", err);
}

Detect support before showing the button

getDisplayMedia() is unavailable on iOS Safari and some embedded WebViews. Gate the button on the capability and the API existence so the user never sees a control that can’t work:

const supported =
  typeof navigator.mediaDevices?.getDisplayMedia === "function";

call.self?.capabilities.screenshare$.subscribe((canShare) => {
  shareButton.hidden = !(supported && canShare);
});

Observing share state

screenShareStatus$ is the reactive form. Use it instead of polling screenShareStatus so your UI reflects auto-stop (user clicked “Stop sharing” in the browser bar, OS revoked permission, etc.):

type ScreenShareStatus = 'idle' | 'starting' | 'started' | 'stopping';
self.screenShareStatus$.subscribe((status) => {
  shareButton.classList.toggle("active", status === "started");
  shareButton.disabled = status === "starting" || status === "stopping";
});

How the share appears to other participants

The screen-share track is delivered as a separate participant entry in call.participants$, usually with a name like Screen or the sharer’s name suffixed with (Screen). The local participant doesn’t need to render their own share to see it; the SDK doesn’t mirror the local capture into remoteStream$.

To detect which participants are screen shares specifically, check the participant’s metadata or type. In the kitchen-sink demo, the share state is read from self.screenShareStatus directly:

const isSharing = call.self?.screenShareStatus === "started";

Audio with the share

getDisplayMedia() can capture system / tab audio on some platforms (Chromium-based browsers on macOS / Windows). The SDK forwards whatever the browser provides, there’s no separate “share audio” toggle. If the user’s browser doesn’t support display audio, only the video is shared.

In practice:

  • Chrome / Edge on macOS or Windows: works for tab audio, may work for system audio depending on OS version.
  • Firefox: video only.
  • Safari: video only (and screen sharing requires explicit user-initiated permission per session).

Browser quirks

  • User gesture required.startScreenShare() must originate from a click or keyboard event, calling it on a timer or after an async chain that didn’t start from a gesture will fail.
  • iOS Safari. Tab screen sharing isn’t supported. iOS has its own system-wide screen-broadcast flow which is outside the browser’s reach.
  • Multiple displays. The picker lists each display separately; selecting “Entire screen” on a multi-monitor setup shares only the picked display.

Stopping from outside the page

The user can stop sharing from the browser’s “Stop sharing” toolbar. When they do, the underlying track ends and the SDK transitions screenShareStatus$ to 'idle' automatically, no action required on your side. Subscribe to screenShareStatus$ and your UI will update without polling.

Reference

  • SelfParticipant.startScreenShare(), open picker, add the share track
  • SelfParticipant.stopScreenShare(), remove the share track
  • SelfParticipant.screenShareStatus$ / screenShareStatus, reactive status ('idle' | 'starting' | 'started' | 'stopping')
  • SelfCapabilities.screenshare$, capability gate

SSR & Next.js

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

@signalwire/js and @signalwire/web-components are browser-only. They depend on WebSocket, RTCPeerConnection, navigator.mediaDevices, and customElements, APIs that don’t exist in Node.js. Importing either package at the top of a server-rendered module will crash the server during build or during SSR.

Two boundaries to hold across any server-rendered framework: load the SDK only on the client, and mint tokens only on the server. The patterns below are Next.js-specific, but the same shape applies in Nuxt, SvelteKit, Remix, Astro, or Gatsby, wrap the SDK in that framework’s client-only escape hatch, and put token minting behind a server route.

Next.js App Router

Client components

Wrap any code that touches the SDK in a "use client" component. The import won’t execute on the server.

// app/_components/dialer.tsx
"use client";

import { useEffect, useState } from "react";
import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

export function Dialer({ token }: { token: string }) {
  const [client, setClient] = useState<SignalWire | null>(null);

  useEffect(() => {
    const c = new SignalWire(new StaticCredentialProvider({ token }));
    setClient(c);
    return () => c.disconnect();
  }, [token]);

  // ...
}

A "use client" file’s transitive imports are fine, they’re bundled for the browser, not Node. You don’t need dynamic() for ordinary SDK use.

When dynamic() is needed

next/dynamic with ssr: false is only required when an import has side effects that run at module evaluation time (custom element registration, top-level new SignalWire(...), etc.). The web components package registers customElements.define on import, so in the App Router, import the components from inside an effect or use next/dynamic:

// app/_components/widget-mount.tsx
"use client";

import dynamic from "next/dynamic";
import { useEffect } from "react";

const RegisterWebComponents = dynamic(
  () => import("@signalwire/web-components").then(() => () => null),
  { ssr: false }
);

export function Widget({ token }: { token: string }) {
  return (
    <>
      <RegisterWebComponents />
      <sw-call-widget token={token} destination="/public/sales" />
    </>
  );
}

For the embed bundle (signalwire-web-components-embed.iife.js) loaded via <script>, use next/script with strategy="lazyOnload" or "afterInteractive":

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <Script
          src="https://unpkg.com/@signalwire/web-components/dist/embed/signalwire-web-components-embed.iife.js"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

Token route handler

Mint tokens in a route handler. This is the only place your SignalWire project credentials are allowed to live.

// app/api/sw-token/route.ts
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic"; // never cache the token

export async function POST(req: Request) {
  // 1. Authenticate the caller. Read your session cookie / NextAuth
  //    session here, whatever your app uses.
  const user = await getUserFromRequest(req);
  if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 401 });

  // 2. Mint a Subscriber Access Token (SAT) via the SignalWire REST API.
  const auth = Buffer.from(
    `${process.env.SIGNALWIRE_PROJECT_ID}:${process.env.SIGNALWIRE_TOKEN}`
  ).toString("base64");

  const res = await fetch(
    `https://${process.env.SIGNALWIRE_SPACE}/api/fabric/subscribers/tokens`,
    {
      method: "POST",
      headers: {
        Authorization: `Basic ${auth}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ reference: user.id }),
    }
  );

  if (!res.ok) {
    return NextResponse.json({ error: "token mint failed" }, { status: 502 });
  }

  const { token } = await res.json();
  return NextResponse.json({ token });
}

On the client, wrap the route in a CredentialProvider so the SDK can re-fetch a fresh SAT when the current one nears expiry. expiry_at is a Date.now()-style millisecond timestamp.

// app/_lib/sw-credentials.ts
"use client";

import type { CredentialProvider } from "@signalwire/js";

export class FetchedCredentialProvider implements CredentialProvider {
  async authenticate() {
    const res = await fetch("/api/sw-token", { method: "POST" });
    if (!res.ok) throw new Error("token fetch failed");
    const { token } = await res.json();
    // Default SAT lifetime is 2h. If you override `expire_at` when
    // minting, compute from that value instead.
    return { token, expiry_at: Date.now() + 2 * 60 * 60 * 1000 };
  }
  async refresh() {
    return this.authenticate();
  }
}

See Authentication › Refresh strategies for refresh-token-based flows that avoid re-authenticating the user on every rollover.

Pages Router

For a pages/-based Next.js app, the only difference is that there’s no "use client" directive. Use next/dynamic({ ssr: false }) for any component that imports the SDK:

// pages/index.tsx
import dynamic from "next/dynamic";

const Dialer = dynamic(() => import("@/components/dialer"), { ssr: false });

export default function Home() {
  return <Dialer />;
}

Token endpoints live under pages/api/sw-token.ts and use the same REST flow as the App Router example.

Hydration caveats

  • Don’t render call state from the server. Anything driven by an observable belongs in a useEffect / onMount / <ClientOnly>, not in the initial server render. Otherwise hydration will mismatch (the server rendered "idle", the client mounts with "connected").
  • Don’t read window in module scope. Even inside a "use client" file, the module body runs once during client hydration. Wrap any window. / document. access in an effect.
  • <video>``srcObject won’t survive serialization. Bind it from an effect after the stream observable emits, never from a prop on the first render.

Environment variables

VariableLives inWhy
SIGNALWIRE_PROJECT_IDServer onlyAPI credential. Never expose to the browser.
SIGNALWIRE_TOKENServer onlyAPI credential. Never expose to the browser.
SIGNALWIRE_SPACEServer onlyHostname for REST minting.
NEXT_PUBLIC_SW_HOSTPublic (client)Optional. The WebSocket host the SDK connects to.

In Next.js, NEXT_PUBLIC_* is the only prefix that gets inlined into the client bundle. Project ID and auth token must stay on the serverside of that boundary, always.

See Authentication for the full token flow.


Troubleshooting & FAQ

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Common failure modes and how to diagnose them. For end-to-end patterns, see the guides; for full API contracts, see the reference.

Connection issues

InvalidCredentialsError on connect

Cause: Token is expired, malformed, or minted for a different SignalWire space.

Fix:

  1. Mint a fresh SAT from your backend, see Authentication.
  2. Confirm the token was issued by the space you’re connecting to.
  3. Check that the full token string was copied (SATs are long; truncation is common).
import { jwtDecode } from "jwt-decode";

const decoded = jwtDecode(token);
const expiresAt = new Date(decoded.exp * 1000);
console.log("Expired?", expiresAt < new Date());

Connection fails without an obvious error

Errors that happen outside of an await flow surface on errors$. If you never subscribe to it, those errors are silently dropped, subscribe during client construction so they always reach your logs.

client.errors$.subscribe((error) => console.error("Client error:", error));

NotConnectedError when calling dial()

Cause: Calling dial() before the client finished connecting.

Wait for ready$ to emit true:

import { filter, take } from "rxjs";

client.ready$.pipe(filter(Boolean), take(1)).subscribe(async () => {
  const call = await client.dial(destination);
});

WebSocket disconnects frequently

Causes: Unstable network, corporate firewall/proxy blocking WebSocket, idle timeout.

The SDK reconnects automatically. Drive a “reconnecting” banner off isConnected$:

client.isConnected$.subscribe((connected) => {
  connected ? hideReconnectingBanner() : showReconnectingBanner();
});

Video / Audio issues

Video is black

Causes: Camera permissions denied, camera in use by another app, wrong camera selected, hardware issue.

const permission = await navigator.permissions.query({ name: "camera" });
console.log("Camera permission:", permission.state);

client.videoInputDevices$.subscribe((devices) => {
  console.log("Available cameras:", devices);
});

No remote audio

Causes: Output device not selected, remote participant muted, browser blocking unmuted autoplay, or the muted attribute left on the <video> element.

<!-- Wrong, the `muted` attribute mutes playback regardless of the track -->
<video id="remote" autoplay muted></video>

<!-- Right -->
<video id="remote" autoplay playsinline></video>

Browsers block autoplay with audio until the page has been interacted with, play() will reject. Catch that promise and surface a play-to-start button if it fails.

Remote can’t hear me

call.self$.subscribe((self) => console.log("Audio muted:", self?.audioMuted));

client.selectedAudioInputDevice$.subscribe((device) => {
  console.log("Microphone:", device?.label);
});

Echo or feedback

Use headphones, or enable echo cancellation:

call.self$.subscribe(async (self) => {
  if (self && !self.echoCancellation) await self.toggleEchoCancellation();
});

Permission denied for camera/microphone

HTTPS is required.getUserMedia only works on secure contexts (HTTPS or localhost).

try {
  await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
} catch (e) {
  if (e.name === "NotAllowedError") {
    alert("Click the lock icon in your address bar to reset permissions.");
  }
}

Call issues

Call stays in trying state

Causes: Invalid destination, destination not reachable, network/firewall blocking.

call.errors$.subscribe((error) => console.error("Call error:", error));

Call connects but no media flows

Cause: ICE connection failed (firewall blocking UDP) or TURN server unreachable.

const pc = call.rtcPeerConnection;
console.log("ICE state:", pc?.iceConnectionState);
pc?.addEventListener("iceconnectionstatechange", () => {
  console.log("ICE state changed:", pc.iceConnectionState);
});

Can’t receive inbound calls

Causes: register() was never called, or the token can’t register. Only full Subscriber Access Tokens can register; Guest SATs, Invite SATs, and embed tokens are outbound-only.

client.ready$.subscribe(async (ready) => {
  if (ready) await client.register();
});

Inbound calls then surface on session.incomingCalls$.

DTMF tones not working

Send digits only after the call is connected:

import { filter, take } from "rxjs";

call.status$
  .pipe(filter((s) => s === "connected"), take(1))
  .subscribe(async () => await call.sendDigits("123#"));

UI issues

Video element shows nothing

call.remoteStream$.subscribe((stream) => {
  const video = document.getElementById("remoteVideo");
  if (stream && video) {
    video.srcObject = stream;
    video.play().catch((e) => console.error("Play failed:", e));
  }
});

UI doesn’t update when state changes

Subscribe immediately after getting the object, BehaviorSubjects emit current state on subscribe.

Memory leak / page slows down

Almost always a missing unsubscribe on a long-lived subscription. See RxJS Primer → Cleanup and the framework patterns in Framework Integration.

Browser-specific issues

Safari: video doesn’t play

Safari has strict autoplay policies. Add playsinline and handle the play promise:

<video id="remote" autoplay playsinline></video>
call.remoteStream$.subscribe(async (stream) => {
  const video = document.getElementById("remote");
  video.srcObject = stream;
  try {
    await video.play();
  } catch {
    showPlayButton(() => video.play());
  }
});

Firefox: no audio output selection

Firefox doesn’t fully support setSinkId. Audio plays through default output.

Mobile: camera switches unexpectedly

Device rotation or app switching can reset the camera.

client.videoInputDevices$.subscribe((devices) => {
  const preferred = devices.find((d) => d.label.includes("front"));
  if (preferred) call.self?.selectVideoInputDevice(preferred);
});

Debugging

Verbose logging

The SDK emits debug-level logs to the browser console. Filter by signalwire in DevTools to isolate them.

Inspect WebSocket traffic

DevTools → Network → “WS” filter → click the connection → Messages tab.

Get call statistics

call.rtcPeerConnection is the underlying RTCPeerConnection, it’s undefined until media negotiation starts, so guard before reading it.

const stats = await call.rtcPeerConnection?.getStats();
stats?.forEach((report) => {
  if (report.type === "inbound-rtp" && report.kind === "video") {
    console.log("Packets received:", report.packetsReceived);
    console.log("Packets lost:", report.packetsLost);
  }
});

Test without real media

Chrome flag: --use-fake-device-for-media-stream. Or generate a canvas stream:

const canvas = document.createElement("canvas");
canvas.getContext("2d").fillRect(0, 0, 640, 480);
const fakeStream = canvas.captureStream(30);

FAQ

Do I need HTTPS?

Yes for production. WebRTC’s getUserMedia requires a secure context. Localhost is exempt for development.

What browsers are supported?

Modern Chrome, Firefox, Safari, and Edge. No IE11.

Can I use this in Node.js?

No, the SDK is browser-only. Use the SignalWire REST APIs or server-side SDKs.

How do I implement a mute button?

muteButton.onclick = () => call.self?.toggleMute();

call.self is null until the local participant joins, always check.

How do I get call duration?

let startTime;
call.status$.subscribe((status) => {
  if (status === "connected") startTime = Date.now();
  if (status === "disconnected" && startTime) {
    console.log("Lasted:", Math.round((Date.now() - startTime) / 1000), "s");
  }
});

Can I record calls?

Recording is controlled by the SignalWire platform, not the browser. startRecording() is on the API surface but not yet implemented in v4 and will throw when called. Recording state configured server-side still surfaces through recording$.

Why “Unimplemented” errors?

Some methods are on the API surface but pending implementation, and others depend on capabilities the server hasn’t granted to this call. Check capabilities$ to gate the UI on what’s actually available.


Users

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Users are the Browser SDK’s name for what the platform calls Subscribers, the SignalWire Resource type that represents a person in your application. Each User has credentials, a profile, a private Address for direct dialing, and may own public phone numbers. When the Browser SDK authenticates with a Subscriber Access Token (SAT), the platform identifies the caller as one specific User, and the SDK surfaces that identity through client.user.

Not every SAT corresponds to a registered User (subscriber), guest and invite tokens produce temporary sessions that don’t map to a platform-side Subscriber Resource. The SDK exposes all of them through the same User object on client.user, which carries whatever identity and scopes the token was minted with.

This guide covers the runtime side: reading the authenticated profile, gating on scopes, and ending the session. For creating Users (Subscriber Resources) on the server, see the platform docs and the REST API.

Accessing the authenticated user

client.user is populated automatically when the client connects. Subscribe to client.user$ to wait for it without risking a null read:

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

const client = new SignalWire(
  new StaticCredentialProvider({ token: "YOUR_SAT" })
);

client.user$.subscribe((user) => {
  if (!user) return;
  console.log("Signed in as", user.displayName ?? user.email);
});

client.user$ is a BehaviorSubject, late subscribers receive the cached value synchronously once the profile loads.

Profile

The User instance carries the platform-side profile: identity (id, email, firstName / lastName / displayName), organization context (jobTitle, companyName, timeZone, country, region), the assigned addresses and pushNotificationKey, plus appSettings.scopes from the SAT. See User for the full field list.

client.user$.subscribe((user) => {
  if (!user) return;
  document.querySelector("#name").textContent =
    user.displayName ?? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim();
  document.querySelector("#email").textContent = user.email;
  document.querySelector("#company").textContent = user.companyName ?? "";
});

Assigned addresses

user.addresses is the list of Resource Addresses the authenticated identity owns directly, typically one private address (e.g. /private/jane-doe) plus any phone numbers or aliases the platform has provisioned. These are the addresses others dial to reach this user.

This is distinct from client.directory, which is the broader list of addresses this user can reach (other Users, rooms, AI agents, scripts the platform exposes). See Address Book & Directory.

Scopes

user.appSettings?.scopes reflects the permission scopes the SAT was minted with. Use it to gate UI for features the backend has opted the user into:

client.user$.subscribe((user) => {
  if (!user) return;
  const canRecord = user.appSettings?.scopes.includes("recording");
  recordButton.hidden = !canRecord;
});

Scopes are advisory in the UI, enforcement is server-side. Reading them locally avoids showing buttons that would fail.

Creating and managing Users

User lifecycle happens on your backend, the Browser SDK can only sign in as an existing identity. Use the Subscribers REST API from your server (with your SignalWire API credentials, never from the browser). The typical flow: on login, your backend looks up or creates a User (Subscriber) for the authenticated user, mints a SAT, and returns it to the browser. See Authentication for the token flow.

Sign-out

The User’s platform record persists as a Subscriber Resource; “sign out” means ending the session. Disconnect the client and discard the SAT:

await client.disconnect();

A disconnected client can be garbage-collected. For the next login, construct a new SignalWire with a fresh credential provider.

Reference

  • client.user / client.user$, authenticated user accessor and observable
  • User, profile fields (id, email, firstName, lastName, displayName, addresses, pushNotificationKey, appSettings, satClaims)
  • client.directory, addresses this user can reach
  • client.disconnect(), end the session

Web Components

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

@signalwire/web-components is a set of custom HTML elements built on Lit that wraps the Browser SDK in declarative markup. They work in vanilla HTML, React, Vue, Angular, or server-rendered templates, no imperative JavaScript required for the common cases.

The headline element is <sw-call-widget>, which owns the full call lifecycle (client init, dialing, media, controls, optional AI transcript) and renders inline or as a modal. Around it sits a family of composable primitives, <sw-call-media>, <sw-local-camera>, <sw-call-controls>, <sw-device-selector>, and the sw-ui-* presentational layer, that can be used à la carte. See the Web Components reference for the full element list.

Two ways to load the library

Embed bundle

A single self-contained <script> tag. Registers every custom element and exposes the SDK on window.SignalWireUI. For static sites, CMS pages, and contexts without a bundler.

npm bundle

npm install @signalwire/web-components@latest. Tree-shakes through your bundler, supports per-element subpath imports, ships TypeScript types. For SPAs and framework apps.

Both register the same custom elements with the same attribute and event APIs. They differ only in how the script is loaded and where SDK classes are imported from.

Embed pathway

The embed bundle is a single IIFE file that registers every custom element as a side effect and re-exports the SDK on window.SignalWireUI. No build step.

<!doctype html>
<html>
  <head>
    <script src="https://unpkg.com/@signalwire/web-components/dist/embed/signalwire-web-components-embed.iife.js"></script>
  </head>
  <body>
    <sw-call-widget
      token="c2c_892b29eca7b6d96091faf713f07cdf46"
      destination="/public/support"
      transcription
    >
      <sw-ui-background slot="background" default></sw-ui-background>
    </sw-call-widget>
  </body>
</html>

SDK classes are available on the global for programmatic use:

<script>
  const { SignalWire, StaticCredentialProvider, EmbedTokenCredentialProvider } =
    SignalWireUI;

  // Embed token (c2c_ / c2t_ prefix), uses embeds.signalwire.com:
  const client = new SignalWire(
    new EmbedTokenCredentialProvider("embeds.signalwire.com", "c2c_…")
  );

  // SAT token, points at your project:
  const client2 = new SignalWire(
    new StaticCredentialProvider({ token: "eyJ…your_SAT…" })
  );
</script>

Pick the embed pathway for marketing sites, landing pages, and CMS templates, anywhere a single copy-pasteable URL is the goal.

npm pathway

Install the components alongside @signalwire/js and rxjs (peer dependencies):

npm install @signalwire/web-components@latest @signalwire/js@latest rxjs

Register every element with a side-effect import:

import "@signalwire/web-components";

Or import only the elements in use:

import "@signalwire/web-components/sw-call-widget";
import "@signalwire/web-components/sw-ui-background";

Use them anywhere in markup or JSX:

<sw-call-widget
  token="c2c_892b29eca7b6d96091faf713f07cdf46"
  destination="/public/support"
  transcription
>
  <sw-ui-background slot="background" default></sw-ui-background>
</sw-call-widget>

SDK classes import from @signalwire/js:

import { SignalWire, StaticCredentialProvider } from "@signalwire/js";

For framework-specific wrappers (React, Vue, Svelte), see Framework Integration.

Anatomy of a widget

<sw-call-widget> is the simplest entry point, set token and destination and it handles the rest. Internally it composes the same primitives that are available individually:

<sw-call-provider .call="${callObject}" .deviceController="${deviceController}">
  <sw-call-media></sw-call-media>
  <sw-self-media></sw-self-media>
  <sw-call-controls></sw-call-controls>
</sw-call-provider>

<sw-call-provider> sets up reactive contexts (call state, devices, transcript, user events) that descendants subscribe to automatically. Reach for the primitives when the built-in layout doesn’t fit the design.

Next

  • Click-to-Call Widget, single call button.
  • Theming, design tokens and CSS Parts.
  • Customization, slots, events, primitives.
  • Web Components reference, per-element attributes, events, and slots.

Customization

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

When <sw-call-widget> doesn’t fit the layout, drop down to the primitives. The widget itself is built from the same elements, wired together through reactive contexts.

Three patterns, in increasing order of customization:

  1. Slots, keep <sw-call-widget>, replace one region (trigger or background).
  2. Events & methods, attribute / event / method API; the widget still owns the lifecycle.
  3. Primitives, drop the widget and compose <sw-call-provider>, <sw-call-media>, <sw-call-controls>, etc. directly.

Slots

<sw-call-widget> exposes a background slot for the full-bleed background and a default slot for the idle-state trigger. Anything in the default slot becomes the click target; clicking calls widget.dial().

<sw-call-widget modal token="c2c_…" destination="/public/sales">
  <sw-ui-background slot="background" default></sw-ui-background>

  <button class="cta">
    <svg><!-- your icon --></svg>
    Talk to sales
  </button>
</sw-call-widget>

Once dialing starts the widget swaps into call mode (inline or modal, per the modal attribute).

Programmatic control

<sw-call-widget> exposes dial() and hangup() for driving dialing from JS instead of the trigger slot:

const widget = document.querySelector("sw-call-widget");

document.querySelector("#dial-btn").addEventListener("click", async () => {
  try {
    await widget.dial();
  } catch (err) {
    console.error("dial failed", err);
  }
});

document.querySelector("#hangup-btn").addEventListener("click", () => {
  widget.hangup();
});

The widget bubbles sw-dial, sw-call-ended, and forwarded agent events:

widget.addEventListener("sw-call-ended", (e) => {
  analytics.track("call_ended", { status: e.detail.status });
});

See <sw-call-widget> for the full event surface.

Pass-through attributes

Widget attributes toggle sub-features:

  • transcription, enables the AI transcript drawer.
  • allow-incoming-calls, listens for inbound calls on the same token.
  • audio-only, skips the camera.
  • user-variables, JSON string forwarded into the Verto invite for the receiving side to read.

See <sw-call-widget> for the full attribute list.

Building from primitives

For full layout control, compose the primitives directly. <sw-call-provider> is the only required wrapper, it owns the reactive contexts that the SDK-aware components subscribe to.

<sw-call-provider id="provider">
  <div class="my-grid">
    <sw-call-media></sw-call-media>
    <sw-self-media></sw-self-media>
    <sw-call-status></sw-call-status>
    <sw-call-controls show-screen-share show-fullscreen></sw-call-controls>
    <sw-device-selector></sw-device-selector>
  </div>
</sw-call-provider>

<script type="module">
  import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
  import "@signalwire/web-components";

  const client = new SignalWire(
    new StaticCredentialProvider({ token: "YOUR_SAT" })
  );

  const call = await client.dial("/private/sales", { audio: true, video: true });

  const provider = document.getElementById("provider");
  provider.call = call;
  provider.deviceController = client.deviceController;
</script>

For the embed bundle, replace the imports with const { SignalWire, StaticCredentialProvider } = SignalWireUI; and drop <script type="module">.

Provider contexts

<sw-call-provider> mounts reactive contexts for call state, devices, transcript, and forwarded user events. Set .call and .deviceController; every nested SDK-aware element subscribes automatically. State changes (participant joins, device changes, transcript lines) push through the contexts to whichever components are listening.

Component events

Each primitive bubbles its own composed events: <sw-call-controls> emits sw-mute-audio / sw-mute-video / sw-hangup; <sw-call-dialpad> emits sw-digit-press; <sw-device-selector> emits sw-device-change. Events bubble across shadow DOM:

provider.addEventListener("sw-hangup", () => {
  // user clicked hangup, clean up app state
});

Per-element event payloads are listed in the Web Components reference.

Replacing a primitive

Because components subscribe to context independently, a custom element that dispatches the same composed events is a drop-in replacement.

<sw-call-provider .call="${call}">
  <sw-call-media></sw-call-media>
  <my-fancy-dialpad></my-fancy-dialpad>      <!-- emits sw-digit-press -->
  <sw-call-controls></sw-call-controls>
</sw-call-provider>

SDK-aware components don’t talk to each other directly, they all go through the call object, so any element emitting the right events fits.

Choosing the right level

NeedUse
One call button on a page<sw-click-to-call>
Inline or modal call UI with default layout<sw-call-widget>
Same with a custom trigger or background<sw-call-widget> + slots
Custom layout, keep SDK-aware primitives<sw-call-provider> + primitives
No web componentsBrowser SDK directly

See Theming for the visual side and the Web Components reference for per-element attributes, slots, and events.


Theming

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The web components are themed through CSS, no JavaScript configuration, no theme objects, no provider. The components consume DTCG tokens shipped in theme.css; overriding any of them at an ancestor element re-styles every descendant component.

Two layers:

  1. Design tokens, CSS custom properties for colors, typography, spacing, and radii. Set on a parent selector, cascade into shadow DOM via inheritance.
  2. CSS Parts, ::part(...) selectors for restyling a specific inner element when no token covers the change.

Tokens are the stable surface and one declaration can restyle every element on the page. Parts couple to internal structure, so prefer tokens when both are available.

How theme.css loads

<sw-call-widget> and <sw-click-to-call> auto-inject theme.css on first render. To control load order (override tokens before the widget mounts, or self-host the stylesheet), set disable-auto-theme on the widget and import explicitly:

import "@signalwire/web-components/theme.css";
<sw-call-widget disable-auto-theme token="…" destination="…"></sw-call-widget>

The auto-load also pulls SignalWire brand fonts (Lexend, Instrument Sans, JetBrains Mono) from Google Fonts. Set disable-auto-fonts if the site already self-hosts them.

Brand colors

For most apps, a handful of tokens is enough:

sw-call-widget,
sw-click-to-call {
  --interactive-button-primary-bg: #7c3aed;
  --interactive-button-primary-hover: #6d28d9;
  --interactive-status-success:     #10b981;
  --fg-emphasis:                    #7c3aed;
  --radius-md:                      12px;
  --type-family-body:               "Inter", sans-serif;
}

The full token surface groups into foreground / background, interactive (buttons, inputs), structure (border, radius, spacing, transition), and typography. Each web-component reference page lists the tokens it consumes, start at the Web Components reference.

Light theme

The defaults are tuned for dark UI. Scope light overrides to a wrapper; the bg / fg / border tokens propagate to the rest:

.light-section {
  --bg-page:        #fafbfc;
  --bg-surface:     #f3f4f6;
  --fg-default:     #1a1a18;
  --fg-muted:       #737371;
  --border-default: rgba(0, 0, 0, 0.1);
}

Combine with prefers-color-scheme to follow the visitor’s OS preference.

CSS Parts

Parts are named attachment points each component exposes for piercing shadow DOM.

sw-click-to-call::part(button) {
  border-radius: 999px;
  padding: 14px 28px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
}

Each reference page lists the parts the element exposes, see the Web Components reference.

Example

<style>
  .branded {
    --interactive-button-primary-bg: #7c3aed;
    --interactive-status-success:    #10b981;
    --radius-md:                     12px;
    --type-family-body:              "Inter", sans-serif;
  }
  .branded sw-click-to-call::part(button) {
    box-shadow: 0 4px 14px rgba(124, 58, 237, 0.3);
  }
</style>

<div class="branded">
  <sw-click-to-call token="c2c_…" destination="/public/support" label="Talk to us"></sw-click-to-call>
</div>

For structural changes (layout, slot composition, swapping in controls), see Customization.


Browser SDK Guides

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

The SignalWire Browser SDK is a JavaScript library that enables WebRTC-based voice, video, and chat applications directly in web browsers. Built on WebSocket architecture, it provides real-time communication capabilities without plugins or downloads.

npm\ \ @signalwire/js GitHub\ \ signalwire-js

Guides & Examples\ \ Step-by-step tutorials and practical examples to get you building quickly

npm install @signalwire/js@3

Great guides to get you started

Video Guides

Getting Started with the SignalWire Video SDK\ \ Learn how to build a video conferencing application using the SignalWire Browser SDK. Zoom like application\ \ Learn how to build a Zoom clone application using the SignalWire Browser SDK.

Chat Guides

Get Started with a Chat Application\ \ Learn how to build a chat application using the SignalWire Browser SDK.

How the Browser SDK Works

The SDK operates through WebSocket connections that handle both method calls and real-time events. When you call methods like join() or publish(), the SDK sends requests and returns promises. Simultaneously, you can listen for real-time events like new members joining or messages arriving using the .on() method.

Getting Started

1

Install the SDK

Choose your preferred installation method:

npm install @signalwire/js@3

Or include it via CDN:

<script src="https://cdn.signalwire.com/@signalwire/js"></script>

2

Obtain tokens from your server

Browser applications require tokens from SignalWire’s REST APIs for security. Create these server-side:

// Server-side: Get a Video Room token
// Replace <YOUR_SPACE>, <username>, and <password> with your actual values
const response = await fetch('https://<YOUR_SPACE>.signalwire.com/api/video/room_tokens', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    'Authorization': 'Basic ' + btoa('<PROJECT_ID>:<API_TOKEN>')  // Your SignalWire credentials
  },
  body: JSON.stringify({
    room_name: "my_room",
    user_name: "John Smith",
    permissions: [\
      "room.self.audio_mute",\
      "room.self.audio_unmute",\
      "room.self.video_mute",\
      "room.self.video_unmute",\
      "room.self.deaf",\
      "room.self.undeaf",\
      "room.self.set_input_volume",\
      "room.self.set_output_volume",\
      "room.self.set_input_sensitivity"\
    ],
    room_display_name: "My Room",
    join_as: "member"
  })
});

const { token } = await response.json();

3

Test your setup

Create a simple video room to test your setup:

NPM Package
CDN
import { Video } from "@signalwire/js";

// Join a video room
const roomSession = new Video.RoomSession({
  token: "your-room-token",  // From your server
  rootElement: document.getElementById("video-container")
});

// Listen for events
roomSession.on("member.joined", (e) => {
  console.log(`${e.member.name} joined the room`);
});

roomSession.on("room.joined", () => {
  console.log("Successfully joined the room!");
});

// Join the room
await roomSession.join();

Add this HTML element to your page:

<div id="video-container"></div>

Usage Examples

Video Conferencing
Real-time Chat
PubSub Messaging
WebRTC Utilities
import { Video } from "@signalwire/js";

const roomSession = new Video.RoomSession({
  token: "your-room-token",
  rootElement: document.getElementById("video-container"),
  video: true,
  audio: true
});

// Handle room events
roomSession.on("room.joined", () => {
  console.log("Joined the video room");

  // Set up UI controls after joining
  setupControls();
});

roomSession.on("member.joined", (e) => {
  console.log(`${e.member.name} joined`);
});

roomSession.on("member.left", (e) => {
  console.log(`${e.member.name} left`);
});

// Detect when members are talking
roomSession.on("member.talking", (e) => {
  if (e.member.id === roomSession.memberId) {
    console.log("You are talking");
  } else {
    console.log(`${e.member.name} is talking`);
  }
});

// Join the room
await roomSession.join();

// Example: Set up media controls for your UI
function setupControls() {
  // Toggle camera on button click
  document.getElementById("cameraBtn").onclick = async () => {
    if (roomSession.localVideo.active) {
      await roomSession.videoMute();
      console.log("Camera muted");
    } else {
      await roomSession.videoUnmute();
      console.log("Camera unmuted");
    }
  };

  // Toggle microphone on button click
  document.getElementById("micBtn").onclick = async () => {
    if (roomSession.localAudio.active) {
      await roomSession.audioMute();
      console.log("Microphone muted");
    } else {
      await roomSession.audioUnmute();
      console.log("Microphone unmuted");
    }
  };
}

Make a Clubhouse like application

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Our Video APIs can do more than video! In this guide, we will build an audio-only application inspired by the popular Clubhouse. Here is what we are going to build:

Your browser does not support the video.

Overview

We are going to build an audio-only application inspired by the popular Clubhouse. Our application will run on the browser and will be composed by a frontend written in React, and a small server in Node.js. We will use the Browser SDK to provide high-quality communication functionality to our application.

Before starting, a few resources:

  • ``, this repository contains the implementation of our application. The master branch contains the full implementation, while the livewire branch contains just the UI.
  • The Browser SDK Technical Reference, find here all technical details about the Browser SDK.

Getting Started

Clone the repository with the following command:

git clone -b livewire https://github.com/signalwire/browser-audioconf-example.git

We will work from the “livewire” branch, which contains some basic UI to get started.

To start the project:

npm install
npm run start  # starts both backend and frontend

Your server will listen at , while the frontend will be available at .

Server

We need to build a small server to obtain Room Tokens from SignalWire, and to get the list of active rooms. In essence, the server will do the following:

  • listen on a /get_token endpoint for POST requests from the browser. Our server will obtain a Room Token for the requested user name and room name, and will send it back to the browser.
  • send events on a WebSocket, to provide the clients with an updated list of rooms and participants whenever a change happens.

We will write the server in Node.js. Node.js is not a requirement, but it allows us to use the handy SignalWire Realtime SDK.

Obtaining your SignalWire API Token

First, we need to obtain some authentication information from SignalWire. Login to your Space here, and from the left menu go to the “API” page. Once you have navigated to the API page, click on “Create Token”. Give it a name so that you can identify it later, and make sure that the “Video” scope is enabled. Then hit “Save”. After the token is created, you will see it listed in the table.

Here are the three pieces of information that you need to copy:

  • Project ID
  • Space URL
  • Token

The API page shows the active Project ID and Space URL, and a list of API tokens organized by Name, Token, and Last Used.

The API page shows the active Project ID and Space URL, and a list of API tokens organized by Name, Token, and Last Used.

You need to put these values in the .env file inside the backend folder:

PROJECT_ID=...
API_KEY=...
SPACE=...

We are now ready to make requests to the SignalWire REST APIs.

It is important that the API tokens are kept confidential. They can be used to make API requests on your behalf. Take extreme care to make sure that the tokens don’t get pushed to GitHub. Make sure that the tokens aren’t publicly accessible, for example they must not be exposed in frontend code. For Node.js backends, you can use dotenv files or similar mechanisms to safely store confidential constants.

Endpoint: /get_token

The code of the server is located in the file backend/src/index.js.

We are now going to create a /get_token endpoint to be used by the client to obtain a Room Token for a given room and username. The core instructions in the /get_token endpoint look like this:

// Endpoint to request token for a room
app.post("/get_token", async (req, res) => {
  const { user_name, room_name } = req.body;

  const response = await axios.post(
    apiurl + "/room_tokens",
    {
      user_name,
      room_name: room_name,
      permissions: normalPermissions,
    },
    {
      auth,
    }
  );

  const token = response.data?.token;

  return res.json({ token });
});

What we are doing is to listen on POST requests on our /get_token endpoint, for a message which contains a JSON-encoded payload such as {"user_name": "...", "room_name": "..."}. We use the user name and room name to request a Room Token from SignalWire by making a POST request on the /room_tokens endpoint. Note that in this code example, apiurl is a SignalWire URL such as https://<your_space>.signalwire.com/api/video. Finally, we send the token back to the client that initiated the request.

The API token gives full access to SignalWire APIs. Whoever owns the API token can for example delete any room, mute or unmute any participant, and so on without limitations. You must only use the API token in your server to communicate with SignalWire.

The Room Token is a limited-scope token that can be used by clients to access SignalWire APIs without knowing the API token. Clients must ask for a Room Token to your own server, which in turn will obtain it from SignalWire servers and pass it back to the client. Room Tokens are associated to a given < user, room > pair, so you can think of them as a personal key to access a given room, by a given user. Your server decides the permissions for each individual Room Token, for example whether they are allowed to mute other users.

WebSocket: rooms_updated

The implementation for this feature is also located in backend/src/index.js.

We use Socket.IO to create a WebSocket over which to send a list of rooms and, for each room, the list of participants inside. We want to send the updates whenever there is a change.

First, we write a function to obtain the list of rooms and participants using the REST APIs:

async function getRoomsAndParticipants() {
  // Get all most recent room sessions
  let rooms = await axios.get(`${apiurl}/room_sessions`, { auth });
  rooms = rooms.data.data; // In real applications, check the "next" field.

  // Filter to get only the in-progress room sessions
  rooms = rooms.filter((r) => r.status === "in-progress");

  // Augment each room session object with the list of participants in it
  rooms = await Promise.all(
    rooms.map(async (r) => ({
      ...r,
      members: (
        await axios.get(`${apiurl}/room_sessions/${r.id}/members`, { auth })
      ).data.data,
    }))
  );

  return rooms;
}

In this case we use two different SignalWire endpoints: the first, /room_sessions, gives us a list of room sessions. This however does not include information about the participants, so we use the endpoint /room_sessions/:id/members to get a list of members for the given room session id. We pack everything in an array, and we return it asynchronously.

We use the getRoomsAndParticipants function whenever we detect an update using the SignalWire Realtime SDK. The Realtime SDK allows listening to events from rooms, sessions, and members. Here is how we do it:

// We create a SignalWire Realtime SDK client.
const realtimeClient = await createClient({
  project: auth.username,
  token: auth.password,
});

// Function that sends a `rooms_updated` events over Socket.IO.
const emitRoomsUpdated = async () =>
  io.emit("rooms_updated", await getRoomsAndParticipants());

// When a new Socket.IO client connects, send them the list of rooms
io.on("connection", (socket) => emitRoomsUpdated());

// When something changes in the list of rooms or members, trigger a new
// event.
realtimeClient.video.on("room.started", async (room) => {
  emitRoomsUpdated();
  room.on("member.joined", () => emitRoomsUpdated());
  room.on("member.left", () => emitRoomsUpdated());
});
realtimeClient.video.on("room.ended", () => emitRoomsUpdated());

await realtimeClient.connect();

We use Socket.IO to emit a rooms_updated event that the clients will listen to and update their user interface.

Frontend

File structure

We implement the frontend as a React application. We have structured the UI around three pages, and we are mainly interested in two component files:

frontend

src

pages

LoginPage.js# Login screen RoomListPage.js# Browse, join, or create rooms RoomPage.js# Active room with participant list

components

Audio.js# SignalWire JS SDK integration Server.js# Server API calls; Room Token retrieval

We will start by writing the logic in Server.js.

Server.js

getToken

First, we implement the getToken function. This function performs a POST request to our /get_token endpoint specifying a room name and a user name, and returns a Room Token.

export async function getToken(user, room) {
  const response = await axios.post(`${url}/get_token`, {
    user_name: user,
    room_name: room,
  });
  return response.data.token;
}

This is all we needed for what concerns the communication with the server. Indeed, refreshing the list of rooms is handles in RoomListPage.js, like this:

const socket = socketIOClient(Server.url);
socket.on("rooms_updated", (rooms) => {
  setRooms(rooms);
  setIsLoading(false);
});

The above code connects the WebSocket and, whenever it receives a rooms_updated events, refreshes the list of rooms in the UI.

We can now connect to a room using the SignalWire JavaScript SDK.

Audio.js

In frontend/src/components/ Audio.js we have the logic to connect with SignalWire by using the SignalWire JavaScript SDK. In particular, we have a function declared as follows:

async function Audio({
  room,
  user,
  onParticipantsUpdated = () => { },
  onParticipantTalking = () => { },
  onMutedUnmuted = () => { },
})

Here, room and user are strings that indicate the respective names. The parameter onParticipantsUpdated is a function that we must call whenever the list of participants changes, and receives the list of participants. The UI will handle it. Similarly, we must call onParticipantTalking when a given participant starts or stops talking, and onMutedUnmuted when we get muted or unmuted. The React UI in the application uses these callbacks to update the interface.

We will proceed in steps:

  1. We will create a RoomSession object with a Room Token.
  2. We will connect events to detect whenever the list of participants has been updated.
  3. We will connect events to detect whenever we get muted or unmuted.
  4. We will connect events to detect when a participant is talking.
  5. We will join the room session.

Step 1: creating a RoomSession object

Make sure that the package @signalwire/js is installed, and is at version v3.5.0 or higher.

First, let’s import both the SignalWire SDK and our Server file:

import * as SignalWire from "@signalwire/js";
import * as Server from "./Server";

We use the Server component to get a token:

const token = await Server.getToken(user, room);

Then, we create a RoomSession object specifying the token and some settings:

const roomSession = new SignalWire.Video.RoomSession({
  token: token,
  audio: true,
  video: false,
});

Here, we have specified that we only want to use audio functionality.

Before actually joining the room session, we are going to connect some events. Here, we connect all events that indicate a variation in the list of members. In the event handlers, we call onParticipantsUpdated to let the UI know the updated list of members.

// Internal list of members
let members = [];

roomSession.on("room.joined", async (e) => {
  console.log("Event: room.joined");
  const currMembers = await roomSession.getMembers();
  members = [...currMembers.members];
  onParticipantsUpdated(members);
});

roomSession.on("member.joined", (e) => {
  console.log("Event: member.joined");
  members = [...members, e.member];
  onParticipantsUpdated(members);
});

roomSession.on("member.updated", (e) => {
  console.log("Event: member.updated");
  const memberIndex = members.findIndex((x) => x.id === e.member.id);
  if (memberIndex < 0) return;
  members[memberIndex] = {
    ...members[memberIndex],
    ...e.member,
  };
  onParticipantsUpdated([...members]);
});

roomSession.on("member.left", (e) => {
  console.log("Event: member.left");
  members = members.filter((m) => m.id !== e.member.id);
  onParticipantsUpdated([...members]);
});

Step 3: muted/unmuted events

We need to detect when we get muted or unmuted. This may happen for example if an administrator mutes us. If we have been muted or unmuted, we call onMutedUnmuted.

roomSession.on("member.updated", (e) => {
  // Have we been muted/unmuted? If so, trigger an event.
  if (e.member.id === roomSession.memberId) {
    if (e.member.updated.includes("audio_muted")) {
      onMutedUnmuted(e.member.audio_muted);
    }
  }
});

We need to detect when a user starts or stops speaking, so that the UI can update accordingly. Whenever we detect such event, we call onParticipantTalking passing the id of the participant and whether they are currently talking.

roomSession.on("member.talking", (e) => {
  console.log("Event: member.talking");
  onParticipantTalking(e.member.id, e.member.talking);
});

Step 5: join the room session

Now that all events are connected, we can join the room!

await roomSession.join();
console.log("Joined!");

return roomSession;

We return the RoomSession object so that its methods (e.g., audioMute) can be used from the outside.

Find the full code at frontend/src/components/ Audio.js.

Muting the microphone

Right now, the button to mute the microphone does not work. We can make it work by connecting it in frontend/src/pages/ RoomPage.js. In that file, there is a function named toggleMute, which is called whenever a user clicks on the mute button. In addition, inside RoomPage we have a reference to the RoomObject returned by Audio.js: it is stored as roomSession.current.

We can then mute or unmute the user as follows:

function toggleMute() {
  if (!roomSession.current) return;

  // The RoomSession object returned by the Audio function is
  // stored in `roomSession.current`.

  if (muted) {
    // We need to unmute
    roomSession.current.audioUnmute(); // <-- add this
  } else {
    // We need to mute
    roomSession.current.audioMute(); // <-- add this
  }

  setMuted(!muted);
}

Wrap up

We have built a Clubhouse-like application with the SignalWire JavaScript SDK.

You can find the full code for this application in the master branch of our GitHub repository here.

Sign Up Here

If you would like to test this example out, create a SignalWire account and Space.

Please feel free to reach out to us on our Community Discord or create a Support ticket if you need guidance!

As Seen on LIVEWire

If you want to see a live code breakdown, explanation, and demonstration of this guide at work, click here or check it out below to watch it on YouTube! While you’re there, feel free to take a look at our YouTube Channel to see other LIVEWire code and application breakdowns!

YouTube


Display call thumbnails

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

Once you start to host multiple rooms with several people each, you might want a way to peek into the rooms. Room names only take you so far.

A preview of a Video Room. Text at the top reads 'Join a room'. Four previews are shown, each labeled with the room name.

Room Previews

Introducing Video Previews

Video previews are live thumbnails of the ongoing room sessions. They refresh twice every minute, and record a small slice of the room. You can use these previews to represent a room.

Turning Video Previews On

Depending on how you are creating your rooms, you need to enable video previews before you can begin using them.

If you’re using the API to programmatically create rooms, you need to set the enable_room_previews attribute to true when creating the new room.

If you’re auto-creating a new room when requesting a room token, you need to set the enable_room_previews attribute to true .

If you’re using the new programmable video communication tool, just turn on Enable Room Previews option from settings.

This screenshot shows the configuration options for Video Conferences.

Turning room previews on from the UI.

Obtaining the actual previews

SignalWire makes the video previews accessible as animated .webp images. There are a few ways to get their URL: some might be easier or better suited based on your application. In the following sections we review the different methods, namely REST APIs, JavaScript SDKs, and Programmable Video Conferences.

REST API

If you have a proxy backend (as described in the Simple Video Demo), you can query the Rest API for the room sessions. You can either list all room sessions with the GET /api/video/room_sessions endpoint. Or if you have the id of your current room session, you can GET /api/video/room_sessions/{id}.

The URL for the preview image will be in the attribute, preview_url for the room session. If preview is turned off, there’ll be a null instead of the URL.

Video Client SDK

For the Video Client SDK running in the browser, the previewUrl is available in the same RoomSession object you create to start the video call.

You will find the preview image in the previewUrl attribute of the RoomSession object.

Refreshing the previews

Vanilla HTML/JavaScript

The previews of the room are regenerated a few times every minute. The content changes, but the URL remains the same. To keep them up to date in your website, you should keep on updating them using a timing mechanism like createInterval. For example, using Programmable Video Conferences with AppKit:

<body>
  <img id="preview" />
  <script>
    // Video Conference embed code...
    // !function(e,t){function i(){let ...

    SignalWire.AppKit.VideoConference({
      token: "<your room token>",
      setupRoomSession: function (roomSession) {
        roomSession.on("room.joined", (room) => {
          console.log(roomSession.previewUrl);
          setInterval(() => {
            const preview_img = roomSession.previewUrl;
            document.getElementById("preview").src = preview_img;
          }, 10000);
        });
      },
    });
  </script>
</body>

React

If you are using React, you can use the @signalwire-community/react package which offers a handy component for rendering room previews. You just need to provide the URL, and the component will take care of refreshing, caching, loading indicators, and so on.

For example:

// npm install @signalwire-community/react

import { RoomPreview } from "@signalwire-community/react";

export default function App() {
  // const previewUrl = ...

  return (
    <RoomPreview
      previewUrl={previewUrl}
      loadingUrl={"https://swrooms.com/swloading.gif"}
      style={{ height: 150 }}
    />
  );
}

React Native

If you are using React Native, you can use the @signalwire-community/react-native-room-preview package which offers a handy component for rendering room previews. Just like for the React component, you just need to provide the URL, and the component will take care of refreshing, caching, loading indicators, and so on.

For example:

import React from "react";
import { SafeAreaView } from "react-native";
import { RoomPreview } from "@signalwire-community/react-native-room-preview";

export default function App() {
  return (
    <SafeAreaView>
      <RoomPreview
        previewUrl={{ uri: "https://my-preview-url" }}
        loadingUrl={{ uri: "https://swrooms.com/swloading.gif" }}
        style={{ width: "50%" }}
      />
    </SafeAreaView>
  );
}

Demo

The demo code is also available on GitHub.


Interactive live streaming

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

In case of large events, scalability is key. If you need to broadcast your live video event to a large audience, we have a couple of solutions.

As a first option, you can use RTMP Streaming. With RTMP Streaming, you can stream the audio and video of the video room to an external service such as YouTube, from where your audience can watch.

Streaming with RTMP works fine in many cases, but sometimes you may need more flexibility. What if you want to temporarily bring a member from the audience on stage, for example to ask or answer a question? What if you want your own custom UI? To address these advanced use cases, we support Interactive Live Streaming.

What is Interactive Live Streaming

You can use Interactive Live Streaming with any of your video rooms. When streaming, a room can have two different kinds of participants: audience and members.

An audience participant can only watch and listen: their own media is not going to be shared. On the other hand, a member is the typical videoconference room member: they can watch and listen, but their own media is also shared with all other participants in the room. Depending on their permissions, members can also perform other actions, such as changing the layout of the room or playing videos.

When streaming, audience participants can be promoted to members and, vice-versa, members can be demoted to audience participants.

Source Code on GitHub\ \ The source code for this application is available on GitHub.

Joining a room

When using the Browser SDK, the kind of Video Room Token that you get determines whether you join the room as audience or as a member.

Joining as audience

To join as audience, specify "join_as": "audience" when creating a token.

POST

/api/video/room_tokens

cURL

curl -X POST https://{your_space_name}.signalwire.com/api/video/room_tokens \
     -H "Content-Type: application/json" \
     -u "<project_id>:<api_token>" \
     -d '{
  "room_name": "my_room"
}'

Try it

curl -L -X POST "https://$SPACE_URL/api/video/room_tokens" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -u "$PROJECT_ID:$API_TOKEN" \
  --data-raw '{
    "room_name": "my_room",
    "user_name": "John Smith",
    "join_as": "audience"
  }'

Then use the returned token to initialize a RoomSession.

Joining as a member

To join as an audience member, specify "join_as": "member" when creating a token.

curl -L -X POST "https://$SPACE_URL/api/video/room_tokens" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -u "$PROJECT_ID:$API_TOKEN" \
  --data-raw '{
    "room_name": "my_room",
    "user_name": "John Smith",
    "join_as": "member"
  }'

Then use the returned token to initialize a RoomSession:

import * as SignalWire from "@signalwire/js";

const roomSession = new SignalWire.Video.RoomSession({
  token: "<YourRoomToken>",
  rootElement: document.getElementById("yourVideoElement"),
});

roomSession.join();

For more information about using tokens to join a video room, please refer to our build a video application guide. Follow that guide to learn the basics about instantiating custom video rooms using the SDKs.

Promoting and demoting

Using the SDKs, you can programmatically promote and demote participants, to allow the audience participants to interact with the room and vice-versa.

To promote a member, use the promote method:

await roomSession.promote({
  memberId: "de550c0c-3fac-4efd-b06f-b5b8614b8966",
  mediaAllowed: "all",
  permissions: [\
    "room.self.audio_mute",\
    "room.self.audio_unmute",\
    "room.self.video_mute",\
    "room.self.video_unmute",\
    "room.list_available_layouts",\
  ],
});

Only members can promote other participants. As you can observe from the code snippet above, you can specify a set of permissions to assign to the new member.

The memberId value identifies the id of the audience participant that you want to promote.

Demoting a member back to the audience, instead, is performed by the demote method:

await roomSession.demote({
  memberId: "de550c0c-3fac-4efd-b06f-b5b8614b8966",
  mediaAllowed: "all",
});

Wrap up

We have seen how to use Interactive Live Streaming to easily build next generation communication platforms. Make sure to check out our technical documentation. If, instead, you are just starting out, then we suggest reading our guide on how to get started with the Video APIs.


Record calls

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

If you are using SignalWire to conduct your video conferences, it is quite simple to record the video feed and access them later at your convenience. Depending on how you are using SignalWire Video, there are several ways you might go about controlling your recordings.

How to start a recording

From the Embeddable Video Conference Widget

If you are using Embeddable Video Rooms in your website, just click the Start Recording option to start the recording.

Anyone with a moderator token will be able to start and stop recording. Embed the guest video room version on public pages for people that shouldn’t be able to control recordings.

Embedded video conference widget with Start Recording' selected

Starting a recording from the Embeddable Video Conference Widget.

If you are using AppKit to create or extend UI-included rooms, use the setupRoomSession callback to get a reference to the RoomSession object. You can use that reference to the RoomSession object to start recordings.

<script>
  // ... the code snippet you copied from
  // your SignalWire Space ...
  SignalWire.AppKit.VideoConference({
    token: "vpt_xxxxxxxxxxxxxxxxxxx",

    // add this part to the snippet to control recording
    setupRoomSession: (roomSession) => {
      roomSession.on("room.joined", () => {
        // Start recording
        const rec = await roomSession.startRecording();
        // Stop recording after 10 seconds
        setTimeout(rec.stop, 10 * 1000);
      })
    },
  });
</script>

From the Browser SDK

To start recording in an ongoing room session from the browser SDK, use the RoomSession.startRecording() method. You must have the room.recording permission to be able to start and stop recording.

This method returns a Promise which resolves to a RoomSessionRecording object. You can use this returned object to control the recording, including pausing and stopping it.

// Join a room
const roomSession = new SignalWire.Video.RoomSession({
  token: "<Your Token Here>",
  rootElement: document.getElementById("root"),
});
await roomSession.join();

// Start recording
const rec = await roomSession.startRecording();

// Stop recording after 10 seconds
setTimeout(rec.stop, 10 * 1000);

The Record on Start Option

To start recording the video conference as soon as it is started, use the Record on Start option. With this option enabled, all sessions occurring in that room will automatically be recorded.

If you are creating a Embeddable Video Conference, it will be available via your SignalWire Dashboard (at the Conferences tab on the Video page).

If you are creating an advanced room through the REST API, use the record_on_start option while creating the room. Further, you have to make sure that the room.recording permission is set in the room token.

The Record on Start setting is the only control the REST API provides related to room recording. To control room recordings more precisely from your server, use the SDK’s Video API. The SDK exposes room session management similar to the Browser SDK, so you have finer control over the room session in progress.

How to Stop a Recording

From the Embeddable Video Conference Widget

To stop an ongoing recording through the Embeddable Video Conference widget, click the Stop Recording option which should have replaced the “Start Recording” button once active.

From the Browser SDK

Use the RoomSessionRecording.stop() method to stop the ongoing recording. This method is included on the object returned when you called the RoomSession.startRecording() method.

const rec = await roomSession.startRecording();
await rec.stop();

How to Access Recordings

From the SignalWire Dashboard

Any recording you make will be available in your SignalWire Dashboard for download at the Storage sidebar tab. Navigate to Storage > Recordings to view, or download your recordings.

From the REST APIs

You can get a list of all videos that have been recorded with a GET request at https://<space_name>.signalwire.com/api/video/room_recordings.

The request returns a JSON object with a paginated array of all room recordings, including the id of the room session which was recorded, and a uri string that you can use to download the recording.

GET

/api/video/room_recordings

cURL

curl https://{your_space_name}.signalwire.com/api/video/room_recordings \
     -u "<project_id>:<api_token>"

Try it

Conclusion

There are several ways you can record your video conferences and calls, most of them just a handful of clicks away. Your recordings will stay in the SignalWire servers so you can access them when you need, and delete them if you don’t.


Stream to YouTube and other platforms

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

In this guide, we will show how to stream a video room to external services like YouTube. We are going to use the Streaming APIs, which represent the simplest way to set up a stream, and the Browser SDK.

Getting started

Fresh

To get started, we need to create a video room, or find the id of an existing one. You can create a new room either from the REST APIs, or from the UI. For this guide, we will use a room with UI included. A UI-included video conference room can be created from the Resources section of the Dashboard.

You also need to set up your streaming service. Any service supporting RTMP or RTMPS works with SignalWire. In any case, you will need a stream URL and, sometimes, a stream key. For YouTube, you will find your stream key and your stream URL in a screen such as the one depicted in the image below.

The YouTube stream parameters menu. The Stream Settings tab is selected, with fields for Stream Key type, Stream Key, Stream URL, and Backup server URL. Appropriate SignalWire parameters have been entered in each field.

YouTube stream parameters. Regardless of the streaming service that you are using, you need to make a note of the stream URL (which usually starts with 'rtmp') and, when provided, of the stream key.

Connecting the stream with the Dashboard UI

If you would like your video conference to start a stream every time you enter the video room, you can set up a stream from the room’s settings. This will start a stream automatically when you join the room from the Dashboard or when you join the room embedded in an external application.

From your Video Dashboard, click on the name of the conference you would like to use. This setting is only available on conferences with UI included. Then, click on the Streaming tab.

Click the blue “Setup a Stream” button and input the RTMP URL. If there is no stream key, simply use the stream URL that you copied from the streaming service. If you have a stream key, append it to the stream URL, separated by a slash like this: rtmp://<stream_url>/<stream_key>. Then, hit “Save”. You can delete the stream later if you need to from the ⋮ menu.

You can now start the room by joining it from the Dashboard or from where it is embedded. You should be able to see your stream in the streaming service within a few seconds.

Security Considerations

Setting up streaming in the Programmable Video Conference (PVC) room settings will automatically start the outbound stream in any active instance of the room. That means that if you embed the same PVC in multiple applications, the stream will start when the room is joined from any of those applications. Ensure the room is embedded in a secure application accessible only to users who you would like to be able to stream.

Connecting the stream with REST APIs

We can also use the REST APIs to connect a room to the stream. We need five things:

  • Your Space name. This is the <name> in your Space URL <name>.signalwire.com
  • Your Project Id.
  • Your API token.
  • The UUID of the room you want to stream. From your SignalWire Space, open the configuration page for your room: you will find the UUID below your room’s name, which will look like this: 431dcfbe-2218-44ae-7e2f-b5a11a9c79e9.
  • The final RTMP URL. If you don’t have a stream key, simply use the stream URL that you copied from the streaming service. If you also have a stream key, append it to the stream URL, separated by a slash. Like this: rtmp://<stream_url>/<stream_key>.

We are now ready to associate the stream with the room.

If you are using a room with UI included:

curl --request POST 'https://<your space>.signalwire.com/api/video/conferences/<RoomUUID>/streams' \
    --header 'Content-Type: application/json' \
    --header 'Accept: application/json' \
    -u "<ProjectId>:<APIToken>"
    --data-raw '{
      "url": "<YourFinalRTMPUrl>"
    }'

If you are using a room without UI included:

curl --request POST 'https://<your space>.signalwire.com/api/video/rooms/<RoomUUID>/streams' \
    --header 'Content-Type: application/json' \
    --header 'Accept: application/json' \
    -u "<ProjectId>:<APIToken>"
    --data-raw '{
      "url": "<YourFinalRTMPUrl>"
    }'

You can now start the room by joining it. You should be able to see your stream in the streaming service within a few seconds.

Security Considerations

As with setting up a stream in the Dashboard UI, setting the stream with the REST API will automatically start the outbound stream in any active instance of the room. Ensure the video conference is embedded in a secure application accessible only to users who you would like to be able to stream.

Connecting the stream with SDKs

Finally, you may choose to use the Video SDK to set up the stream. With this option, you can build an application that starts and stops the RTMP stream. You can see a full demo application on the Guides Repo.

For this demo, we first created a PVC in the Video Dashboard and copied the embed code. We pasted the embed code in an html file and added buttons to start and stop the stream.

<div id="pvc">
  <!--paste PVC embed code here-->
  <div id="button-bar">
    <form id="rtmp-form">
      <label for="stream-url">RTMP Streaming URL</label>
      <input
        type="url"
        id="stream-url"
        required
        placeholder="rtmp://&lt;stream_url&gt;/&lt;stream_key&gt;"
      />
      <button id="start" type="submit">Start Stream</button>
      <button id="stop">Stop Stream</button>
    </form>
    <p id="note">
      If your streaming service provides a stream key, append it to the stream URL,
      separated by a slash.
      <br />
      ex: rtmp://&lt;stream_url&gt;/&lt;stream_key&gt;.
    </p>
  </div>
</div>

Later, we will put click handlers on the start and stop buttons to call startStream and stopStream respectively. The startStream function is available on the Room Session object, so first we need to use the setupRoomSession callback function on the PVC to get that object. So, the VideoConference constructor at the end of the embed script should look like this:

SignalWire.AppKit.VideoConference({
  token: "vpt_40b...458",
  setupRoomSession: setRoomSession,
});

We can then access setRoomSession in the external JavaScript file and use the Room Session object returned to set event listeners and click handlers. The JavaScript file will look something like this:

let roomSession;
let stream;

const stopStream = () => {
  if (stream) {
    stream.stop();
  }
};

const setRoomSession = (session) => {
  roomSession = session;
  roomSession.on("room.left", () => {
    stopStream();
  });
};

document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("rtmp-form").onsubmit = async (e) => {
    e.preventDefault();

    const url = document.getElementById("stream-url").value;
    try {
      stream = await roomSession.startStream({ url });
    } catch (error) {
      console.log(error);
      alert(
        "There was an error starting the stream. Please check your URL and try again."
      );
    }
  };

  document.getElementById("stop").onclick = (e) => {
    e.preventDefault();
    try {
      stopStream();
    } catch (e) {
      console.log(e);
    }
  };
});

The full demo application has some cosmetic additions, but these two files are all you need to get an RTMP outbound stream set up from any application with an embedded PVC. You should be able to see your stream in the streaming service within a few seconds of pressing “Start Stream”.

While this demo used a PVC, you can use the same methods on a video room without the prebuilt UI. For a complete guide on building video rooms without a prebuilt UI, see the build a video application guide. From there, you can add start and stop stream buttons and hook them up in the same way as above.

Wrap up

We showed the options to configure an RTMP stream that allows you to stream the content of your video room to any compatible streaming service: the Dashboard UI, a POST request, or an SDK application is all you need.

Resources

  • API Streams Reference
  • SDK Streaming Demo
  • SDK Streaming Reference
  • PVC Reference

Switch devices during calls

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

SignalWire Video API allows you to host real-time video calls and conferences on your website. In this guide, we’ll learn to allow users to change the camera and microphone that’s being used in the call.

Getting Started

If you haven’t yet set up a video conference project using the Video SDK, you can check out the build a video application guide first. After you have a video application set up, you can continue with this guide.

Getting a list of supported input devices

First, we want to find out what devices are available as input. Getting the list of media devices is handled by the WebRTC object available via SignalWire.WebRTC from the SDK. The methods in the WebRTC allow you to get the list of microphones, cameras, and speakers.

Listing webcams

To get the list of connected devices that can be used via the browser, we use the getCameraDevicesWithPermissions() method in WebRTC. The method returns an array of InputDeviceInfo object, each of which have two attributes of interest to us here: InputDeviceInfo.deviceId and InputDeviceInfo.label. The label will be used to refer to the webcam via the UI, and looks like ‘Facetime HD Camera’ or ‘USB camera’. The deviceId is used in your code to address a particular device.

const cams = await SignalWire.WebRTC.getCameraDevicesWithPermissions();
cams.forEach((cam) => {
  console.log(cam.label, cam.deviceId);
});

Listing microphones

Exactly as with getCameraDevicesWithPermissions(), we can use the getMicrophoneDevicesWithPermissions() to get a list of allowed microphones.

const mics = await SignalWire.WebRTC.getMicrophoneDevicesWithPermissions();
mics.forEach((mic) => {
  console.log(mic.label, mic.deviceId);
});

Changing webcams and microphones

Once you have set up the video call with SignalWire.Video.joinRoom() or equivalent methods, we can use Room.updateCamera() and Room.updateMicrophone() to change devices.

As a simplified example:

const roomSession = new SignalWire.Video.RoomSession({
  token,
  rootElement: document.getElementById("root"), // an html element to display the video
  iceGatheringTimeout: 0.01,
  requestTimeout: 0.01,
});

try {
  await roomSession.join();
} catch (error) {
  console.error("Error", error);
}

const cams = await SignalWire.WebRTC.getCameraDevicesWithPermissions();
const mics = await SignalWire.WebRTC.getMicrophoneDevicesWithPermissions();

// Pick the first camera in the list as the new video input device
roomSession.updateCamera({
  deviceId: cams[0].deviceId,
});

// Pick the first microphone in the list as the new audio input device
roomSession.updateMicrophone({
  deviceId: mics[0].deviceId,
});

Note that you don’t explicitly have to update camera and microphone. SignalWire Video SDK chooses the preferred input devices by default on setup. Only updateCamera or updateMicrophone when you want to switch to a non-default device.


Make a Zoom like application

For AI agents: a documentation index is available at the root level at /llms.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.

In this guide, we are going to make a Zoom-like video conferencing system using React, SignalWire APIs, SDKs and other tools.

The full source code for this project is available on GitHub.

We will use:

  1. The SignalWire Video SDK will run in the client’s browser. It handles the cameras, the microphones, communication with the SignalWire servers, and with other members in the conference. We will also use this SDK to display the video stream in the browser.

  2. The SignalWire REST APIs for Video to provision rooms and access tokens for your conference members from the SignalWire server. SignalWire REST APIs are only available on your server, as they require your SignalWire API tokens to operate which shouldn’t be exposed client-side.

  3. The React library from SignalWire Community to handle the integration between the SDK and React.

We will be using Next.js for convenience and brevity, but you should be able to use any React framework to write the frontend and any server-side framework to write the backend. We will use the React Bootstrap framework to make a neat layout without too much boilerplate.

If you are looking for something far simpler to quickly embed on your existing page, please use the UI-included video room from the Dashboard instead.

Setting Up the project

Our starting point will be the Next.js boilerplate on which we will install the packages discussed above:

yarn create next-app --typescript
cd <your app name>
yarn add @signalwire-community/react
yarn add bootstrap react-bootstrap react-bootstrap-icons swr axios

Backend

While most of the work with respect to capturing and displaying media in the conference happens client-side, you do still need a server to securely proxy the SignalWire REST API. The client SDK needs a token be able to access the SignalWire servers hosting the conference. Your server can query for this token using SignalWire REST API, given that you have the API credentials.

Note that this is not the server where all the video streaming and processing happens. All those complex tasks will be handled by powerful SignalWire servers elsewhere. The figure below illustrates how all parts fit.

Diagram of the interaction between the client, your server, and SignalWire.

In a production setting, your server should authenticate your users, manage their permissions, get appropriate tokens for members and relay the tokens from the SignalWire’s Video REST APIs to the client’s browser.

The following code will create a new endpoint at /api/token, which will query SignalWire and serve tokens given at least a valid room_name. It also takes additional user_name and mod parameters. The user_name parameter simply sets the display name for the user requesting the token. The mod parameter (short for “moderator” in this case) selects between the two sets of permissions defined in permissions.ts which can be assigned to the user.

Note that the location of this file ensures that this will run server-side at api/token endpoint. Learn more about Next.js routing here.

token.ts
permissions.ts

pages/api/token.ts

import axios from "axios";
import { FULL_PERMISSIONS, GUEST_PERMISSIONS } from "../../data/permissions";

const AUTH = {
  username: process.env.PROJECT_ID as string,
  password: process.env.API_TOKEN as string,
};
const SPACE_NAME = process.env.SPACE_NAME as string;

export default async function handler(req: any, res: any) {
  const { room_name, user_name, mod } = req.query;

  if (room_name === undefined) return res.status(422).json({ error: true });

  try {
    const tokenResponse = await axios.post(
      `https://${SPACE_NAME}.signalwire.com/api/video/room_tokens`,
      {
        room_name,
        user_name,
        enable_room_previews: true,
        permissions: mod === "true" ? FULL_PERMISSIONS : GUEST_PERMISSIONS,
      },
      { auth: AUTH } // pass {username: project_id, password: api_token} as basic auth
    );
    const token = tokenResponse.data.token;

    if (token !== undefined) res.json({ token, error: false });
    else res.status(400).json({ error: true });
  } catch (e) {
    res.status(400).json({ error: true });
  }
}

In a production setting, you would want this endpoint to be behind an authentication middleware to make sure only your intended users can use it. For Next.js, an easy addition would be next-auth.

You might also want to check if the users requesting mod permissions have the authorization to actually do so in your system.

To quickly go over various parts of this code:

  1. The constants FULL_PERMISSIONS and GUEST_PERMISSIONS are arrays of strings representing the permissions given to the user. So while FULL_PERMISSIONS might look like [..., 'room.member.video.mute', 'room.member.remove', ...], GUEST_PERMISSIONS would look like [..., 'room.self.video.mute'], indicating that guest is not allowed to mute or remove any other user.

SignalWire offers a flexible permission system so you can give users all combination of permissions as required. Permissions are described here.

  1. The constant AUTH is a structure that assigns your SignalWire Project ID as the username, and the API token as password. You will find the Project ID and API token at your SignalWire Dashboard ( explained here). We will use this for basic auth to authenticate with the SignalWire REST API.

The constant SPACE_NAME is your SignalWire username which you also use as the subdomain to access your Dashboard.

  1. We perform an HTTP POST request using Axios to the room_tokens endpoint. We will send the name of the room, the name of the user, and the array of permissions for the user to this endpoint. We will also give axios the Project ID and the API token to be encoded as basic authentication header.

If all goes well, the SignalWire server will send us a token that we can forward to the client.

Thunder Client showing a GET query being used to test the /api/token endpoint.

Testing the /api/token endpoint with Thunder Client

This simple backend will suffice to be able to conduct video conferences. But we will have one more endpoint to add here to support room previews.

Frontend

We will rely heavily on the SignalWire Community React library ( @signalwire-community/react) to write the frontend.

Basic Video Feed

Consider the following piece of code.

pages/rooms/[roomName]/index.ts

// other imports
import { Video } from "@signalwire-community/react";

export default function Room() {
  const router = useRouter();
  const { roomName, userName, mod } = router.query;
  const [roomSession, setRoomSession] = useState<any>();

  const { data: token } = useSWRImmutable(
    roomName !== undefined
      ? `/api/token?room_name=${roomName}&user_name=${userName}&mod=${mod}`
      : null
  );

  if (!router.isReady) {
    return Loading;
  }

  if (roomName === undefined || roomName === "undefined") return Error;

  return (
    <Container>
      {token?.token && (
        <Video
          token={token.token}
          onRoomReady={(r) => {
            setRoomSession(r);
          }}
          onRoomLeft={() => router.push("/")}
        />
      )}
    </Container>
  );
}

A few things to note about this code are:

  1. Next.js router places it at /rooms/[roomName] where roomName can be any URL-safe string. So /rooms/guest should take you to the guest room automatically. The dynamic roomName parameter is accessible at useRouter().query.roomName. The userName and mod parameters should come from the URL query string (/rooms/guest?userName=user&mod=false)

  2. We are using the immutable variant of the swr library to load the token. The React hook useSWRImmutable sends a GET request to /api/token just once after it is instantiated. we made /api/token in the previous section. We are using swr for convenience here, but you are free to use any way to HTTP GET /api/token.

  3. The <Video /> component from @signalwire-community/react is supplied the token from the backend. It uses the token to connect to the video feed for the room, and it asks for permission to access camera and microphone from the user. With this component alone, you should be able to video conference in the room by just navigating to localhost:3000/rooms/guest in multiple tabs.

Video Controls

With the tokens received and the video feed showing, all that’s left is for us to show controls. The way we have chosen to go here is to have a separate <Toolbar/> component which takes a RoomSession object and renders controls for the room. The RoomSession object is emitted by the onRoomReady event from the <Video /> component. For simplicity, each control is written as it’s own component ( <Participants/>, <LayoutSelector/>, <Controls/>). We will go over each of these components briefly as we discuss the code.

Toolbar.tsx
Controls.tsx

components/Toolbar/Toolbar.tsx

import { Button, Container, Navbar } from "react-bootstrap";
import { Video } from "@signalwire/js";
import {
  useLayouts,
  useMembers,
  usePermissions,
  useScreenShare,
  useStatus,
} from "@signalwire-community/react";
import Participants from "./Participants/Participants";
import LayoutSelector from "./LayoutSelector";
import Controls from "./ControlButtons/Controls";

export default function Toolbar({
  roomSession,
}: {
  roomSession: Video.RoomSession,
}): JSX.Element {
  const { self, members } = useMembers(roomSession);
  const { toggle, active } = useScreenShare(roomSession);
  const { active: roomActive } = useStatus(roomSession);
  const layoutControls = useLayouts(roomSession);
  const P = usePermissions(roomSession);

  return (
    <>
      <Navbar bg="light" expand="lg" fixed="bottom">
        <Container>
          <Controls control={self} self={true} disabled={!roomActive} />

          <Participants members={members} disabled={!roomActive} P={P} />

          {P?.layout && (
            <LayoutSelector layoutControls={layoutControls} disabled={!roomActive} />
          )}

          {P?.screenshare && (
            <Button
              variant={active ? "danger" : "success"}
              onClick={toggle}
              disabled={!roomActive}
            >
              {active ? "Stop" : "Share Screen"}
            </Button>
          )}

          <Button
            variant="danger"
            onClick={() => {
              self?.remove();
            }}
            disabled={!roomActive}
          >
            Leave
          </Button>
        </Container>
      </Navbar>
    </>
  );
}

There are some interesting things going on in this code.

  1. The component <Toolbar/> takes only a RoomSession object as prop, because we only need that object to manipulate the room.

  2. The usePermissions() hook is being used to check for permissions allowed to the user and only render the controls which are allowed. The usePermissions hook maps the permissions given to the user (like room.member.video_mute) to an object P?.member?.video_mute). It also adds some convenience aggregates. For example, P?.layout is true if both room.list_available_layouts and room.set_layout is true. Similarly, P?.member?.video_full is true if both mute and unmute permissions are given to the user.

  3. We are passing a disabled prop to all controls. It doesn’t make sense to have the buttons look active when the room is not connected. So we use the useStatus hook is used to check if the room is active, and only enable the controls if it is.

  4. To the <Participants/> component, we are passing the member array from useMembers. The component simply renders the list of members with a .map().

  5. To the <Controls/> component, we are passing the self object, which is a reference to the current user. It renders the mute/unmute buttons for microphone, camera, and speakers. It also allows users to change devices being used (switch from one webcam to another or from earphones to speakers) using another hook useWebRTC, which we will discuss further below.

  6. The <LayoutSelector/> takes layoutControls from useLayouts hook, which has a list of all allowed layouts, a way to change layouts and the layout currently being used. It renders a selector for layouts.

a video conference, showing controls for video, audio, participants, invitations, and layout.

A video conference with all controls

The useWebRTC() Hook

The useWebRTC hook, also provided by @signalwire-community/react package provides a list input and output devices that we can use. It is used thus:

const { cameras, speakers, microphones } = useWebRTC();

The useWebRTC() hook is the React wrapper for the WebRTC namespace in the SDK.

Now, to change the active webcam, the information from useWebRTC can be used in conjunction with self.video.setDevice(). For example:

const { cameras, speakers, microphones } = useWebRTC();
// ...
self.video.setDevice(cameras[1]); //assuming there are multiple devices each
self.speakers.setDevice(speakers[0]);
self.audio.setDevice(microphones[2]);

This would set the video call to use the second camera in the list, the first speaker in the list and the third microphone in the list. This is assuming the devices exist and the browser can see them. If there was just one microphone, for example, microphones[2] would be undefined and setDevice would fail.

Displaying Room Previews

Backend

Finally, we want to display the previews of ongoing conferences in the home screen. The preview thumbnails of any active SignalWire RoomSession can be downloaded from the preview_url attribute of the session.

We can query for the list of active room sessions via the REST API at the room_sessions endpoint. Sending a GET request to this endpoint with a query parameter status set to in-progress will get us the list of active room sessions.

Again, since this is a REST API call, this will have to be done in the server and proxied to the client.

pages/api/sessions.ts

import axios from "axios";
import { AUTH } from "../../data/auth";

export default async function handler(req: any, res: any) {
  try {
    const sessionsResponse = await axios.get(
      `https://${process.env.SPACE_NAME}.signalwire.com/api/video/room_sessions`,
      { auth: AUTH, params: { status: "in-progress" } }
    );
    const sessions = sessionsResponse.data?.data;
    if (Array.isArray(sessions)) {
      return res.send({ sessions, error: false });
    } else {
      console.log(sessions, sessionsResponse.data, sessionsResponse.status);
      return res.status(400).send({ error: true });
    }
  } catch (e) {
    console.log(e);
    res.status(400).json({ error: true });
  }
}

a GET query in Thunder Client. A GET request is being sent to /api/sessions.

Sending GET request to '/api/sessions'

Now we have created an endpoint at /api/sessions which, when called, sends a GET request to the room_sessions endpoint with the authentication information, and passes that information to the client.

Frontend

With that bit of backend in place, creating a frontend should be very simple. @signalwire-community/react already comes with a <RoomPreview/> component ( described in detail here).

components/RoomPreviews.tsx

import { RoomPreview } from "@signalwire-community/react";
import { useRouter } from "next/router";
import { Card } from "react-bootstrap";
import useSWR from "swr";

export default function RoomPreviews() {
  const router = useRouter();
  const { data: sessions, error } = useSWR("/api/sessions");
  if (error || sessions?.error === true)
    return Error trying to access room sessions in progress;
  if (!sessions?.sessions || sessions?.sessions?.length === 0) {
    return (

        <h4 class="text-muted">No ongoing rooms</h4>

    );
  }
  return (
    <>

        {sessions?.sessions?.map((session: any) => (

            <Card onClick={(e) => router.push(`/rooms/${session.name}`)}>
              <RoomPreview
                previewUrl={session.preview_url}
                loadingUrl={"https://swrooms.com/swloading.gif"}
                style={{ height: 150, aspectRatio: "16 / 9" }}
              />
              <Card.Body>
                <Card.Title>{session.display_name ?? session.name}</Card.Title>
              </Card.Body>
            </Card>

        ))}

    </>
  );
}

two thumbnail images representing ongoing rooms, with the option to join.

Thumbnails of ongoing room sessions being displayed.

Again, we are using the useSWR hook to fetch data from the server, but any way you use to GET /api/sessions should be fine. With useSWR however, the data is automatically cached and refreshed after changing tabs or network loss.

Conclusion

With that, we have created a zoom-like application for video conferencing with little effort. You can add more features to it as you go, but this should be a good starting point.

The full source code for this project is available on GitHub.

Thunder Client showing a GET query being used to test the /api/token endpoint.

a video conference, showing controls for video, audio, participants, invitations, and layout.

a GET query in Thunder Client. A GET request is being sent to /api/sessions.

two thumbnail images representing ongoing rooms, with the option to join.

SignalWire Developer Documentation