Skip to main content

WebRTC Client SDK

The WebRTC Client SDK provides a high-level interface for building softphone applications. It handles WebSocket signalling, WebRTC media, call management, and presence — so you can focus on your UI.

Installation

Bash
npm install @dialstack/sdk

The WebRTC client is available at @dialstack/sdk/webrtc.

DialStackPhone

The main entry point. Manages the WebSocket connection, calls, and presence for a single user.

Constructor

TypeScript
import { DialStackPhone } from '@dialstack/sdk/webrtc';

const phone = new DialStackPhone(options: PhoneOptions);

PhoneOptions

PropertyTypeRequiredDescription
tokenstringYesUser token from POST /v1/user_sessions
onTokenExpiring() => Promise<string>NoCalled ~60s before token expiry. Return a fresh token; the SDK applies it in-band over the open connection (no reconnect).
autoReconnectbooleanNoReconnect automatically on disconnect (default: true)
iceServersRTCIceServer[]NoOverride ICE server configuration (default: fetched from API)
audioInputDeviceIdstringNoMicrophone deviceId for new calls; defaults to the OS default. Not persisted — see Audio Devices.
audioOutputDeviceIdstringNoSpeaker deviceId for the ringback tone. Does not route call audio — see Audio Devices.

Connection

TypeScript
// Connect to the signalling server
await phone.connect(): Promise<void>;

// Disconnect (ends all active calls)
phone.disconnect(): void;

// Connection state
phone.isConnected: boolean;

Making Calls

TypeScript
// Dial a phone number or extension
const call = await phone.call(
destination: string,
options?: CallOptions
): Promise<Call>;

CallOptions

PropertyTypeDescription
callerIdstringOutbound caller ID (E.164). Must be assigned to the account.

Active Calls

TypeScript
// All active calls (ringing, active, held)
phone.activeCalls: Call[];

// Find a call by ID
phone.getCall(callId: string): Call | undefined;

Presence

TypeScript
// Subscribe to presence updates
phone.subscribePresence(userIds?: string[]): void;

// Set your own presence
await phone.setPresence(
status: 'available' | 'dnd' | 'away',
statusText?: string
): Promise<void>;

Emergency Address

TypeScript
// Set or update emergency address (E911)
await phone.setEmergencyAddress(address: EmergencyAddressRequest): Promise<EmergencyAddress>;

// Get current emergency address
await phone.getEmergencyAddress(): Promise<EmergencyAddress | null>;

EmergencyAddressRequest

PropertyTypeRequiredDescription
streetstringYesStreet address
street2string | nullNoSuite, floor, apartment, room number
citystringYesCity
statestringYesTwo-letter state/province code
zipstringYesZIP/postal code
countrystringYesTwo-letter country code (ISO 3166-1)

Audio Devices

Select which microphone is captured and which speaker the ringback tone plays through. Both apply to calls already in progress, not just new ones.

TypeScript
// `labelsAvailable` is false until the browser has granted microphone access — before
// that it reports how many devices exist but withholds their names.
const { inputs, outputs, labelsAvailable, supported } = await phone.listAudioDevices();

// Pick a microphone. Pass null for the OS default.
await phone.setAudioInputDevice(inputs[0].deviceId);
phone.audioInputDeviceId; // string | null

// Record a saved choice at startup without acquiring the device (no permission prompt).
phone.seedAudioInputDevice(savedId);

// Pick a speaker for the ringback tone.
phone.setAudioOutputDevice(outputs[0].deviceId);
phone.audioOutputDeviceId; // string | null

setAudioInputDevice rejects without changing the selection when the device can't be acquired. Check error.code for mic_permission_denied (re-promptable), audio_device_unavailable (offer a different device), or audio_device_in_use (another application holds the microphone — a different device won't help).

Routing call audio is the host's job. The SDK exposes the remote audio as call.remoteMediaStream; whoever attaches it to a media element is the only code that can route it:

TypeScript
const el = document.querySelector('audio');
el.srcObject = call.remoteMediaStream;
// Feature-detect: TypeScript types setSinkId, but iOS Safari has no implementation.
// A rejection leaves the element on its previous sink, so audio keeps playing — never
// gate play() on this.
if (typeof el.setSinkId === 'function') {
await el.setSinkId(deviceId).catch(() => undefined);
}

The React <Softphone> does all of this for you, including the device picker.

AudioDevice

PropertyTypeDescription
deviceIdstringOpaque id to pass to setAudioInputDevice / setSinkId
labelstringName, or '' when the browser withholds it
groupIdstringShared by the input and output halves of one device
isDefaultbooleanTrue for the host's default entry

Selections are not persisted — the phone holds them for its lifetime, so save the choice yourself and pass it back via PhoneOptions. Device ids are origin-scoped and rotate, so an id that no longer resolves falls back to the default device rather than failing the call.

Events

EventPayloadDescription
connectedWebSocket connected and authenticated
disconnectedWebSocket disconnected
reconnectedReconnected after a drop
incomingCallIncoming call
presenceListPresenceEntry[]Initial presence snapshot
presenceUpdatePresenceUpdateUser presence changed
network.changedClient IP address changed (prompt for E911)
audioDevicesChangedAudioDeviceListA device was plugged/unplugged, or names became readable after permission was granted. Carries the fresh list.
errorPhoneErrorConnection or protocol error
TypeScript
phone.on(event: string, handler: Function): void;
phone.off(event: string, handler?: Function): void;

Call

Represents a single phone call. Obtained from phone.call() or the incoming event.

Properties

PropertyTypeDescription
idstringCall identifier
direction'inbound' | 'outbound'Call direction
state'trying' | 'ringing' | 'active' | 'held' | 'ended'Current call state
fromstringCaller's number (E.164)
fromNamestring | nullCaller's display name
tostringCalled number or extension
isMutedbooleanWhether the microphone is muted
isHeldbooleanWhether the call is on hold
durationnumberCall duration in seconds (updates in real-time)

Methods

TypeScript
// Answer an incoming call
call.answer(): void;

// Reject an incoming call
call.reject(reason?: 'busy' | 'decline'): void;

// Hang up
call.hangup(): void;

// Hold / resume
call.hold(): void;
call.resume(): void;

// Mute / unmute
call.mute(): void;
call.unmute(): void;

// DTMF (sent inline with the audio stream, RFC 4733)
call.sendDtmf(digits: string, duration?: number, interToneGap?: number): void;
call.canSendDtmf: boolean; // false where no DTMF sender exists (e.g. React Native)

// Blind transfer
call.transfer(destination: string): void;

// Attended transfer (returns the consultation call)
call.attendedTransfer(destination: string): Promise<Call>;

// Complete an attended transfer
call.completeTransfer(): void;

// Access the underlying RTCPeerConnection (for stats, custom media handling)
call.peerConnection: RTCPeerConnection;

Events

EventPayloadDescription
tryingServer is processing the call
ringingRemote party is ringing
answeredCall connected
held'local' | 'remote'Call placed on hold
resumedCall resumed from hold
endedstringCall ended (reason: hangup, no-answer, busy, failed, transferred, rejected)
TypeScript
call.on(event: string, handler: Function): void;
call.off(event: string, handler?: Function): void;

Types

PresenceEntry

TypeScript
interface PresenceEntry {
userId: string;
name: string;
status: 'available' | 'on_call' | 'dnd' | 'away' | 'offline';
statusText: string | null;
updatedAt: string;
}

PresenceUpdate

TypeScript
interface PresenceUpdate {
userId: string;
status: 'available' | 'on_call' | 'dnd' | 'away' | 'offline';
statusText: string | null;
updatedAt: string;
}

PhoneError

TypeScript
interface PhoneError {
code:
| 'auth_failed'
| 'auth_expired'
| 'invalid_message'
| 'call_failed'
| 'call_not_found'
| 'emergency_address_required'
| 'session_limit'
| 'session_revoked'
| 'rate_limited'
| 'mic_permission_denied'
| 'audio_device_unavailable'
| 'audio_device_in_use'
| 'internal_error';
message: string;
callId?: string;
fatal: boolean;
}

Server SDK Additions

The following resources are added to the server SDK for WebRTC-related backend operations:

userSessions

Mints a short-lived JWT session token for a single DialStack user. The returned client_secret is the Bearer token your frontend uses on the signalling WebSocket and /v1/me/* REST routes.

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

The user must already be provisioned via POST /v1/users and belong to an account owned by the calling platform. To extend a session, simply call userSessions.create again — there is no refresh endpoint; expiry is enforced by the token's own exp claim.

To cut a user off before their tokens expire (offboarding, a compromised device), revoke all of their sessions at once:

TypeScript
// Invalidate every outstanding session token for a user
const { sessions_revoked_at } = await dialstack.users.revokeSessions(
'user_01h2xcejqtf2nbrexx3vqjhp42'
);

Revoked tokens stop working immediately on REST routes and at connection time; an already-connected phone is disconnected with a fatal session_revoked error the next time it places or receives a call, and the SDK will not auto-reconnect. Minting a new session afterwards restores service.

webrtc

TypeScript
// Get ICE server configuration
const { ice_servers, expires_at } = await dialstack.webrtc.iceServers({
token: userToken,
});