WebRTC
Build softphone experiences for your users on web, mobile, and desktop using DialStack's WebRTC API. DialStack does not ship end-user softphone apps: you build your own and brand it as you like.
This API is the softphone side of DialStack — it lets a signed-in user place and receive calls from their browser or native app. It is not a programmable-voice or A2P (application-to-person) API: every WebRTC session is bound to an authenticated DialStack user, and the call shows up in their call history, billing, and presence just like a call from their desk phone.
Overview
The WebRTC API enables your application to make and receive phone calls directly from a browser or native app on behalf of a signed-in user. It consists of three parts:
- User authentication — end users authenticate to get a token (Authentication guide)
- Signalling — a WebSocket connection at
wss://webrtc.dialstack.ai/v1/webrtchandles call setup, control, and presence - Media — WebRTC peer connections carry the actual audio between the client and DialStack
A single WebSocket connection handles all signalling for one user, including multiple simultaneous calls.
Architecture
┌───────────────────────────────────────────────────────────┐
│ Your Application (browser / mobile / desktop) │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ │
│ │ Your UI │ │ DialStack SDK │ │ WebRTC │ │
│ │ (dial pad, │──│ (signalling, │──│ Media │ │
│ │ contacts) │ │ call control) │ │ (audio) │ │
│ └─────────────┘ └────────┬─────────┘ └──────┬──────┘ │
│ │ │ │
└────────────────────────────┼───────────────────┼──────────┘
│ WSS │ SRTP
│ (signalling) │ (encrypted audio)
▼ ▼
┌────────────────────────────────────────┐
│ DialStack │
│ │
│ Signalling ──── Media ──── PSTN │
│ Server Gateway Gateway │
└────────────────────────────────────────┘
- Signalling travels over a single WebSocket (JSON messages)
- Media flows directly via WebRTC (ICE/DTLS-SRTP) — encrypted end-to-end between client and DialStack
- PSTN connectivity is handled server-side — your client just sends and receives audio
Quick Start
1. Get a user token
After your backend has signed the user in with your own identity provider, it mints a DialStack user token for them, server to server, and hands the token's client_secret to your frontend. Your secret key never reaches the client. User tokens shows how to mint one.
2. Connect the softphone
// Your frontend
import { DialStackPhone } from '@dialstack/sdk-webrtc';
const phone = new DialStackPhone({
token,
onTokenExpiring: async () => {
const { token } = await fetch('/api/dialstack/user-token', {
method: 'POST',
headers: { Authorization: `Bearer ${sessionToken}` },
}).then((r) => r.json());
return token;
},
});
await phone.connect();
console.log('Phone connected, ready for calls');
3. Make a call
const call = await phone.call('+14155551234');
call.on('ringing', () => console.log('Ringing...'));
call.on('answered', () => console.log('Connected!'));
call.on('ended', (reason) => console.log('Call ended:', reason));
4. Receive a call
phone.on('incoming', (call) => {
console.log('Incoming call from', call.from, call.fromName);
// Show UI, then:
call.answer();
// Or reject:
// call.reject();
});
Connecting
WebSocket Connection
The SDK manages the WebSocket connection automatically. Under the hood, it:
- Opens a WebSocket to
wss://webrtc.dialstack.ai/v1/webrtc, a host that is latency-routed with regional failover - Sends an
authenticatemessage with the user token - Receives
authenticatedconfirming the session - Sends
pingevery 30 seconds to keep the connection alive
const phone = new DialStackPhone({ token });
phone.on('connected', () => {
// WebSocket connected and authenticated
});
phone.on('reconnecting', (attempt, delayMs) => {
// Connection dropped; the SDK retries after delayMs
});
phone.on('reconnected', () => {
// Back online after a drop
});
phone.on('disconnected', (error) => {
// Closed for good: no reconnect follows. `error` is the fatal error
// (e.g. session_replaced, auth_expired), or undefined after disconnect()
});
await phone.connect();
Reconnection
If the WebSocket connection drops (network change, brief outage), the SDK emits reconnecting and retries with exponential backoff and jitter, waiting at most 30 seconds between attempts. Once it is back it emits reconnected. Pass autoReconnect: false in PhoneOptions to handle reconnection yourself.
A drop ends the calls that were in progress: each one emits ended with reason failed, and an outbound call still being set up rejects with transport_closed. Place or receive new calls after reconnected.
disconnected means the connection is closed and no reconnect follows: after disconnect(), after a drop with autoReconnect off, or on a fatal error such as session_replaced, session_revoked or auth_expired, which it carries as its argument.
Session Limits
A single user can have up to 3 concurrent softphone sessions. Browsers and mobile apps count toward the same limit: a session is one connected DialStackPhone, so two browser tabs that each connect use two sessions. All connected sessions ring on an incoming call, and the user can answer it from any of them.
A mobile app that is dormant, waiting to be woken by a push notification, is not connected and does not use a session.
When a 4th session connects, the oldest session is disconnected with a fatal session_replaced error. If the oldest session is on a call, the call is not dropped: the session stays connected until the call ends, then receives session_replaced. While it finishes that call it has already given up its slot, so it no longer rings for new incoming calls.
The SDK does not reconnect after session_replaced, and your app shouldn't either. Reconnecting would push out another of the user's sessions, which would then reconnect in turn. Tell the user instead, and reconnect only when they ask:
phone.on('disconnected', (error) => {
if (error?.code === 'session_replaced') {
showMessage('Your phone is open in too many places. Click to use it here.', () =>
phone.connect()
);
}
});
Sharing One Connection Across Browser Tabs
If your app runs in a browser and users keep it open in several tabs, don't connect a DialStackPhone in every tab. Three tabs would use all of the user's sessions, a fourth would push one out, and the user's mobile app or second browser would be left without a slot.
Connect once per browser instead, and share that connection with every tab:
- One tab is elected leader with the Web Locks API. Only the leader creates the
DialStackPhone, so only the leader holds the connection and plays call audio. Audio plays normally from a background tab. - The leader relays call state to the other tabs over a
BroadcastChannel. Every tab can show the incoming call and an Answer button. - A click in any tab sends a command to the leader, which runs it and reports any failure back to the tab that sent it.
- When the leader tab closes, its lock is released and the next tab takes over and connects. The only interruption is that one reconnect, and calls on the closed tab end with it.
The shared-tab softphone example is a complete, runnable version. Its shared-phone.mjs has no UI and can be copied into your app. At its core, every tab runs this:
const channel = new BroadcastChannel('dialstack-phone');
let phone = null; // set in the leader tab only
// Any tab: run a command in the leader, e.g. send({ action: 'answer', callId }).
function send(command) {
if (phone)
run(command); // this tab is the leader
else channel.postMessage({ type: 'command', command });
}
channel.onmessage = ({ data }) => {
if (phone && data.type === 'command') run(data.command); // leader
if (!phone && data.type === 'state') render(data.state); // every other tab
};
// Every tab asks for the lock, and the browser grants it to one tab at a time.
navigator.locks.request('dialstack-phone', async () => {
phone = new DialStackPhone({ token: await fetchToken(), onTokenExpiring: fetchToken });
phone.on('incoming', track); // track() relays each call's events with publish()
await phone.connect();
// Hold the lock, and with it the connection, until this tab closes.
await new Promise(() => {});
});
// Leader: send the current calls to every tab. A BroadcastChannel does not
// deliver a message back to the tab that sent it, so draw this tab too.
function publish() {
const state = { calls: phone.activeCalls.map(({ id, state, from }) => ({ id, state, from })) };
render(state);
channel.postMessage({ type: 'state', state });
}
The example adds what a real app needs on top of this: an <audio> element per call, status for every connection outcome, reconnecting with backoff and a fresh token, failed commands reported to the tab that sent them, and commands for devices and the emergency address.
A few things to plan for:
- All tabs must share an origin. Web Locks and
BroadcastChannelare scoped to the origin, so tabs on different subdomains each elect their own leader. - Grant microphone access before the first call. The leader is often a background tab, and a browser won't show a permission prompt there. Microphone permission is granted to the whole origin, so ask for it once in whichever tab the user is looking at, for example with
navigator.mediaDevices.getUserMedia({ audio: true })during onboarding. - Show the emergency location prompt in the tab the user is looking at.
network.changedfires only in the leader, which may be in the background. The example putsneedsLocationin the shared state, so every tab can show the prompt and send the new address with asetEmergencyAddresscommand. See Emergency Calling. - Choose devices in the leader. The microphone belongs to the leader's phone (
setAudioInputDevice), and the speaker belongs to the leader's<audio>elements (setSinkId). The example relays both, assetMicrophoneandsetSpeakercommands. See Audio Devices. - Expect a reconnect when the leader tab closes. The next tab connects within a few seconds. A call in progress on the closed tab ends.
Token Refresh
User session tokens default to a 24-hour lifetime (configurable up to 7 days via ttl_seconds). The server enforces the token's expiry for the whole lifetime of the connection: when the token expires it closes the connection with an auth_expired error, so a long-lived connection does not outlive its token.
To avoid that, the SDK fires the onTokenExpiring callback shortly before expiry (about 60 seconds ahead) and delivers the fresh token you return in-band over the existing signalling connection — there is no reconnect and no call disruption. The WebSocket, and any in-progress call, stays open across the token swap.
How often the user has to interactively sign in depends on your identity provider, not DialStack. The session token is minted by your backend on demand, so DialStack inherits whatever session lifetime your IdP enforces. In practice this means:
- While the IdP session is still valid,
onTokenExpiringsilently calls your backend, which mints a fresh DialStack session; the SDK refreshes the connection in place and the user notices nothing. - If the IdP session has expired, your backend's mint call won't happen. The SDK surfaces the failure as an
errorevent (it does not tear down the still-valid connection); handle it by triggering your normal sign-in flow. If the token is never refreshed in time, the server evicts the connection with a fatalauth_expired; the SDK surfaces that and stops — obtain a fresh token and callphone.connect()again to start a new session.
const phone = new DialStackPhone({
token: initialToken,
onTokenExpiring: async () => {
// Fetch a new token from your backend
const { token } = await fetch('/api/dialstack/user-token', {
method: 'POST',
}).then((r) => r.json());
return token;
},
});
Error Handling
phone.on('error', (error) => {
switch (error.code) {
case 'auth_failed':
// Token is invalid — get a new one
break;
case 'auth_expired':
// Token expired. The SDK refreshes in-band via onTokenExpiring before
// this fires; seeing it means the refresh could not be obtained in time
// (e.g. the IdP session ended). Trigger your sign-in flow, then reconnect.
break;
case 'session_replaced':
// A newer session for this user took this one's slot — see Session Limits
break;
case 'rate_limited':
// Too many requests — back off
break;
}
});
What's Next
- Calling & Call Control — Make and receive calls, hold, transfer, DTMF
- Presence — Real-time user status and BLF
- Mobile Integration Guide — The ways to add calling to a mobile app, compared
- Push Notifications — The server side of waking a mobile app for a call
- Emergency Calling (E911) — Address registration and 911 routing for nomadic users
- Network & Troubleshooting — Codecs, firewall configuration, debugging
- Signalling Protocol — Low-level WebSocket message reference
@dialstack/sdk-webrtcreference — Complete TypeScript API