diff --git a/fern/assets/images/img/outbound-call-lifecycle-themed.svg b/fern/assets/images/img/outbound-call-lifecycle-themed.svg
new file mode 100644
index 0000000000..1605a53487
--- /dev/null
+++ b/fern/assets/images/img/outbound-call-lifecycle-themed.svg
@@ -0,0 +1,90 @@
+
+ Outbound call with the Calling API or Server SDK REST client
+ Your code sends dial with from, to, and SWML. SignalWire returns the call id with status queued and rings the destination and reports status ringing. The destination answers, SignalWire reports status answered, and your SWML runs. When the call finishes, SignalWire reports status ended.
+
+
+
+
+
+
+
+
+
+
+
+
+ Your code
+
+
+
+
+ SignalWire
+
+
+
+
+ Destination
+
+
+ dial
+ from, to, SWML
+
+ queued
+ status, call id
+
+
+
+ rings
+
+ ringing
+ status
+
+
+ answers
+
+
+
+ answered
+ status
+
+
+
+ Your SWML runs
+ Call finishes
+
+ ended
+ status
+
+
+
diff --git a/fern/assets/images/img/outbound-call-relay-lifecycle-themed.svg b/fern/assets/images/img/outbound-call-relay-lifecycle-themed.svg
new file mode 100644
index 0000000000..ca3eaf262f
--- /dev/null
+++ b/fern/assets/images/img/outbound-call-relay-lifecycle-themed.svg
@@ -0,0 +1,83 @@
+
+ Relay: commands and events over one WebSocket
+ Your code and SignalWire share one persistent WebSocket. Your code sends calling.dial; SignalWire reports created, ringing, and answered through calling.call.state. Your code sends calling.play; SignalWire reports playing and finished through calling.call.play. Your code sends calling.end; SignalWire reports ending and ended through calling.call.state.
+
+
+
+
+
+
+
+
+
+
+
+
+ Your code
+
+
+
+
+ SignalWire
+
+ one persistent WebSocket, both directions
+ dial
+ calling.dial
+
+ created → ringing → answered
+ calling.call.state
+
+ play
+ calling.play
+
+ playing → finished
+ calling.call.play
+
+ hangup
+ calling.end
+
+ ending → ended
+ calling.call.state
+
+
+ Command you send
+
+ Event from SignalWire
+
+
diff --git a/fern/docs.yml b/fern/docs.yml
index 4d3ecfa4d6..8b2295dbda 100644
--- a/fern/docs.yml
+++ b/fern/docs.yml
@@ -196,6 +196,12 @@ css:
- components/sigmond-card/styles.css
redirects:
+ # Browser SDK outbound guide consolidated into the platform outbound calling
+ # guide, which now carries the browser walkthrough and media options.
+ - source: /docs/browser-sdk/guides/outbound-calls
+ destination: /docs/platform/voice/outbound-calling
+ - source: /docs/browser-sdk/v4/guides/outbound-calls
+ destination: /docs/platform/voice/outbound-calling
# Legacy Agents SDK pages (#526). Use known paths and equivalent existing
# content only. Exact entries prevent unknown or removed pages (examples,
# best-practices, patterns, migration, changelog) from redirecting to an
@@ -534,6 +540,12 @@ redirects:
- source: /docs/platform/ai/prompt-engineering/where-to-apply
destination: /docs/platform/ai/prompt-engineering
+ # The VAPI inbound and outbound guides were merged into one integration guide.
+ - source: /docs/platform/ai/vapi/inbound
+ destination: /docs/platform/ai/vapi
+ - source: /docs/platform/ai/vapi/outbound
+ destination: /docs/platform/ai/vapi
+
# SWML methods reorganized into calling/ and messaging/ subsections.
# The old methods overview lived at /docs/swml/reference, which was
# entirely calling-flavored — point it at the new calling overview.
diff --git a/fern/llms.txt b/fern/llms.txt
index d05ee2bcc8..05fe9d20e5 100644
--- a/fern/llms.txt
+++ b/fern/llms.txt
@@ -76,14 +76,14 @@ Send text and media messages from your application, and choose how to process in
Give users an identity, authorize their access, and connect them to other users or shared resources. Your backend manages subscribers and credentials; the client provides the calling and chat interface.
-- Browser SDK: For web clients, [place audio or video calls](/docs/browser-sdk/v4/guides/outbound-calls) and [send and display chat messages](/docs/browser-sdk/v4/guides/messaging-chat).
+- Browser SDK: For web clients, [place audio or video calls](/docs/platform/voice/outbound-calling) and [send and display chat messages](/docs/browser-sdk/v4/guides/messaging-chat).
- REST APIs: [Create subscribers](/docs/apis/rest/subscribers/create-subscriber) and [issue subscriber access tokens](/docs/apis/rest/subscribers/tokens/create-subscriber-token) from your backend. Follow the [Browser SDK authentication guide](/docs/browser-sdk/v4/guides/authentication) to connect a web client.
### Video conferences
Build a browser conference interface or use a conference with a prebuilt interface. The [platform video overview](/docs/platform/video) explains rooms, participant permissions, and recordings.
-- Browser SDK: [Join a room with audio and video](/docs/browser-sdk/v4/guides/outbound-calls), then add [call controls](/docs/browser-sdk/v4/guides/call-controls), [screen sharing](/docs/browser-sdk/v4/guides/screen-sharing), and [participant layouts](/docs/browser-sdk/v4/guides/layouts).
+- Browser SDK: [Join a room with audio and video](/docs/browser-sdk/v4/guides/build-voice-video), then add [call controls](/docs/browser-sdk/v4/guides/call-controls), [screen sharing](/docs/browser-sdk/v4/guides/screen-sharing), and [participant layouts](/docs/browser-sdk/v4/guides/layouts).
- REST APIs: [Create a video room](/docs/apis/rest/video/rooms/create-room) and [issue room access tokens](/docs/apis/rest/video/room-tokens/create-room-token), or [create a video conference with a prebuilt interface](/docs/apis/rest/video/video-conferences/create-video-conference).
### Migrate a Twilio application
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx
index 1523b83990..85c3f017eb 100644
--- a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx
@@ -24,7 +24,7 @@ instance — get one of these going first.
Install the SDK and create a `SignalWire` client with a credential provider.
-
+
Dial a destination with `client.dial()` to get a `Call` instance.
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx
index 92f1d2af38..e0e44a331e 100644
--- a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx
@@ -448,7 +448,7 @@ Pick a different mic, camera, or speaker before dialing. The log records each pi
### Switch a device mid-call
-Enter a destination in the **Destination** field (a `/public/`, `/private/`, PSTN number, or SIP URI the token can reach — see [Outbound Calls](/docs/browser-sdk/v4/guides/outbound-calls#pick-a-destination) for the destination shapes) and click **Dial**. The local and remote tiles populate once the call connects.
+Enter a destination in the **Destination** field (a `/public/`, `/private/`, PSTN number, or SIP URI the token can reach — see [Outbound calling](/docs/platform/voice/outbound-calling#choose-a-destination) 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 `` element so the sink swaps too.
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx
index 51186ea48e..0b74e2ab8c 100644
--- a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx
@@ -413,7 +413,7 @@ The demo logs the ringing call and enables the **Answer** / **Decline** buttons.
## Next steps
-
+
Dial users, rooms, or PSTN destinations with `client.dial()`.
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx
deleted file mode 100644
index 294eaf536f..0000000000
--- a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/outbound-calls.mdx
+++ /dev/null
@@ -1,262 +0,0 @@
----
-title: "Outbound Calls"
-slug: /guides/outbound-calls
-sidebar-title: "Outbound Calls"
-position: 2
-max-toc-depth: 3
----
-
-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/` — a [resource](/docs/platform/resources) like a room, IVR, or app anyone in the project can dial.
-
-
- `/private/` — 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 `` 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
-
-
-```
-
-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 `` 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.
-
-
-
-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
-
-
-
-
- SignalWire SDK outbound call demo
-
-
-
- SignalWire SDK outbound call demo
-
- Subscriber Access Token
-
-
- Destination
-
-
-
- Media
- Send audio
- Send video
-
-
- Dial
- Hang up
-
-
-
-
-
-
-
-
-
-
-
-```
-
-
-
-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()`.
-
-
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx
index 2d5cd0a49c..011e021dae 100644
--- a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/overview.mdx
@@ -20,9 +20,10 @@ features," but each is self-contained.
If you're starting from zero:
-1. **[Outbound Calls](/docs/browser-sdk/v4/guides/outbound-calls)** — the simplest
- thing that works: `client.dial()`, attach streams, listen for
- status, hang up.
+1. **[Call from the browser](/docs/platform/voice/outbound-calling#call-from-the-browser)**
+ — the simplest thing that works: `client.dial()`, attach streams,
+ listen for status, hang up. This lives in the platform outbound
+ calling guide; jump straight to the browser example.
2. **[Call Controls](/docs/browser-sdk/v4/guides/call-controls)** — the muscle of any
call UI: mute, deaf, hand raise, hangup, DTMF.
3. **[Device Management](/docs/browser-sdk/v4/guides/device-management)** — pick a
diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx
index e37279ffdd..17e91e24b9 100644
--- a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx
+++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/screen-sharing.mdx
@@ -24,7 +24,7 @@ There's no separate "screen share call" — the share is added to the
Install the SDK and create a `SignalWire` client with a credential provider.
-
+
Dial a destination with `client.dial()` to get a `Call` instance.
diff --git a/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx b/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx
index 8bd7abbe9b..51de2d8823 100644
--- a/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx
+++ b/fern/products/browser-sdk/pages/v4/guides/getting-started/authentication.mdx
@@ -411,7 +411,7 @@ You should see `Authenticated — WebSocket open.` in the log. If you see `Faile
Receive incoming calls in a signed-in user session.
-
+
Dial users, rooms, or PSTN destinations.
diff --git a/fern/products/browser-sdk/versions/v4.yml b/fern/products/browser-sdk/versions/v4.yml
index e98e1edc71..d5b0c8c5df 100644
--- a/fern/products/browser-sdk/versions/v4.yml
+++ b/fern/products/browser-sdk/versions/v4.yml
@@ -37,8 +37,6 @@ navigation:
contents:
- page: Overview
path: ../pages/v4/guides/build-voice-video/overview.mdx
- - page: Outbound Calls
- path: ../pages/v4/guides/build-voice-video/outbound-calls.mdx
- page: Inbound Calls
path: ../pages/v4/guides/build-voice-video/inbound-calls.mdx
- page: Device Management
diff --git a/fern/products/platform/pages/ai/guides/Integrations/vapi.mdx b/fern/products/platform/pages/ai/guides/Integrations/vapi.mdx
new file mode 100644
index 0000000000..5c1edaef91
--- /dev/null
+++ b/fern/products/platform/pages/ai/guides/Integrations/vapi.mdx
@@ -0,0 +1,305 @@
+---
+title: VAPI integration
+slug: /ai/vapi
+description: Connect VAPI AI assistants to SignalWire phone numbers over SIP trunking, for inbound calls to an assistant and outbound calls from it.
+max-toc-depth: 3
+---
+
+[signup]: https://signalwire.com/signup
+[vapi]: https://dashboard.vapi.ai/
+[vapi-sip-guide]: https://docs.vapi.ai/advanced/sip/sip-trunk
+[vapi-test-guide]: https://docs.vapi.ai/advanced/sip/sip-trunk#test-your-sip-trunk
+[sw-firewall-guide]: /docs/platform/allow-signalwire-ips-through-your-firewall
+[resources]: https://my.signalwire.com?page=resources
+[addresses-guide]: /docs/platform/addresses
+[swml-guide]: /docs/swml
+[swml-connect]: /docs/swml/reference/calling/connect
+[outbound-calling]: /docs/platform/voice/outbound-calling
+
+VAPI assistants need phone numbers, and SignalWire supplies them over SIP trunking in both
+directions. Callers dial your SignalWire number and reach the assistant, and the assistant places
+calls that show your SignalWire number as caller ID.
+
+Each direction is its own SIP trunk in VAPI and its own SWML script in SignalWire. Set up the one
+you need, or both. The VAPI side follows VAPI's own [SIP trunk setup guide][vapi-sip-guide] with
+SignalWire-specific values.
+
+## What you'll need
+
+- A SignalWire account with at least one phone number ([sign up here][signup])
+- A [VAPI][vapi] account with API access
+- Your VAPI private API key (found in your VAPI dashboard)
+- For outbound calls, a SIP domain app password from SignalWire Support (requested below)
+
+## Inbound calls to your assistant
+
+VAPI has to accept calls from SignalWire's network, and SignalWire has to know which assistant a
+number belongs to. You create a trunk in VAPI that trusts SignalWire's IP addresses, register your
+number with it, and then route the number to VAPI with a SWML script.
+
+### Create the inbound SIP trunk in VAPI
+
+Run the API call below to create the trunk, making sure to replace `YOUR_VAPI_PRIVATE_KEY` with your actual API key.
+
+Save the `id` from the response - you'll need this credential ID for the next step.
+
+
+These IP addresses are current as of this guide's publication, but SignalWire IPs can change and should be programmatically monitored to avoid any impact on calls. You can gather SignalWire's latest IPs by performing a DIG or nslookup of `sip.signalwire.com`.
+
+See our guide on [allowing SignalWire IPs through firewalls][sw-firewall-guide] for more details.
+
+
+```bash
+curl -X POST "https://api.vapi.ai/credential" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
+ -d '{
+ "provider": "byo-sip-trunk",
+ "name": "SignalWire Inbound Trunk",
+ "gateways": [
+ { "ip": "170.64.128.96", "inboundEnabled": true },
+ { "ip": "198.13.56.186", "inboundEnabled": true },
+ { "ip": "104.248.176.184", "inboundEnabled": true },
+ { "ip": "152.42.144.114", "inboundEnabled": true },
+ { "ip": "104.248.150.114", "inboundEnabled": true },
+ { "ip": "138.68.125.160", "inboundEnabled": true },
+ { "ip": "159.65.244.171", "inboundEnabled": true },
+ { "ip": "167.99.198.84", "inboundEnabled": true },
+ { "ip": "13.245.35.235", "inboundEnabled": true },
+ { "ip": "108.61.169.31", "inboundEnabled": true },
+ { "ip": "137.184.4.155", "inboundEnabled": true },
+ { "ip": "188.166.126.7", "inboundEnabled": true },
+ { "ip": "139.59.34.94", "inboundEnabled": true },
+ { "ip": "165.232.186.228", "inboundEnabled": true },
+ { "ip": "157.175.131.128", "inboundEnabled": true }
+ ]
+ }'
+```
+
+### Register your SignalWire phone number
+
+Now register your SignalWire phone number with VAPI using the credential ID from above:
+
+```bash
+curl -X POST "https://api.vapi.ai/phone-number" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
+ -d '{
+ "provider": "byo-phone-number",
+ "name": "SignalWire Inbound Number",
+ "number": "+15551234567",
+ "numberE164CheckEnabled": true,
+ "credentialId": "YOUR_CREDENTIAL_ID"
+ }'
+```
+
+Make sure to replace:
+- `YOUR_VAPI_PRIVATE_KEY` with your actual API key
+- `+15551234567` with your SignalWire phone number in E.164 format (including the + and country code)
+- `YOUR_CREDENTIAL_ID` with the credential ID from the previous step
+
+### Assign your assistant
+
+In your VAPI dashboard, find the phone number you just registered and assign it to one of your AI
+assistants. Configure the inbound settings only. If you also set up outbound calls below, you
+register the same number a second time against the outbound trunk.
+
+### Create the routing script in SignalWire
+
+In your SignalWire dashboard, go to [**Resources**][resources] → **Add** → **Script** → **SWML Script**. This script will handle all incoming calls by immediately connecting them to VAPI:
+
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - connect:
+ to: 'sip:%{call.to}@YOUR_CREDENTIAL_ID.sip.vapi.ai'
+```
+
+Replace `YOUR_CREDENTIAL_ID` with the credential ID from the VAPI trunk setup above. The `%{call.to}` variable ensures VAPI receives the original dialed number, which helps with call routing and analytics.
+
+
+This SWML script uses the [`connect` method][swml-connect] to bridge the incoming call directly to VAPI's SIP endpoint.
+The call happens in real time with no delays. Learn more about SWML in our [complete guide][swml-guide].
+
+
+### Connect the script to your phone number
+
+Go to **Phone Numbers** in your SignalWire dashboard, find your number, and click **Edit Settings**. Under the voice settings, assign your new SWML script to handle incoming calls.
+
+### Test inbound calls
+
+Call your SignalWire phone number. Your VAPI assistant answers within a few seconds. If it doesn't,
+the [troubleshooting section](#troubleshoot-the-vapi-integration) below covers the common causes.
+
+## Outbound calls from your assistant
+
+For outbound calls the roles reverse. SignalWire publishes a SIP address that VAPI dials, and a SWML
+script bridges each call onto the public telephone network with your SignalWire number as caller
+ID. VAPI authenticates to that SIP address with a password issued by SignalWire Support.
+
+### Create the outbound routing script
+
+In your SignalWire dashboard, go to [**Resources**][resources] → **Add** → **Script** → **SWML Script**. This script will handle outbound calls by connecting them through the PSTN:
+
+```yaml
+version: 1.0.0
+sections:
+ main:
+ - connect:
+ answer_on_bridge: true
+ from: +1A-Number-From-Your-Space-here
+ to: '%{call.to.replace(/^sip:/i, '''').replace(/@.*/, '''')}'
+```
+
+Replace `"+1A-Number-From-Your-Space-here"` with an actual phone number from your SignalWire account.
+This will be the caller ID shown to people who receive calls from your VAPI assistant.
+
+
+This SWML script uses the [`connect` method][swml-connect] with `answer_on_bridge: true` to ensure calls connect properly.
+The `to` field uses a regular expression to extract the phone number from VAPI's SIP format and route it through the PSTN.
+
+
+### Add a SIP address to your script
+
+After saving your SWML script, you'll need to create a SIP address that VAPI can connect to:
+
+1. In your saved SWML script, navigate to the **Addresses & Phone Numbers** section
+2. Click **Add** and select **SIP Address**
+3. Configure the SIP address settings and save
+
+After configuration, note down your unique SIP domain app. It will look something like: `test-space-vapi.dapp.signalwire.com`
+
+The [Addresses documentation][addresses-guide] covers resource addresses in more detail.
+
+
+To complete this setup, you must contact SignalWire Support to generate a password for your SIP domain app. VAPI needs this password for authentication.
+
+Contact support by:
+- Clicking the "Help?" button in your SignalWire Space
+- Emailing support@signalwire.com
+
+Provide your SIP domain app and let them know you need a password for VAPI integration.
+
+
+### Create the outbound SIP trunk in VAPI
+
+Run this API call to create the outbound trunk, making sure to replace the placeholder values with your actual configuration:
+
+```bash
+curl -X POST "https://api.vapi.ai/credential" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
+ -d '{
+ "provider": "byo-sip-trunk",
+ "name": "SignalWire Outbound Trunk",
+ "gateways": [{
+ "ip": "YOUR_SIGNALWIRE_SIP_DOMAIN",
+ "inboundEnabled": false
+ }],
+ "outboundLeadingPlusEnabled": true,
+ "outboundAuthenticationPlan": {
+ "authUsername": "YOUR_SIGNALWIRE_PHONE_NUMBER",
+ "authPassword": "YOUR_SIGNALWIRE_PASSWORD"
+ }
+ }'
+```
+
+Make sure to replace:
+- `YOUR_VAPI_PRIVATE_KEY` - Your VAPI API key
+- `YOUR_SIGNALWIRE_SIP_DOMAIN` - Your SIP domain app from the previous step (e.g., `test-space-vapi.dapp.signalwire.com`)
+- `YOUR_SIGNALWIRE_PHONE_NUMBER` - Your SignalWire phone number in E.164 format (e.g., `+15551234567`)
+- `YOUR_SIGNALWIRE_PASSWORD` - The password provided by SignalWire Support
+
+Save the `id` from the response - you'll need this credential ID for the next step.
+
+### Register your SignalWire phone number for outbound
+
+Register your SignalWire phone number with VAPI for outbound calling using the credential ID from above:
+
+```bash
+curl -X POST "https://api.vapi.ai/phone-number" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
+ -d '{
+ "provider": "byo-phone-number",
+ "name": "SignalWire Outbound Number",
+ "number": "+15551234567",
+ "numberE164CheckEnabled": true,
+ "credentialId": "YOUR_CREDENTIAL_ID"
+ }'
+```
+
+Replace:
+- `YOUR_VAPI_PRIVATE_KEY` - Your VAPI API key
+- `+15551234567` - Your actual SignalWire phone number in E.164 format
+- `YOUR_CREDENTIAL_ID` - The credential ID from the trunk creation step
+
+### Assign your assistant for outbound calls
+
+In your VAPI dashboard, find the phone number you just registered and assign it to one of your AI assistants. Configure the outbound settings to specify which assistant should handle outbound calls.
+
+
+If you've already set up inbound calling with the same number, VAPI will indicate this is a duplicate number. This is completely normal and expected - you're using the same phone number for both inbound and outbound calling with different configurations.
+
+
+### Test outbound calls
+
+Your VAPI assistant can now make outbound calls through SignalWire. The calls will show your SignalWire phone number as the caller ID.
+
+To test your setup, refer to VAPI's official documentation on [testing your SIP trunk][vapi-test-guide] for specific instructions on initiating outbound calls.
+
+## Troubleshoot the VAPI integration
+
+You can monitor call logs in both platforms. SignalWire shows the call routing, and VAPI shows the
+assistant interaction details.
+
+### Inbound calls aren't reaching VAPI
+
+Check that your SWML script has the correct credential ID and is assigned to your phone number.
+Also, verify that your phone number is set to accept voice calls. Phone numbers must be in E.164
+format (`+1234567890`) with no spaces or special characters.
+
+### VAPI can't connect on inbound calls
+
+Make sure all SignalWire IP addresses are in your VAPI inbound trunk. If SignalWire has added new
+IP addresses since you created the trunk, you'll need to update it.
+
+### Outbound calls from VAPI fail to authenticate
+
+Double-check that you're using the correct SIP domain app password from SignalWire Support. The
+authentication username should be your phone number in E.164 format.
+
+### Outbound calls from VAPI don't connect
+
+Verify your outbound SWML script has the correct caller ID number and that it's a valid number from
+your SignalWire account. Ensure your SIP domain app is properly configured in SignalWire and that
+VAPI can resolve the domain name.
+
+## Next steps
+
+
+
+ Place outbound calls from SignalWire directly, including calls handled by a SignalWire AI agent
+
+
+
+ Add IVR menus, call screening, or business hours logic before connecting to VAPI
+
+
+
+ Every option on the method both routing scripts use
+
+
diff --git a/fern/products/platform/pages/ai/guides/Integrations/vapi/inbound-calls.mdx b/fern/products/platform/pages/ai/guides/Integrations/vapi/inbound-calls.mdx
deleted file mode 100644
index 7752d50f83..0000000000
--- a/fern/products/platform/pages/ai/guides/Integrations/vapi/inbound-calls.mdx
+++ /dev/null
@@ -1,194 +0,0 @@
----
-id: 85a7837b-5b92-4673-afe5-192995b0fbfb
-title: VAPI inbound calling
-subtitle: Route incoming calls to your VAPI AI assistants using SignalWire's phone network
-slug: /ai/vapi/inbound
-description: Route incoming calls from SignalWire phone numbers to VAPI AI assistants using SIP trunking
----
-
-
-[signup]: https://signalwire.com/signup
-[vapi]: https://dashboard.vapi.ai/
-[vapi-sip-guide]: https://docs.vapi.ai/advanced/sip/sip-trunk
-[sw-firewall-guide]: /docs/platform/allow-signalwire-ips-through-your-firewall
-[resources]: https://my.signalwire.com?page=resources
-[swml-guide]: /docs/swml/
-
-VAPI provides powerful AI voice assistants, but these assistants need phone numbers to receive calls. SignalWire's phone network can route incoming calls directly to your VAPI assistants using SIP trunking. This guide shows you how to connect them.
-
-When you're finished, callers will dial your SignalWire phone number and immediately connect to your VAPI AI assistant - no additional routing or servers needed.
-
-## Setup overview
-
-
-
-### Configure VAPI for SignalWire
-
-Set up a SIP trunk with SignalWire's IP addresses and register your phone number.
-
-### Create call routing in SignalWire
-
-Build a SWML script that forwards incoming calls to your VAPI assistant.
-
-### Connect and test
-
-Assign the routing script to your phone number and verify calls reach your AI assistant.
-
-
-
-## What you'll need
-
-- A SignalWire account with at least one phone number ([sign up here][signup])
-- A [VAPI][vapi] account with API access
-- Your VAPI private API key (found in your VAPI dashboard)
-
-## Setting up VAPI to receive SignalWire calls
-
-VAPI needs to know that calls will be coming from SignalWire's network. We'll create a SIP trunk that includes all of SignalWire's IP addresses, then register your phone number with that trunk.
-
-This follows VAPI's official [SIP trunk setup guide][vapi-sip-guide], but with SignalWire-specific configuration.
-
-### Create the SIP trunk
-
-Run the API call below to create the trunk, making sure to replace `YOUR_VAPI_PRIVATE_KEY` with your actual API key.
-
-Save the `id` from the response - you'll need this credential ID for the next step.
-
-
-These IP addresses are current as of this guide's publication, but SignalWire IPs can change and should be programmatically monitored to avoid any impact on calls. You can gather SignalWire's latest IPs by performing a DIG or nslookup of `sip.signalwire.com`.
-
-See our guide on [allowing SignalWire IPs through firewalls][sw-firewall-guide] for more details.
-
-
-```bash
-curl -X POST "https://api.vapi.ai/credential" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
- -d '{
- "provider": "byo-sip-trunk",
- "name": "SignalWire Inbound Trunk",
- "gateways": [
- { "ip": "170.64.128.96", "inboundEnabled": true },
- { "ip": "198.13.56.186", "inboundEnabled": true },
- { "ip": "104.248.176.184", "inboundEnabled": true },
- { "ip": "152.42.144.114", "inboundEnabled": true },
- { "ip": "104.248.150.114", "inboundEnabled": true },
- { "ip": "138.68.125.160", "inboundEnabled": true },
- { "ip": "159.65.244.171", "inboundEnabled": true },
- { "ip": "167.99.198.84", "inboundEnabled": true },
- { "ip": "13.245.35.235", "inboundEnabled": true },
- { "ip": "108.61.169.31", "inboundEnabled": true },
- { "ip": "137.184.4.155", "inboundEnabled": true },
- { "ip": "188.166.126.7", "inboundEnabled": true },
- { "ip": "139.59.34.94", "inboundEnabled": true },
- { "ip": "165.232.186.228", "inboundEnabled": true },
- { "ip": "157.175.131.128", "inboundEnabled": true }
- ]
- }'
-```
-
-
-### Register your SignalWire phone number
-
-Now register your SignalWire phone number with VAPI using the credential ID from above:
-
-```bash
-curl -X POST "https://api.vapi.ai/phone-number" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
- -d '{
- "provider": "byo-phone-number",
- "name": "SignalWire Inbound Number",
- "number": "+15551234567",
- "numberE164CheckEnabled": true,
- "credentialId": "YOUR_CREDENTIAL_ID"
- }'
-```
-
-Make sure to replace:
-- `YOUR_VAPI_PRIVATE_KEY` with your actual API key
-- `+15551234567` with your SignalWire phone number in E.164 format (including the + and country code)
-- `YOUR_CREDENTIAL_ID` with the credential ID from the previous step
-
-### Assign your AI assistant
-
-In your VAPI dashboard, find the phone number you just registered and assign it to one of your AI assistants. You'll only need to configure the inbound settings - we're not using this number for outbound calls.
-
----
-
-## Configuring SignalWire to route calls
-
-Now we need to tell SignalWire where to send incoming calls. We'll create a simple SWML script that forwards calls to your VAPI assistant.
-
-### Create the routing script
-
-In your SignalWire dashboard, go to [**Resources**][resources] → **Add** → **Script** → **SWML Script**. This script will handle all incoming calls by immediately connecting them to VAPI:
-
-```yaml
-version: 1.0.0
-sections:
- main:
- - connect:
- to: 'sip:%{call.to}@YOUR_CREDENTIAL_ID.sip.vapi.ai'
-```
-
-Replace `YOUR_CREDENTIAL_ID` with the credential ID from the VAPI trunk setup above. The `%{call.to}` variable ensures VAPI receives the original dialed number, which helps with call routing and analytics.
-
-
-This SWML script uses the `connect` method to bridge the incoming call directly to VAPI's SIP endpoint.
-The call happens in real time with no delays. Learn more about SWML in our [complete guide][swml-guide].
-
-
-### Connect the script to your phone number
-
-Go to **Phone Numbers** in your SignalWire dashboard, find your number, and click **Edit Settings**. Under the voice settings, assign your new SWML script to handle incoming calls.
-
-That's it - your phone number is now connected to your VAPI assistant.
-
----
-
-## Testing and troubleshooting
-
-Call your SignalWire phone number and you should hear your VAPI assistant answer within a few seconds.
-
-If something isn't working, here are the most common issues:
-
-**Calls aren't reaching VAPI**: Check that your SWML script has the correct credential ID and is assigned to your phone number. Also, verify that your phone number is set to accept voice calls.
-
-**VAPI can't connect**: Make sure all SignalWire IP addresses are in your VAPI trunk. If SignalWire has added new IP addresses since you created the trunk, you'll need to update it.
-
-**Wrong number format errors**: Phone numbers must be in E.164 format (`+1234567890`) with no spaces or special characters.
-
-You can monitor call logs in both platforms - SignalWire shows the initial call routing, while VAPI shows the assistant interaction details.
-
----
-
-## What's next
-
-Now that you have basic inbound routing working, you might want to explore:
-
-
-
- Let your VAPI assistant make outbound calls through SignalWire
-
-
-
- Add IVR menus, call screening, or business hours logic before connecting to VAPI
-
-
-
- Best practices for creating effective VAPI assistants
-
-
diff --git a/fern/products/platform/pages/ai/guides/Integrations/vapi/outbound-calls.mdx b/fern/products/platform/pages/ai/guides/Integrations/vapi/outbound-calls.mdx
deleted file mode 100644
index bce5eb560c..0000000000
--- a/fern/products/platform/pages/ai/guides/Integrations/vapi/outbound-calls.mdx
+++ /dev/null
@@ -1,212 +0,0 @@
----
-id: ee4080eb-4dce-457f-a2cc-b21537700230
-title: Outbound calling
-subtitle: Let your VAPI AI assistants initiate calls through SignalWire's phone network
-slug: /ai/vapi/outbound
-description: Configure VAPI AI assistants to make outbound calls through SignalWire's infrastructure
----
-
-
-[signup]: https://signalwire.com/signup
-[vapi]: https://dashboard.vapi.ai/
-[addresses-guide]: /docs/platform/addresses
-[vapi-sip-guide]: https://docs.vapi.ai/advanced/sip/sip-trunk
-[resources]: https://my.signalwire.com?page=resources
-[vapi-test-guide]: https://docs.vapi.ai/advanced/sip/sip-trunk#test-your-sip-trunk
-
-While VAPI provides powerful AI voice assistants, they need a way to make outbound calls to your customers or contacts.
-SignalWire's phone network can handle outbound calls from your VAPI assistants using SIP trunking. This guide shows you how to connect them for outbound calling.
-
-When you're finished, your VAPI assistants will be able to initiate calls using your SignalWire phone numbers, giving you complete control
-over both inbound and outbound AI-powered communications.
-
-## Setup overview
-
-
-
-### Configure SignalWire for outbound routing
-
-Create a SWML script and SIP domain to handle outbound calls from VAPI.
-
-### Set up VAPI outbound trunk
-
-Configure VAPI with your SignalWire SIP domain and authentication details.
-
-### Connect and test
-
-Register your phone number with VAPI and verify outbound calling works.
-
-
-
-## What you'll need
-
-- A SignalWire account with at least one phone number ([sign up here][signup])
-- A [VAPI][vapi] account with API access
-- Your VAPI private API key (found in your VAPI dashboard)
-- Access to SignalWire support for SIP domain app password generation
-
-## Setting up SignalWire for outbound calls
-
-SignalWire needs to be configured to receive and route outbound call requests from VAPI. This involves creating a SWML script that handles
-the call routing and setting up a SIP address.
-
-### Create the outbound routing script
-
-In your SignalWire dashboard, go to [**Resources**][resources] → **Add** → **Script** → **SWML Script**. This script will handle outbound calls by connecting them through the PSTN:
-
-```yaml
-version: 1.0.0
-sections:
- main:
- - connect:
- answer_on_bridge: true
- from: +1A-Number-From-Your-Space-here
- to: '%{call.to.replace(/^sip:/i, '''').replace(/@.*/, '''')}'
-```
-
-Replace `"+1A-Number-From-Your-Space-here"` with an actual phone number from your SignalWire account.
-This will be the caller ID shown to people who receive calls from your VAPI assistant.
-
-
-This SWML script uses the `connect` method with `answer_on_bridge: true` to ensure calls connect properly.
-The `to` field uses a regular expression to extract the phone number from VAPI's SIP format and route it through the PSTN.
-
-
-### Add a SIP address to your script
-
-After saving your SWML script, you'll need to create a SIP address that VAPI can connect to:
-
-1. In your saved SWML script, navigate to the **Addresses & Phone Numbers** section
-2. Click **Add** and select **SIP Address**
-3. Configure the SIP address settings and save
-
-After configuration, note down your unique SIP domain app. It will look something like: `test-space-vapi.dapp.signalwire.com`
-
-Learn more about addresses in our [Call Fabric Addresses documentation][addresses-guide].
-
-
-**Important**: To complete this setup, you must contact SignalWire Support to generate a password for your SIP domain app. VAPI needs this password for authentication.
-
-Contact support by:
-- Clicking the "Help?" button in your SignalWire Space
-- Emailing support@signalwire.com
-
-Provide your SIP domain app and let them know you need a password for VAPI integration.
-
-
----
-
-## Configuring VAPI for outbound calls
-
-Now we'll set up VAPI to make outbound calls through your SignalWire SIP domain app. This follows VAPI's official [SIP trunk setup guide][vapi-sip-guide], but with SignalWire-specific configuration.
-
-### Create the outbound SIP trunk
-
-Run this API call to create the outbound trunk, making sure to replace the placeholder values with your actual configuration:
-
-```bash
-curl -X POST "https://api.vapi.ai/credential" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
- -d '{
- "provider": "byo-sip-trunk",
- "name": "SignalWire Outbound Trunk",
- "gateways": [{
- "ip": "YOUR_SIGNALWIRE_SIP_DOMAIN",
- "inboundEnabled": false
- }],
- "outboundLeadingPlusEnabled": true,
- "outboundAuthenticationPlan": {
- "authUsername": "YOUR_SIGNALWIRE_PHONE_NUMBER",
- "authPassword": "YOUR_SIGNALWIRE_PASSWORD"
- }
- }'
-```
-
-Make sure to replace:
-- `YOUR_VAPI_PRIVATE_KEY` - Your VAPI API key
-- `YOUR_SIGNALWIRE_SIP_DOMAIN` - Your SIP domain app from the previous step (e.g., `test-space-vapi.dapp.signalwire.com`)
-- `YOUR_SIGNALWIRE_PHONE_NUMBER` - Your SignalWire phone number in E.164 format (e.g., `+15551234567`)
-- `YOUR_SIGNALWIRE_PASSWORD` - The password provided by SignalWire Support
-
-Save the `id` from the response - you'll need this credential ID for the next step.
-
-### Register your SignalWire phone number for outbound
-
-Register your SignalWire phone number with VAPI for outbound calling using the credential ID from above:
-
-```bash
-curl -X POST "https://api.vapi.ai/phone-number" \
- -H "Content-Type: application/json" \
- -H "Authorization: Bearer YOUR_VAPI_PRIVATE_KEY" \
- -d '{
- "provider": "byo-phone-number",
- "name": "SignalWire Outbound Number",
- "number": "+15551234567",
- "numberE164CheckEnabled": true,
- "credentialId": "YOUR_CREDENTIAL_ID"
- }'
-```
-
-Replace:
-- `YOUR_VAPI_PRIVATE_KEY` - Your VAPI API key
-- `+15551234567` - Your actual SignalWire phone number in E.164 format
-- `YOUR_CREDENTIAL_ID` - The credential ID from the trunk creation step
-
-### Assign your AI assistant for outbound calls
-
-In your VAPI dashboard, find the phone number you just registered and assign it to one of your AI assistants. Configure the outbound settings to specify which assistant should handle outbound calls.
-
-
-If you've already set up inbound calling with the same number, VAPI will indicate this is a duplicate number. This is completely normal and expected - you're using the same phone number for both inbound and outbound calling with different configurations.
-
-
----
-
-## Testing your outbound setup
-
-Your VAPI assistant can now make outbound calls through SignalWire. The calls will show your SignalWire phone number as the caller ID.
-
-To test your setup, refer to VAPI's official documentation on [testing your SIP trunk][vapi-test-guide] for specific instructions on initiating outbound calls.
-
----
-
-## Troubleshooting
-
-**Authentication failures**: Double-check that you're using the correct SIP domain app password from SignalWire Support. The authentication username should be your phone number in E.164 format.
-
-**Calls not connecting**: Verify your SWML script has the correct caller ID number and that it's a valid number from your SignalWire account.
-
-**Domain app issues**: Ensure your SIP domain app is properly configured in SignalWire and that VAPI can resolve the domain name.
-
----
-
-## What's next
-
-Now that you have outbound calling configured, you might want to explore:
-
-
-
- Complete your integration by setting up inbound calls from SignalWire to VAPI
-
-
-
- Add call screening, business hours logic, or complex routing before outbound calls
-
-
-
- Best practices for creating effective VAPI assistants for outbound campaigns
-
-
diff --git a/fern/products/platform/pages/calling/voice/outbound-calling.mdx b/fern/products/platform/pages/calling/voice/outbound-calling.mdx
new file mode 100644
index 0000000000..5a28785cb1
--- /dev/null
+++ b/fern/products/platform/pages/calling/voice/outbound-calling.mdx
@@ -0,0 +1,1847 @@
+---
+title: Outbound calling
+slug: /voice/outbound-calling
+description: Place your first outbound phone call, track its progress, and add AI or browser calling to your application.
+max-toc-depth: 3
+---
+
+[caller-id]: /docs/platform/voice/how-to-set-caller-id-or-cnam
+[trial-mode]: /docs/platform/trial-mode
+[international]: /docs/platform/how-to-enable-international-services
+[api-credentials]: /docs/platform/your-signalwire-api-space
+[phone-numbers]: /docs/platform/phone-numbers
+[swml-ai]: /docs/swml/reference/calling/ai
+[ai-best-practices]: /docs/platform/ai/best-practices
+[tcpa]: /docs/platform/compliance/tcpa
+[webhooks]: /docs/platform/webhooks
+[swml-detect-machine]: /docs/swml/reference/calling/detect-machine
+[swml-record-call]: /docs/swml/reference/calling/record-call
+[swml-stream]: /docs/swml/reference/calling/stream
+[call-whisper]: /docs/swml/guides/call-whisper
+[subscriber-token]: /docs/apis/rest/subscribers/tokens/create-subscriber-token
+[guest-token]: /docs/apis/rest/subscribers/tokens/create-subscriber-guest-token
+[resources]: /docs/platform/resources
+[sip-credentials]: /docs/platform/voice/sip/sip-credentials
+[subscribers]: /docs/platform/subscribers
+[dial-options]: /docs/browser-sdk/v4/reference/interfaces/dial-options
+[device-management]: /docs/browser-sdk/v4/guides/device-management
+[call-create-error]: /docs/browser-sdk/v4/reference/errors/call-create-error
+[browser-inbound]: /docs/browser-sdk/v4/guides/inbound-calls
+[browser-call-controls]: /docs/browser-sdk/v4/guides/call-controls
+[ts-dial]: /docs/server-sdks/reference/typescript/rest/calling/dial
+[address-default-channel]: /docs/browser-sdk/v4/reference/address/default-channel
+[browser-transfer]: /docs/browser-sdk/v4/reference/webrtc-call/transfer
+
+Place an outbound phone call with SignalWire and choose what happens when the destination answers.
+Start by calling your own phone and playing a short announcement, then track the call's progress,
+run an AI agent, or let users call from your web app.
+
+## Prepare for your first call
+
+Have these values ready:
+
+- Your Space URL, such as `.signalwire.com`.
+- Your Project ID and API token from the Dashboard's [API credentials][api-credentials] page.
+ Enable the token's **Voice** permission for the Calling API.
+- If calling a phone number, a voice-capable [phone number purchased in your Space][phone-numbers]
+ or a [verified caller ID][caller-id].
+- If calling from the browser, you need a [Subscriber token][subscriber-token] or
+ [guest token][guest-token] to provide to the Browser SDK client.
+- A destination device you can answer.
+
+
+A [trial project][trial-mode] can dial only numbers it has purchased or verified, and cannot call
+internationally at all. Any other project can dial any number, but needs
+[international dialing enabled][international] before it reaches another country.
+
+
+## Make your first call
+
+Call a phone you can answer and play a short announcement.
+
+
+
+### Choose how to place your call
+
+Choose the approach that fits how you want to control the call.
+
+| What you want to do | Where to start |
+|---|---|
+| Give SignalWire call instructions over HTTP, supplied inline or returned by your webhook | [REST Calling API](#place-the-call), using cURL or a Server SDK |
+| Control the call in real time, asynchronously receiving events and sending commands over a persistent WebSocket connection | [WebSocket (Relay)](#place-the-call), using a Server SDK |
+| Let someone place and speak on a call from your web app | [Browser SDK](#place-the-call) |
+
+Each approach places the same call, but they differ in how you follow and control it afterward.
+
+| Function | REST | WebSocket (Relay) | Browser SDK |
+|---|---|---|---|
+| Place the call without holding a connection open | | | |
+| Place the call from a web page, with the user speaking on it | | | |
+| Command a call already in progress from any process, by its call ID | | | |
+| Follow the call's events in your own code, with no public webhook URL | | | |
+| Receive call progress as HTTP callbacks to a URL you host | | | |
+
+SWML doesn't place calls. It's the script the call runs once it connects, so you place the call
+with REST or Relay and pass SWML in the `swml` field.
+
+### Set your credentials and caller ID
+
+Replace these values in the code sample you choose:
+
+| Value | Replace with |
+|---|---|
+| `` | Your Space's subdomain in `.signalwire.com` |
+| `` | Your Project ID |
+| `` | Your API token |
+| `` | Your caller ID number |
+| `` | A [Subscriber token][subscriber-token] created by your backend, for the Browser SDK examples |
+
+### Choose a destination
+
+A call can reach a phone, a [SIP destination][sip-credentials], a [Subscriber][subscribers], or
+another [Resource][resources] in your SignalWire Space.
+
+| Destination | Dial | Example |
+|---|---|---|
+| Phone | Its number in [E.164 format](/docs/platform/what-is-e164) | `+12025550123` |
+| SIP destination | Its SIP URI | `sip:support@example.com` |
+| Subscriber | Its resource address | `/private/support-rep` |
+| Application | Its resource address | `/public/support-agent` |
+| Conference | Its resource address | `/public/team-standup` |
+
+For this walkthrough, choose a device you can answer and replace
+`` with its address.
+
+### Place the call
+
+Use the REST Calling API with any server-side HTTP client or a SignalWire Server SDK. For
+WebSocket calling, use a Server SDK or the Browser SDK.
+
+
+
+
+The request includes a SignalWire Markup Language (SWML) document in `swml`. SignalWire runs
+it when the destination answers, playing the announcement below.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} }
+ ]
+ }
+ }
+ }
+ }'
+```
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+from signalwire import SWMLBuilder, SWMLService
+from signalwire.rest import RestClient
+
+client = RestClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+)
+
+swml = (
+ SWMLBuilder(SWMLService(name="outbound-call"))
+ .say("Hello, welcome to SignalWire!")
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+)
+print(call["id"])
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RestClient, SwmlBuilder } from "@signalwire/sdk";
+
+const client = new RestClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+});
+
+const swml = new SwmlBuilder()
+ .say("Hello, welcome to SignalWire!")
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+});
+console.log(call.id);
+```
+
+
+
+
+The TypeScript samples on this page pin `@signalwire/sdk@2.0.5`, whose `dial()` takes a single
+options object. The [TypeScript `dial` reference][ts-dial] documents the positional
+`dial(from, to, options)` form of a newer release. Match the form to the version you install.
+
+
+The REST API returns a call `id` and status `queued`. This confirms that SignalWire accepted the
+request; the call hasn't necessarily rung or been answered yet. Save the `id` to identify this call.
+
+
+
+
+
+
+Use a Server SDK when controlling a call flow from the server, or the Browser SDK when placing a
+call from a web app.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+async def main():
+ async with client:
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play([{
+ "type": "tts",
+ "params": {"text": "Hello, welcome to SignalWire!"},
+ }], on_completed=hang_up_after_playback)
+ await call.wait_for_ended()
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+await client.connect();
+
+try {
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ await call.play([
+ { type: "tts", text: "Hello, welcome to SignalWire!" },
+ ], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+} finally {
+ await client.disconnect();
+}
+```
+
+
+```javascript
+// Install: npm install @signalwire/js@latest rxjs
+// Run on HTTPS or localhost with these elements in your page:
+//
+// Call
+// Hang up
+// Use a Subscriber Access Token issued by your backend.
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(new StaticCredentialProvider({
+ token: "",
+}));
+const remoteAudio = document.querySelector("#remote-audio");
+const callButton = document.querySelector("#call");
+const hangupButton = document.querySelector("#hangup");
+
+callButton.onclick = async () => {
+ callButton.disabled = true;
+ try {
+ const call = await client.dial("", { audio: true, video: false });
+ call.remoteStream$.subscribe((stream) => (remoteAudio.srcObject = stream));
+ hangupButton.disabled = false;
+ hangupButton.onclick = () => {
+ void call.hangup().catch(console.error);
+ };
+ call.status$.subscribe((status) => {
+ if (status === "disconnected" || status === "failed" || status === "destroyed") {
+ remoteAudio.srcObject = null;
+ hangupButton.disabled = true;
+ callButton.disabled = false;
+ }
+ });
+ } catch (error) {
+ callButton.disabled = false;
+ console.error(error);
+ }
+};
+```
+
+
+
+
+
+
+### Answer the call
+
+Answer the destination phone. With a server example, you should hear "Hello, welcome to
+SignalWire!", then the call ends. With the browser example, allow
+microphone access to speak on the call and select **Hang up** when you finish.
+
+
+
+## Track the call's progress
+
+Use callbacks with REST or event handlers with Relay to follow what happens after you dial.
+Follow the section for the approach you used for your first call.
+
+### Track call progress via REST
+
+Add `status_url` and `status_events` to your first request to receive call progress callbacks.
+Keep the inline `swml` announcement. The examples below place another call with notifications
+for `ringing`, `answered`, and `ended`.
+
+Before running the request, replace `` with a webhook endpoint
+you control that SignalWire can reach. Your endpoint receives HTTP POST requests as the call
+reaches the selected states. See the [webhooks guide][webhooks] for endpoint setup and local testing.
+`status_events` accepts `created`, `ringing`, `answered`, and `ended`; if omitted, it defaults
+to `ended`.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} }
+ ]
+ }
+ },
+ "status_url": "",
+ "status_events": ["ringing", "answered", "ended"]
+ }
+ }'
+```
+
+
+```python
+swml = (
+ SWMLBuilder(SWMLService(name="outbound-call"))
+ .say("Hello, welcome to SignalWire!")
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+ status_url="",
+ status_events=["ringing", "answered", "ended"],
+)
+```
+
+
+```typescript
+const swml = new SwmlBuilder()
+ .say("Hello, welcome to SignalWire!")
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+ status_url: "",
+ status_events: ["ringing", "answered", "ended"],
+});
+```
+
+
+
+This flow shows a call with `ringing`, `answered`, and `ended` status events enabled:
+
+
+
+
+
+
+
+
+
+```mermaid
+sequenceDiagram
+ participant App as Your code
+ participant SW as SignalWire
+ participant Dest as Destination
+
+ App->>SW: dial: from, to, SWML
+ SW-->>App: call id, status queued
+ SW->>Dest: rings
+ SW-->>App: status ringing
+ Dest->>SW: answers
+ SW-->>App: status answered
+ Note over SW,Dest: Your SWML runs
+ Note over SW,Dest: Call finishes
+ SW-->>App: status ended
+```
+
+
+
+### Track call progress via WebSocket
+
+Relay exposes call state events through `call.on()`. Because `dial()` returns after the destination
+answers, the handler observes later states such as `ending` and `ended`.
+
+The Browser SDK returns a WebRTC `Call` while it is `ringing`. Subscribe to `call.status$` to follow
+it through `connecting`, `connected`, and the final states.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+from signalwire.relay.event import CallStateEvent
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+async def main():
+ async with client:
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ def handle_state(event: CallStateEvent):
+ print(f"State: {event.call_state}, reason: {event.end_reason}")
+
+ call.on("calling.call.state", handle_state)
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play([{
+ "type": "tts",
+ "params": {"text": "Hello, welcome to SignalWire!"},
+ }], on_completed=hang_up_after_playback)
+ await call.wait_for_ended()
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+await client.connect();
+
+try {
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ call.on("calling.call.state", (event) => {
+ console.log(`State: ${event.params.call_state}, reason: ${event.params.end_reason ?? ""}`);
+ });
+ await call.play([
+ { type: "tts", text: "Hello, welcome to SignalWire!" },
+ ], {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ });
+ await call.waitForEnded();
+} finally {
+ await client.disconnect();
+}
+```
+
+
+```javascript
+// Install: npm install @signalwire/js@latest rxjs
+// Run on HTTPS or localhost with these elements in your page:
+// Idle
+//
+// Call
+// Hang up
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const client = new SignalWire(new StaticCredentialProvider({
+ token: "",
+}));
+const statusLine = document.querySelector("#status");
+const remoteAudio = document.querySelector("#remote-audio");
+const callButton = document.querySelector("#call");
+const hangupButton = document.querySelector("#hangup");
+const finalStatuses = new Set(["disconnected", "failed", "destroyed"]);
+
+callButton.onclick = async () => {
+ callButton.disabled = true;
+ try {
+ const call = await client.dial("", { audio: true, video: false });
+ call.remoteStream$.subscribe((stream) => (remoteAudio.srcObject = stream));
+ call.status$.subscribe((status) => {
+ statusLine.textContent = `Call: ${status}`;
+ console.log(`Call: ${status}`);
+
+ if (finalStatuses.has(status)) {
+ remoteAudio.srcObject = null;
+ hangupButton.disabled = true;
+ callButton.disabled = false;
+ }
+ });
+
+ hangupButton.disabled = false;
+ hangupButton.onclick = () => {
+ void call.hangup().catch(console.error);
+ };
+ } catch (error) {
+ statusLine.textContent = "Call failed";
+ callButton.disabled = false;
+ console.error(error);
+ }
+};
+```
+
+
+
+For more Relay event handlers, see
+[Event listeners in the Relay client guide](/docs/server-sdks/guides/relay-client#event-listeners).
+
+For Relay, this flow shows the commands your code sends and the events SignalWire returns over the
+same persistent connection:
+
+
+
+
+
+
+
+
+
+```mermaid
+sequenceDiagram
+ participant App as Your code
+ participant SW as SignalWire
+
+ Note over App,SW: One persistent WebSocket, both directions
+ App->>SW: calling.dial
+ SW-->>App: calling.call.state: created, ringing, answered
+ App->>SW: calling.play
+ SW-->>App: calling.call.play: playing, then finished
+ App->>SW: calling.end
+ SW-->>App: calling.call.state: ending, then ended
+```
+
+
+
+## Examples
+
+### Run an AI agent
+
+Start an AI agent that welcomes the person and answers basic questions about SignalWire.
+
+
+Before dialing, follow consent, do-not-call, and calling-hour requirements for artificial voices.
+See the [TCPA guide][tcpa] and [AI best practices][ai-best-practices].
+
+
+#### Run an AI agent via REST
+
+Send a `dial` request with an inline SWML [`ai` instruction][swml-ai] that starts when the call is answered.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ {
+ "ai": {
+ "params": {
+ "static_greeting": "Hello, welcome to SignalWire! This call uses an artificial voice.",
+ "static_greeting_no_barge": true
+ },
+ "prompt": {
+ "text": "Welcome the caller to SignalWire. Briefly explain that SignalWire provides APIs and SDKs for voice, messaging, video, and AI. Answer basic follow-up questions. If you are unsure, direct the caller to signalwire.com."
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ }'
+```
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+from signalwire import SWMLBuilder, SWMLService
+from signalwire.rest import RestClient
+
+client = RestClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+)
+
+swml = (
+ SWMLBuilder(SWMLService(name="outbound-ai-call"))
+ .ai(
+ prompt_text="Welcome the caller to SignalWire. Briefly explain that SignalWire provides APIs and SDKs for voice, messaging, video, and AI. Answer basic follow-up questions. If you are unsure, direct the caller to signalwire.com.",
+ params={
+ "static_greeting": "Hello, welcome to SignalWire! This call uses an artificial voice.",
+ "static_greeting_no_barge": True,
+ },
+ )
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+)
+print(call["id"])
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RestClient, SwmlBuilder } from "@signalwire/sdk";
+
+const client = new RestClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+});
+
+const swml = new SwmlBuilder()
+ .ai({
+ prompt: "Welcome the caller to SignalWire. Briefly explain that SignalWire provides APIs and SDKs for voice, messaging, video, and AI. Answer basic follow-up questions. If you are unsure, direct the caller to signalwire.com.",
+ params: {
+ static_greeting: "Hello, welcome to SignalWire! This call uses an artificial voice.",
+ static_greeting_no_barge: true,
+ },
+ })
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+});
+console.log(call.id);
+```
+
+
+
+#### Run an AI agent via WebSocket (Relay)
+
+Dial with Relay, start the agent with `call.ai()`, and keep the connection open until the call ends.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+async def main():
+ async with client:
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ await call.ai(
+ ai_params={
+ "static_greeting": "Hello, welcome to SignalWire! This call uses an artificial voice.",
+ "static_greeting_no_barge": True,
+ },
+ prompt={
+ "text": """Welcome the caller to SignalWire. Briefly explain that SignalWire
+provides APIs and SDKs for voice, messaging, video, and AI. Answer basic
+follow-up questions. If you are unsure, direct the caller to signalwire.com."""
+ },
+ )
+ # Keep the connection open until the destination hangs up.
+ await call.wait_for_ended()
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+await client.connect();
+
+try {
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ await call.ai({
+ aiParams: {
+ static_greeting: "Hello, welcome to SignalWire! This call uses an artificial voice.",
+ static_greeting_no_barge: true,
+ },
+ prompt: {
+ text: `Welcome the caller to SignalWire. Briefly explain that SignalWire
+ provides APIs and SDKs for voice, messaging, video, and AI. Answer basic
+ follow-up questions. If you are unsure, direct the caller to signalwire.com.`,
+ },
+ });
+ // Keep the connection open until the destination hangs up.
+ await call.waitForEnded();
+} finally {
+ await client.disconnect();
+}
+```
+
+
+
+### Leave a voicemail
+
+Use answering machine detection (AMD) to speak to a person immediately or leave a message after a
+voicemail greeting and beep.
+
+Follow the consent and calling-hour requirements in the [TCPA guide][tcpa].
+
+#### Leave a voicemail via REST
+
+Use [`detect_machine`][swml-detect-machine] and `switch` to choose the live or voicemail message.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ {
+ "detect_machine": {
+ "detectors": "amd",
+ "detect_message_end": true,
+ "timeout": 30
+ }
+ },
+ {
+ "switch": {
+ "variable": "detect_result",
+ "case": {
+ "machine": [
+ { "play": {"url": "say:Hello, welcome to SignalWire! Visit signalwire.com to learn more."} }
+ ],
+ "human": [
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} }
+ ]
+ },
+ "default": [
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} }
+ ]
+ }
+ },
+ { "hangup": {} }
+ ]
+ }
+ }
+ }
+ }'
+```
+
+
+```python
+voicemail = "say:Hello, welcome to SignalWire! Visit signalwire.com to learn more."
+live = "say:Hello, welcome to SignalWire!"
+
+swml = (
+ SWMLBuilder(SWMLService(name="outbound-voicemail"))
+ .detect_machine(
+ detectors="amd",
+ detect_message_end=True,
+ timeout=30,
+ )
+ .switch(
+ variable="detect_result",
+ case={
+ "machine": [{"play": {"url": voicemail}}],
+ "human": [{"play": {"url": live}}],
+ },
+ default=[{
+ "play": {
+ "url": "say:Hello, welcome to SignalWire!"
+ }
+ }],
+ )
+ .hangup()
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+)
+print(call["id"])
+```
+
+
+```typescript
+const voicemail = "say:Hello, welcome to SignalWire! Visit signalwire.com to learn more.";
+const live = "say:Hello, welcome to SignalWire!";
+
+const swml = new SwmlBuilder()
+ .detect_machine({
+ detectors: "amd",
+ detect_message_end: true,
+ timeout: 30,
+ })
+ .switch({
+ variable: "detect_result",
+ case: {
+ machine: [{ play: { url: voicemail } }],
+ human: [{ play: { url: live } }],
+ },
+ default: [{
+ play: {
+ url: "say:Hello, welcome to SignalWire!",
+ },
+ }],
+ })
+ .hangup()
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+});
+console.log(call.id);
+```
+
+
+
+#### Leave a voicemail via WebSocket (Relay)
+
+Use `call.detect()` to play the live message after `HUMAN` or `UNKNOWN`, or wait for `READY` after a
+machine greeting.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+from signalwire.relay.event import DetectEvent
+
+VOICEMAIL = "Hello, welcome to SignalWire! Visit signalwire.com to learn more."
+LIVE = "Hello, welcome to SignalWire!"
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+def outcome(event: DetectEvent) -> str:
+ return event.detect.get("params", {}).get("event", "")
+
+async def main():
+ async with client:
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ announced = False
+
+ async def announce(event: DetectEvent):
+ nonlocal announced
+ if announced or call.state == "ended":
+ return
+ result = outcome(event)
+ if result == "READY":
+ # The voicemail greeting and its beep have finished.
+ announced = True
+ await call.play(
+ [{"type": "tts", "params": {"text": VOICEMAIL}}],
+ on_completed=hang_up_after_playback,
+ )
+ elif result in ("HUMAN", "UNKNOWN"):
+ announced = True
+ await call.play(
+ [{"type": "tts", "params": {"text": LIVE}}],
+ on_completed=hang_up_after_playback,
+ )
+ elif result == "finished":
+ await call.hangup()
+
+ call.on("calling.call.detect", announce)
+ await call.detect({"type": "machine", "params": {"detect_message_end": True}}, timeout=30)
+ await call.wait_for_ended()
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// Save as outbound-call.mts and run: npx tsx outbound-call.mts
+import { RelayClient, DetectEvent, RelayEvent } from "@signalwire/sdk";
+
+const VOICEMAIL = "Hello, welcome to SignalWire! Visit signalwire.com to learn more.";
+const LIVE = "Hello, welcome to SignalWire!";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+function outcome(event: RelayEvent): string {
+ const detect = (event as DetectEvent).detect?.params as { event?: string } | undefined;
+ return detect?.event ?? "";
+}
+
+await client.connect();
+
+try {
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ const hangUpAfterPlayback = async () => {
+ if (call.state !== "ended") await call.hangup();
+ };
+ let announced = false;
+
+ call.on("calling.call.detect", async (event) => {
+ if (announced || call.state === "ended") return;
+ const result = outcome(event);
+ if (result === "READY") {
+ // The voicemail greeting and its beep have finished.
+ announced = true;
+ await call.play([{ type: "tts", text: VOICEMAIL }], { onCompleted: hangUpAfterPlayback });
+ } else if (result === "HUMAN" || result === "UNKNOWN") {
+ announced = true;
+ await call.play([{ type: "tts", text: LIVE }], { onCompleted: hangUpAfterPlayback });
+ } else if (result === "finished") {
+ await call.hangup();
+ }
+ });
+
+ await call.detect({ type: "machine", params: { detect_message_end: true } }, { timeout: 30 });
+ await call.waitForEnded();
+} finally {
+ await client.disconnect();
+}
+```
+
+
+
+### Whisper before connecting two people
+
+Play a private message to ``, then connect that call to
+``.
+
+#### Play a whisper via REST
+
+Use `connect.confirm` to play the [whisper][call-whisper] to the agent before bridging the calls.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} },
+ {
+ "connect": {
+ "from": "",
+ "to": "",
+ "confirm": [
+ { "play": {"url": "say:You are about to be connected to the caller."} }
+ ],
+ "confirm_timeout": 20
+ }
+ }
+ ]
+ }
+ }
+ }
+ }'
+```
+
+
+```python
+whisper = "say:You are about to be connected to the caller."
+
+swml = (
+ SWMLBuilder(SWMLService(name="outbound-whisper"))
+ .say("Hello, welcome to SignalWire!")
+ .connect(**{
+ "from": "",
+ "to": "",
+ "confirm": [{"play": {"url": whisper}}],
+ "confirm_timeout": 20,
+ })
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+)
+print(call["id"])
+```
+
+
+```typescript
+const whisper = "say:You are about to be connected to the caller.";
+
+const swml = new SwmlBuilder()
+ .say("Hello, welcome to SignalWire!")
+ .connect({
+ from: "",
+ to: "",
+ confirm: [{ play: { url: whisper } }],
+ confirm_timeout: 20,
+ })
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+});
+console.log(call.id);
+```
+
+
+
+#### Play a whisper via WebSocket (Relay)
+
+Dial the agent first, play the whisper, then connect the caller after playback finishes.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+async def main():
+ async with client:
+ # Call the agent first: only this leg hears the whisper.
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ async def connect_the_caller(_event):
+ if call.state != "ended":
+ await call.connect([[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]])
+
+ await call.play(
+ [{"type": "tts", "params": {"text": "You are about to be connected to the caller."}}],
+ on_completed=connect_the_caller,
+ )
+ await call.wait_for_ended()
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+await client.connect();
+
+try {
+ // Call the agent first: only this leg hears the whisper.
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ await call.play(
+ [{ type: "tts", text: "You are about to be connected to the caller." }],
+ {
+ onCompleted: async () => {
+ if (call.state === "ended") return;
+ await call.connect([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ },
+ },
+ );
+ await call.waitForEnded();
+} finally {
+ await client.disconnect();
+}
+```
+
+
+
+### Record the call
+
+Record both sides of an outbound call and retrieve the finished recording URL.
+
+
+Confirm which parties must consent and announce the recording when required.
+
+
+#### Record the call via REST
+
+Start [`record_call`][swml-record-call] in the background and receive the result at `status_url`.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ {
+ "record_call": {
+ "format": "mp3",
+ "direction": "both",
+ "stereo": true,
+ "beep": true,
+ "status_url": ""
+ }
+ },
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} }
+ ]
+ }
+ }
+ }
+ }'
+```
+
+
+```python
+swml = (
+ SWMLBuilder(SWMLService(name="outbound-recording"))
+ .record_call(
+ format="mp3",
+ direction="both",
+ stereo=True,
+ beep=True,
+ status_url="",
+ )
+ .say("Hello, welcome to SignalWire!")
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+)
+print(call["id"])
+```
+
+
+```typescript
+const swml = new SwmlBuilder()
+ .record_call({
+ format: "mp3",
+ direction: "both",
+ stereo: true,
+ beep: true,
+ status_url: "",
+ })
+ .say("Hello, welcome to SignalWire!")
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+});
+console.log(call.id);
+```
+
+
+
+#### Record the call via WebSocket (Relay)
+
+Start `call.record()` and read the recording URL from its `finished` event.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+async def main():
+ async with client:
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ recording = await call.record(
+ audio={
+ "format": "mp3",
+ "direction": "both",
+ "stereo": True,
+ "beep": True,
+ "initial_timeout": 0,
+ "end_silence_timeout": 0,
+ },
+ )
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": "Hello, welcome to SignalWire!"}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+ finished = await recording.wait()
+ print(f"Recording: {finished.url}")
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// Save as outbound-call.mts and run: npx tsx outbound-call.mts
+import { RelayClient, RecordEvent } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+await client.connect();
+
+try {
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ const recording = await call.record({
+ format: "mp3",
+ direction: "both",
+ stereo: true,
+ beep: true,
+ initial_timeout: 0,
+ end_silence_timeout: 0,
+ });
+ await call.play(
+ [{ type: "tts", text: "Hello, welcome to SignalWire!" }],
+ {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ },
+ );
+ await call.waitForEnded();
+ const finished = await recording.wait();
+ console.log(`Recording: ${(finished as RecordEvent).url}`);
+} finally {
+ await client.disconnect();
+}
+```
+
+
+
+### Stream the call audio
+
+Stream both sides of a live call to your secure WebSocket endpoint for real-time processing.
+
+#### Stream call audio via REST
+
+Start [`stream`][swml-stream] in the background and send status events to your webhook.
+
+
+
+```bash
+curl -X POST "https://.signalwire.com/api/calling/calls" \
+ -u ":" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "command": "dial",
+ "params": {
+ "from": "",
+ "to": "",
+ "swml": {
+ "version": "1.0.0",
+ "sections": {
+ "main": [
+ {
+ "stream": {
+ "url": "",
+ "track": "both_tracks",
+ "codec": "PCMU",
+ "status_url": ""
+ }
+ },
+ { "play": {"url": "say:Hello, welcome to SignalWire!"} }
+ ]
+ }
+ }
+ }
+ }'
+```
+
+
+```python
+# `stream` is not yet in the builder's bundled schema, so add it as raw SWML.
+swml_builder = SWMLBuilder(
+ SWMLService(name="outbound-stream", schema_validation=False)
+)
+swml_builder.service.add_verb("stream", {
+ "url": "",
+ "track": "both_tracks",
+ "codec": "PCMU",
+ "status_url": "",
+})
+swml = (
+ swml_builder
+ .say("Hello, welcome to SignalWire!")
+ .build()
+)
+
+call = client.calling.dial(
+ from_="",
+ to="",
+ swml=swml,
+)
+print(call["id"])
+```
+
+
+```typescript
+// `stream` is not yet in the builder's bundled schema, so add it as raw SWML.
+const swmlBuilder = new SwmlBuilder();
+swmlBuilder.setValidation(false);
+swmlBuilder.addVerb("stream", {
+ url: "",
+ track: "both_tracks",
+ codec: "PCMU",
+ status_url: "",
+});
+swmlBuilder.setValidation(true);
+const swml = swmlBuilder
+ .say("Hello, welcome to SignalWire!")
+ .build();
+
+const call = await client.calling.dial({
+ from: "",
+ to: "",
+ swml,
+});
+console.log(call.id);
+```
+
+
+
+#### Stream call audio via WebSocket (Relay)
+
+Start `call.stream()` over the Relay connection and keep it running until the call ends.
+
+
+
+```python
+# Install: python -m pip install signalwire-sdk==3.4.1
+# Save as outbound_call.py and run: python outbound_call.py
+import asyncio
+from signalwire.relay import RelayClient
+
+client = RelayClient(
+ project="",
+ token="",
+ host=".signalwire.com",
+ contexts=["default"],
+)
+
+async def main():
+ async with client:
+ call = await client.dial(
+ devices=[[{
+ "type": "phone",
+ "params": {
+ "from_number": "",
+ "to_number": "",
+ "timeout": 30,
+ },
+ }]],
+ )
+ stream = await call.stream(
+ url="",
+ track="both_tracks",
+ codec="PCMU",
+ custom_parameters={"session_id": ""},
+ )
+ print(f"Streaming audio, control ID {stream.control_id}")
+
+ async def hang_up_after_playback(_event):
+ if call.state != "ended":
+ await call.hangup()
+
+ await call.play(
+ [{"type": "tts", "params": {"text": "Hello, welcome to SignalWire!"}}],
+ on_completed=hang_up_after_playback,
+ )
+ await call.wait_for_ended()
+
+asyncio.run(main())
+```
+
+
+```typescript
+// Install: npm install @signalwire/sdk@2.0.5
+// This sample also runs as JavaScript: save as outbound-call.mjs,
+// then run: node outbound-call.mjs
+import { RelayClient } from "@signalwire/sdk";
+
+const client = new RelayClient({
+ project: "",
+ token: "",
+ host: ".signalwire.com",
+ contexts: ["default"],
+});
+
+await client.connect();
+
+try {
+ const call = await client.dial([[{
+ type: "phone",
+ params: {
+ from_number: "",
+ to_number: "",
+ timeout: 30,
+ },
+ }]]);
+ const stream = await call.stream("", {
+ track: "both_tracks",
+ codec: "PCMU",
+ customParameters: { session_id: "" },
+ });
+ console.log(`Streaming audio, control ID ${stream.controlId}`);
+ await call.play(
+ [{ type: "tts", text: "Hello, welcome to SignalWire!" }],
+ {
+ onCompleted: async () => {
+ if (call.state !== "ended") await call.hangup();
+ },
+ },
+ );
+ await call.waitForEnded();
+} finally {
+ await client.disconnect();
+}
+```
+
+
+
+### Call from the browser
+
+Place a WebRTC call from a web page using a restricted [guest token][guest-token] from your backend.
+Serve the page over HTTPS or `localhost` so the browser can access the microphone.
+
+
+
+```html
+
+
+
+
+ Call with SignalWire
+
+
+ Idle
+ Call
+ Hang up
+
+
+
+
+```
+
+
+```javascript
+// Install: npm install @signalwire/js@latest rxjs
+// Save as call.js next to the page above.
+// GET /api/guest-token is your own endpoint: it creates a guest SAT with your
+// Project API token and returns it as {"token": "..."}.
+import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
+
+const statusLine = document.querySelector("#status");
+const remoteAudio = document.querySelector("#remote-audio");
+const callButton = document.querySelector("#call");
+const hangupButton = document.querySelector("#hangup");
+
+let clientPromise;
+
+function getClient() {
+ clientPromise ??= (async () => {
+ const response = await fetch("/api/guest-token");
+ if (!response.ok) throw new Error(`Token request failed: ${response.status}`);
+ const { token } = await response.json();
+ if (!token) throw new Error("Token response did not include a token");
+ return new SignalWire(new StaticCredentialProvider({ token }));
+ })().catch((error) => {
+ clientPromise = undefined;
+ throw error;
+ });
+ return clientPromise;
+}
+
+function reset() {
+ remoteAudio.srcObject = null;
+ callButton.disabled = false;
+ hangupButton.disabled = true;
+}
+
+callButton.onclick = async () => {
+ callButton.disabled = true;
+ statusLine.textContent = "Connecting";
+ try {
+ const client = await getClient();
+ const call = await client.dial("", { audio: true, video: false });
+ call.remoteStream$.subscribe((stream) => (remoteAudio.srcObject = stream));
+ hangupButton.disabled = false;
+ hangupButton.onclick = () => {
+ void call.hangup().catch(console.error);
+ };
+ call.status$.subscribe((status) => {
+ statusLine.textContent = status;
+ if (status === "disconnected" || status === "failed" || status === "destroyed") {
+ reset();
+ }
+ });
+ } catch (error) {
+ statusLine.textContent = "Call failed";
+ console.error(error);
+ reset();
+ }
+};
+```
+
+
+
+#### Choose audio, video, or both
+
+`client.dial()` takes a [`DialOptions`][dial-options] object whose `audio` and `video` keys set what
+the browser captures and sends. Omit them and the call sends audio only.
+
+
+
+
+```javascript
+const call = await client.dial("", { audio: true, video: true });
+```
+
+A standard video call. Both tracks come from the selected microphone and camera.
+
+
+
+
+```javascript
+const call = await client.dial("", { audio: true, video: false });
+```
+
+A phone-style call, with no camera permission prompt.
+
+
+
+
+```javascript
+const call = await client.dial("", { audio: false, video: true });
+```
+
+Joins on camera with the microphone muted — a kiosk, or a viewer who watches without speaking.
+
+
+
+
+```javascript
+const call = await client.dial("", {
+ audio: false,
+ video: false,
+ receiveAudio: true,
+ receiveVideo: true,
+});
+```
+
+Joins without sending any media. The remote tracks still arrive on `remoteStream$`.
+
+
+
+
+A destination address can carry a `?channel=audio` or `?channel=video` hint that sets the matching
+defaults, but options passed to `dial()` always win. If you're picking destinations from the
+directory, an `Address` exposes [`defaultChannel`][address-default-channel], a ready-to-dial URI, so
+you don't assemble the string yourself. To pin the microphone, camera, or speaker across
+every call instead of constraining each `dial()`, use the [device management APIs][device-management].
+
+#### Attach the media to the page
+
+The call exposes `localStream$` (what the user sends) and `remoteStream$` (what the user receives).
+Bind each to a media element's `srcObject`. The sample above binds `remoteStream$` to an ``
+element; a video call binds both streams to `` elements:
+
+```html
+
+
+```
+
+```javascript
+call.localStream$.subscribe((stream) => (localVideo.srcObject = stream));
+call.remoteStream$.subscribe((stream) => (remoteVideo.srcObject = stream));
+```
+
+Give the local element `muted` so the user doesn't hear their own voice back, leave the remote
+element unmuted, and give both `playsinline` for mobile Safari. An audio-only call uses
+`remoteStream$` the same way.
+
+Watch the browser console as you dial: the call moves through `connecting` to `connected`, and
+through `disconnecting`, `disconnected`, and `destroyed` once it ends. If `dial()` rejects with
+[`CallCreateError`][call-create-error], the token's scope doesn't reach the destination — re-check the
+token's `allowed_addresses` and its project.
+
+`call.hangup()` ends the call for everyone. To leave the page but keep the call alive on the platform,
+use [`transfer()`][browser-transfer] instead.
+
+For receiving calls in the browser, see the [inbound calls guide][browser-inbound]; for mute, hold,
+and other in-call controls, see [call controls][browser-call-controls].
diff --git a/fern/products/platform/pages/calling/voice/overview.mdx b/fern/products/platform/pages/calling/voice/overview.mdx
index 66eb307434..10c58c1a15 100644
--- a/fern/products/platform/pages/calling/voice/overview.mdx
+++ b/fern/products/platform/pages/calling/voice/overview.mdx
@@ -21,6 +21,9 @@ Whether building a UCaaS solution, modernizing a legacy IVR, augmenting CX with
The fundamentals of your first calling app
+
+ Dial from your backend or the browser, and choose what runs when someone answers
+
Get started with our Compatibility API