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-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>NoReturn a fresh token. Called whenever the token about to be used is expired or near expiry: before connect(), before every reconnect's authentication, on app resume, and ~60s before expiry on a live connection (applied in-band, no reconnect). If the token has already expired and this fails, nothing is sent: the attempt fails with token_refresh_failed and the next reconnect tries again.
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
// Validate, save, and select an emergency address for the next connection
phone.setEmergencyAddress(address: EmergencyAddressRequest): Promise<EmergencyAddress>;

// Select an address and re-run authentication so it applies to a live phone
phone.reconnectWithEmergency(emergencyAddressId: string): Promise<void>;

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

EmergencyAddressRequest​

PropertyTypeRequiredDescription
address_numberstringNoHouse or building number
streetstringYesStreet name
unitstringNoSuite, floor, apartment or room number
citystringYesCity
statestringYesTwo-letter state or province code
postal_codestringYesZIP or postal code
countrystringNoTwo-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
connected—WebSocket connected and authenticated
disconnectedPhoneError | undefinedThe connection is closed and no reconnect follows. Carries the fatal error, such as session_replaced, session_revoked or auth_expired; undefined after disconnect() or a drop with autoReconnect off.
reconnectingattempt: number, delayMs: numberThe connection dropped and the SDK will retry after delayMs
reconnected—Reconnected after a drop
incomingCallIncoming call
presenceListPresenceEntry[]Initial presence snapshot
presenceUpdatePresenceUpdateUser presence changed
network.changed—Client 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
trying—Server is processing the call
ringing—Remote party is ringing
answered—Call connected
held'local' | 'remote'Call placed on hold
resumed—Call 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' | 'offline';
/** A separate axis from `status`: persistent configuration, not an observed state. */
doNotDisturb: boolean;
statusText: string | null;
updatedAt: string;
}

PresenceUpdate​

TypeScript
interface PresenceUpdate {
userId: string;
status: 'available' | 'on_call' | 'offline';
/** A separate axis from `status`: persistent configuration, not an observed state. */
doNotDisturb: boolean;
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_replaced'
| 'session_revoked'
| 'rate_limited'
| 'call_limit'
| 'presence_unavailable'
| 'reachability_unavailable'
| 'internal_error'
| 'going_away'
| 'idle_timeout'
| 'slow_consumer'
| 'transport_closed'
| 'ice_fetch_failed'
| 'token_refresh_failed'
| 'mic_permission_denied'
| 'audio_device_unavailable'
| 'audio_device_in_use';
message: string;
callId: string | null;
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,
});