Outbound Calls

View as MarkdownOpen in Claude

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
1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2
3const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
4const call = await client.dial("/public/test-room", { audio: true, video: true });
5
6// Attach the media to the page.
7call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
8call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
9
10// End the call when the user clicks hang up.
11hangupButton.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
1const 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:

1const 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
1// These subscriptions complete on their own when the call ends.
2call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
3call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
1<video id="localVideo" autoplay muted playsinline></video>
2<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
1hangupButton.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
1curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
2 -H "Content-Type: application/json" \
3 -u "<project_id>:<api_token>" \
4 -d '{
5 "reference": "john.doe@example.com"
6}'

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.

1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <title>SignalWire SDK outbound call demo</title>
6 <style>
7 /* Shared demo shell — identical across the inbound, outbound, and
8 device-management guides. Per-demo extras go below this block. */
9 body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
10 label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
11 input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
12 button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
13 button[disabled] { opacity: 0.5; cursor: not-allowed; }
14 .videos { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 1rem; }
15 video { width: 100%; background: #000; border-radius: 4px; aspect-ratio: 4/3; }
16 #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; }
17 /* Outbound-specific */
18 .media-options { margin: 0.75rem 0 0; padding: 0.5rem 0.75rem; border: 1px solid #ccc; border-radius: 4px; }
19 .media-options legend { padding: 0 0.25rem; font-weight: 600; }
20 .media-options label { display: inline-flex; align-items: center; gap: 0.35rem; margin: 0 1rem 0 0; font-weight: 400; }
21 .media-options input[type="checkbox"] { width: auto; padding: 0; }
22 </style>
23 </head>
24 <body>
25 <h1>SignalWire SDK outbound call demo</h1>
26
27 <label for="token">Subscriber Access Token</label>
28 <input id="token" type="password" placeholder="Paste your SAT here" />
29
30 <label for="destination">Destination</label>
31 <input id="destination" type="text" placeholder="/public/test-room" />
32
33 <fieldset class="media-options">
34 <legend>Media</legend>
35 <label><input type="checkbox" id="audio" checked /> Send audio</label>
36 <label><input type="checkbox" id="video" checked /> Send video</label>
37 </fieldset>
38
39 <button id="dial">Dial</button>
40 <button id="hangup" disabled>Hang up</button>
41
42 <div class="videos">
43 <video id="local" autoplay muted playsinline></video>
44 <video id="remote" autoplay playsinline></video>
45 </div>
46
47 <pre id="log"></pre>
48
49 <script type="module">
50 import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";
51
52 const log = (msg) =>
53 (document.getElementById("log").textContent += msg + "\n");
54
55 let activeCall = null;
56
57 document.getElementById("dial").addEventListener("click", async () => {
58 const token = document.getElementById("token").value.trim();
59 const destination = document.getElementById("destination").value.trim();
60 if (!token || !destination) return log("Need a token and a destination.");
61
62 const audio = document.getElementById("audio").checked;
63 const video = document.getElementById("video").checked;
64
65 const dialBtn = document.getElementById("dial");
66 const hangupBtn = document.getElementById("hangup");
67 dialBtn.disabled = true;
68 log("Dialing " + destination + "...");
69 log("Options: audio=" + audio + ", video=" + video);
70
71 const provider = new StaticCredentialProvider({ token });
72 const client = new SignalWire(provider);
73
74 try {
75 const call = await client.dial(destination, { audio, video });
76 activeCall = call;
77 hangupBtn.disabled = false;
78
79 call.localStream$.subscribe((stream) => {
80 document.getElementById("local").srcObject = stream;
81 });
82 call.remoteStream$.subscribe((stream) => {
83 document.getElementById("remote").srcObject = stream;
84 });
85 call.status$.subscribe((status) => {
86 log("Status: " + status);
87 if (status === "destroyed") {
88 dialBtn.disabled = false;
89 hangupBtn.disabled = true;
90 activeCall = null;
91 }
92 });
93 call.errors$.subscribe((err) => {
94 log("Error: " + (err.name || "Error") + "" + err.message);
95 });
96 } catch (err) {
97 log("dial() rejected: " + (err.name || "Error") + "" + err.message);
98 dialBtn.disabled = false;
99 }
100 });
101
102 document.getElementById("hangup").addEventListener("click", () => {
103 if (activeCall) activeCall.hangup();
104 });
105 </script>
106 </body>
107</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