> For a complete index of all SignalWire documentation pages, fetch https://signalwire.com/docs/llms.txt

# Outbound Calls

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()`](/docs/browser-sdk/v4/reference/signalwire/dial) call works for joining a room, calling another [user](/docs/platform/subscribers), or reaching a phone number — only the destination string changes.

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

```ts 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](/docs/browser-sdk/v4/guides/authentication) 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:

`/public/<resource>` — a [resource](/docs/platform/resources) like a room, IVR, or app anyone in the project can dial.

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

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

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

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

If you're iterating addresses from the directory, an [`Address`](/docs/browser-sdk/v4/reference/address) object exposes [`defaultChannel`](/docs/browser-sdk/v4/reference/address/default-channel) — 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`](/docs/browser-sdk/v4/reference/interfaces/dial-options) object, which extends [`MediaOptions`](/docs/browser-sdk/v4/reference/interfaces/media-options). The four common shapes:

```ts
const call = await client.dial(destination, { audio: true, video: true });
```

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

```ts
const call = await client.dial(destination);
// equivalent: { audio: true, video: false }
```

Phone-style call. No camera permission prompt.

```ts
const call = await client.dial(destination, { audio: false, video: true });
```

Useful for a kiosk that joins on camera with the mic off, or for receive-only viewers.

```ts
const call = await client.dial(destination, {
  audio: false,
  video: false,
  receiveAudio: true,
  receiveVideo: true,
});
```

Joins without sending any media. The remote tracks still arrive on `remoteStream$`.

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`](/docs/browser-sdk/v4/reference/interfaces/call) exposes two media observables: [`localStream$`](/docs/browser-sdk/v4/reference/webrtc-call/local-stream\$) (what the user is sending) and [`remoteStream$`](/docs/browser-sdk/v4/reference/webrtc-call/remote-stream\$) (what the user receives). Both emit a `MediaStream` once the track is ready — bind each one to a `<video>` element's `srcObject`:

```ts 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));
```

```html
<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()`](/docs/browser-sdk/v4/reference/webrtc-call/hangup) when the user clicks the hang-up button or navigates away. The call transitions through `disconnecting` → `disconnected` → `destroyed`; any subscriptions on the call complete naturally.

```ts 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()`](/docs/browser-sdk/v4/reference/webrtc-call/transfer) instead.

## Try it: dial a destination

Create a SAT against your project — the [Authentication guide](/docs/browser-sdk/v4/guides/authentication) covers it end-to-end; the [Create Subscriber Token](/docs/apis/rest/subscribers/tokens/create-subscriber-token) reference sends the request for you.

### Request

POST https\://%7BYour\_Space\_Name%7D.signalwire.com/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"
}'
```

```python
import requests

url = "https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens"

payload = { "reference": "john.doe@example.com" }
headers = {
    "Content-Type": "application/json"
}

response = requests.post(url, json=payload, headers=headers, auth=("<project_id>", "<api_token>"))

print(response.json())
```

```javascript
const url = 'https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens';
const credentials = btoa("<project_id>:<api_token>");

const options = {
  method: 'POST',
  headers: {
    Authorization: `Basic ${credentials}`,
    'Content-Type': 'application/json'
  },
  body: '{"reference":"john.doe@example.com"}'
};

try {
  const response = await fetch(url, options);
  const data = await response.json();
  console.log(data);
} catch (error) {
  console.error(error);
}
```

```go
package main

import (
	"fmt"
	"strings"
	"net/http"
	"io"
)

func main() {

	url := "https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens"

	payload := strings.NewReader("{\n  \"reference\": \"john.doe@example.com\"\n}")

	req, _ := http.NewRequest("POST", url, payload)

	req.SetBasicAuth("<project_id>", "<api_token>")
	req.Header.Add("Content-Type", "application/json")

	res, _ := http.DefaultClient.Do(req)

	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)

	fmt.Println(res)
	fmt.Println(string(body))

}
```

```ruby
require 'uri'
require 'net/http'

url = URI("https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens")

http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true

request = Net::HTTP::Post.new(url)
request.basic_auth("<project_id>", "<api_token>")
request["Content-Type"] = 'application/json'
request.body = "{\n  \"reference\": \"john.doe@example.com\"\n}"

response = http.request(request)
puts response.read_body
```

```java
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.Unirest;

HttpResponse<String> response = Unirest.post("https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens")
  .basicAuth("<project_id>", "<api_token>")
  .header("Content-Type", "application/json")
  .body("{\n  \"reference\": \"john.doe@example.com\"\n}")
  .asString();
```

```php
<?php
require_once('vendor/autoload.php');

$client = new \GuzzleHttp\Client();

$response = $client->request('POST', 'https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens', [
  'body' => '{
  "reference": "john.doe@example.com"
}',
  'headers' => [
    'Content-Type' => 'application/json',
  ],
    'auth' => ['<project_id>', '<api_token>'],
]);

echo $response->getBody();
```

```csharp
using RestSharp;
using RestSharp.Authenticators;

var client = new RestClient("https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens");
client.Authenticator = new HttpBasicAuthenticator("<project_id>", "<api_token>");
var request = new RestRequest(Method.POST);

request.AddHeader("Content-Type", "application/json");
request.AddParameter("application/json", "{\n  \"reference\": \"john.doe@example.com\"\n}", ParameterType.RequestBody);
IRestResponse response = client.Execute(request);
```

```swift
import Foundation

let credentials = Data("<project_id>:<api_token>".utf8).base64EncodedString()

let headers = [
  "Authorization": "Basic \(credentials)",
  "Content-Type": "application/json"
]
let parameters = ["reference": "john.doe@example.com"] as [String : Any]

let postData = JSONSerialization.data(withJSONObject: parameters, options: [])

let request = NSMutableURLRequest(url: NSURL(string: "https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens")! as URL,
                                        cachePolicy: .useProtocolCachePolicy,
                                    timeoutInterval: 10.0)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
request.httpBody = postData as Data

let session = URLSession.shared
let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
  if (error != nil) {
    print(error as Any)
  } else {
    let httpResponse = response as? HTTPURLResponse
    print(httpResponse)
  }
})

dataTask.resume()
```

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`](/docs/browser-sdk/v4/reference/interfaces/dial-options).

```html
<!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

Receive calls in a signed-in user session.

Choose the mic, camera, and speaker the call should use.

Every option you can pass to `client.dial()`.