Outbound calling

View as MarkdownOpen in Claude

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:

Trial projects and international calls are restricted

A trial project 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 before it reaches another country.

Make your first call

Call a phone you can answer and play a short announcement.

1

Choose how to place your call

Choose the approach that fits how you want to control the call.

What you want to doWhere to start
Give SignalWire call instructions over HTTP, supplied inline or returned by your webhookREST Calling API, using cURL or a Server SDK
Control the call in real time, asynchronously receiving events and sending commands over a persistent WebSocket connectionWebSocket (Relay), using a Server SDK
Let someone place and speak on a call from your web appBrowser SDK

Each approach places the same call, but they differ in how you follow and control it afterward.

FunctionRESTWebSocket (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.

2

Set your credentials and caller ID

Replace these values in the code sample you choose:

ValueReplace with
<YOUR_SPACE>Your Space’s subdomain in <YOUR_SPACE>.signalwire.com
<YOUR_PROJECT_ID>Your Project ID
<YOUR_API_TOKEN>Your API token
<YOUR_CALLER_ID>Your caller ID number
<YOUR_SUBSCRIBER_ACCESS_TOKEN>A Subscriber token created by your backend, for the Browser SDK examples
3

Choose a destination

A call can reach a phone, a SIP destination, a Subscriber, or another Resource in your SignalWire Space.

DestinationDialExample
PhoneIts number in E.164 format+12025550123
SIP destinationIts SIP URIsip:support@example.com
SubscriberIts resource address/private/support-rep
ApplicationIts resource address/public/support-agent
ConferenceIts resource address/public/team-standup

For this walkthrough, choose a device you can answer and replace <YOUR_DESTINATION> with its address.

4

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.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"swml": {
"version": "1.0.0",
"sections": {
"main": [
{ "play": {"url": "say:Hello, welcome to SignalWire!"} }
]
}
}
}
}'

TypeScript dial() signature depends on the SDK version

The TypeScript samples on this page pin @signalwire/sdk@2.0.5, whose dial() takes a single options object. The TypeScript dial reference 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.

Response
{
"billing_ms": null,
"charge": 0,
"charge_details": [
{
"charge": 0.004,
"description": "Outbound Voice"
}
],
"created_at": "2024-05-06T12:20:00Z",
"direction": "outbound-api",
"duration": null,
"duration_ms": null,
"from": "+12069708643",
"id": "0e9c80d7-a149-4917-892d-420043709f45",
"parent_id": null,
"source": "realtime_api",
"status": "queued",
"to": "+15550198765",
"type": "relay_pstn_call",
"url": null
}
5

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 <YOUR_STATUS_WEBHOOK_URL> 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 for endpoint setup and local testing. status_events accepts created, ringing, answered, and ended; if omitted, it defaults to ended.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"swml": {
"version": "1.0.0",
"sections": {
"main": [
{ "play": {"url": "say:Hello, welcome to SignalWire!"} }
]
}
},
"status_url": "<YOUR_STATUS_WEBHOOK_URL>",
"status_events": ["ringing", "answered", "ended"]
}
}'

This flow shows a call with ringing, answered, and ended status events enabled:

Your code sends a dial request with from, to, and SWML. SignalWire returns the call id with status queued and rings the destination and reports status ringing. When the person answers, SignalWire reports status answered and runs your SWML. When the call finishes, SignalWire reports 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.

# 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="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
contexts=["default"],
)
async def main():
async with client:
call = await client.dial(
devices=[[{
"type": "phone",
"params": {
"from_number": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"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())

For more Relay event handlers, see Event listeners in the Relay client guide.

For Relay, this flow shows the commands your code sends and the events SignalWire returns over the same persistent connection:

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.

Examples

Run an AI agent

Start an AI agent that welcomes the person and answers basic questions about SignalWire.

Outbound AI calls are regulated

Before dialing, follow consent, do-not-call, and calling-hour requirements for artificial voices. See the TCPA guide and AI best practices.

Run an AI agent via REST

Send a dial request with an inline SWML ai instruction that starts when the call is answered.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"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."
}
}
}
]
}
}
}
}'

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.

# 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="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
contexts=["default"],
)
async def main():
async with client:
call = await client.dial(
devices=[[{
"type": "phone",
"params": {
"from_number": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"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())

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.

Leave a voicemail via REST

Use detect_machine and switch to choose the live or voicemail message.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"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": {} }
]
}
}
}
}'

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.

# 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="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.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": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"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())

Whisper before connecting two people

Play a private message to <YOUR_AGENT_DESTINATION>, then connect that call to <YOUR_DESTINATION>.

Play a whisper via REST

Use connect.confirm to play the whisper to the agent before bridging the calls.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"swml": {
"version": "1.0.0",
"sections": {
"main": [
{ "play": {"url": "say:Hello, welcome to SignalWire!"} },
{
"connect": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_AGENT_DESTINATION>",
"confirm": [
{ "play": {"url": "say:You are about to be connected to the caller."} }
],
"confirm_timeout": 20
}
}
]
}
}
}
}'

Play a whisper via WebSocket (Relay)

Dial the agent first, play the whisper, then connect the caller after playback finishes.

# 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="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.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": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_AGENT_DESTINATION>",
"timeout": 30,
},
}]],
)
async def connect_the_caller(_event):
if call.state != "ended":
await call.connect([[{
"type": "phone",
"params": {
"from_number": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"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())

Record the call

Record both sides of an outbound call and retrieve the finished recording URL.

Recording consent is your responsibility

Confirm which parties must consent and announce the recording when required.

Record the call via REST

Start record_call in the background and receive the result at status_url.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"swml": {
"version": "1.0.0",
"sections": {
"main": [
{
"record_call": {
"format": "mp3",
"direction": "both",
"stereo": true,
"beep": true,
"status_url": "<YOUR_RECORDING_STATUS_URL>"
}
},
{ "play": {"url": "say:Hello, welcome to SignalWire!"} }
]
}
}
}
}'

Record the call via WebSocket (Relay)

Start call.record() and read the recording URL from its finished event.

# 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="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
contexts=["default"],
)
async def main():
async with client:
call = await client.dial(
devices=[[{
"type": "phone",
"params": {
"from_number": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"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())

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 in the background and send status events to your webhook.

curl -X POST "https://<YOUR_SPACE>.signalwire.com/api/calling/calls" \
-u "<YOUR_PROJECT_ID>:<YOUR_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"command": "dial",
"params": {
"from": "<YOUR_CALLER_ID>",
"to": "<YOUR_DESTINATION>",
"swml": {
"version": "1.0.0",
"sections": {
"main": [
{
"stream": {
"url": "<YOUR_AUDIO_STREAM_URL>",
"track": "both_tracks",
"codec": "PCMU",
"status_url": "<YOUR_STREAM_STATUS_URL>"
}
},
{ "play": {"url": "say:Hello, welcome to SignalWire!"} }
]
}
}
}
}'

Stream call audio via WebSocket (Relay)

Start call.stream() over the Relay connection and keep it running until the call ends.

# 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="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
contexts=["default"],
)
async def main():
async with client:
call = await client.dial(
devices=[[{
"type": "phone",
"params": {
"from_number": "<YOUR_CALLER_ID>",
"to_number": "<YOUR_DESTINATION>",
"timeout": 30,
},
}]],
)
stream = await call.stream(
url="<YOUR_AUDIO_STREAM_URL>",
track="both_tracks",
codec="PCMU",
custom_parameters={"session_id": "<YOUR_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())

Call from the browser

Place a WebRTC call from a web page using a restricted guest token from your backend. Serve the page over HTTPS or localhost so the browser can access the microphone.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Call with SignalWire</title>
</head>
<body>
<p id="status">Idle</p>
<button id="call" type="button">Call</button>
<button id="hangup" type="button" disabled>Hang up</button>
<audio id="remote-audio" autoplay></audio>
<script type="module" src="./call.js"></script>
</body>
</html>

Choose audio, video, or both

client.dial() takes a DialOptions object whose audio and video keys set what the browser captures and sends. Omit them and the call sends audio only.

const call = await client.dial("<YOUR_DESTINATION>", { audio: true, video: true });

A standard video call. Both tracks come from the selected microphone and camera.

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, 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.

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 <audio> element; a video call binds both streams to <video> elements:

<video id="local-video" autoplay muted playsinline></video>
<video id="remote-video" autoplay playsinline></video>
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, 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() instead.

For receiving calls in the browser, see the inbound calls guide; for mute, hold, and other in-call controls, see call controls.