Skip to main content

WebRTC

Build softphone experiences for your users on web, mobile, and desktop using DialStack's WebRTC API.

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:

  1. User authentication — end users authenticate to get a token (Authentication guide)
  2. Signalling — a WebSocket connection at wss://api.dialstack.ai/v1/webrtc handles call setup, control, and presence
  3. 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

Text
┌───────────────────────────────────────────────────────────┐
│ 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 authenticated the user with your own identity provider, mint a DialStack user session token for them. Your backend calls DialStack server-to-server — the user's IdP credentials never leave your infrastructure:

JavaScript
// Your backend
import { DialStack } from '@dialstack/sdk/server';

const dialstack = new DialStack(process.env.DIALSTACK_API_KEY);

const { client_secret: token } = await dialstack.userSessions.create({
user: 'user_01h2xcejqtf2nbrexx3vqjhp42',
// ttl_seconds: 3600, // optional; defaults to 24 hours, max 7 days
});

The returned client_secret is a short-lived JWT scoped to the named user. Hand it to your frontend; never expose your DialStack API key to the client. The user must already be provisioned via POST /v1/users.

2. Connect the softphone

JavaScript
// 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

JavaScript
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

JavaScript
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:

  1. Opens a WebSocket to wss://api.dialstack.ai/v1/webrtc
  2. Sends an authenticate message with the user token
  3. Receives authenticated confirming the session
  4. Sends ping every 30 seconds to keep the connection alive
JavaScript
const phone = new DialStackPhone({ token });

phone.on('connected', () => {
// WebSocket connected and authenticated
});

phone.on('disconnected', () => {
// WebSocket lost — SDK will reconnect automatically
});

phone.on('reconnected', () => {
// Reconnected — active calls are restored
});

await phone.connect();

Reconnection

If the WebSocket connection drops (network change, brief outage), the SDK reconnects automatically with exponential backoff. Active calls survive brief disconnections (up to 30 seconds) — the server preserves call state during the gap.

On reconnection, the SDK:

  1. Authenticates with the current token
  2. Receives call.restored for each active call
  3. Closes the old RTCPeerConnection and creates a new one
  4. Completes a full SDP offer/answer exchange to re-establish media

This adds ~1 second to call recovery but is simpler and more reliable than attempting an ICE restart on the existing connection. The SDK handles this automatically — your code just sees the reconnected event.

Session Limits

A single user can have up to 3 concurrent WebRTC sessions (e.g., laptop, phone, tablet). All connected sessions ring on incoming calls, but only one session at a time can hold an active call — once a call is answered on one device, the others stop ringing and cannot place or answer another call until the active call ends. This avoids the "two calls on two devices" gaming behaviors that the model isn't built for.

If a user attempts a 4th connection, the oldest session is disconnected.

JavaScript
phone.on('error', (error) => {
if (error.code === 'session_limit') {
// User has too many active sessions
showMessage('You are connected on too many devices. Close another session and try again.');
}
});

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, onTokenExpiring silently 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 error event (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 fatal auth_expired; the SDK surfaces that and stops — obtain a fresh token and call phone.connect() again to start a new session.
JavaScript
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

JavaScript
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 'rate_limited':
// Too many requests — back off
break;
}
});

What's Next