Calling & Call Control
Make and receive phone calls, and control them with hold, transfer, mute, and DTMF.
Making Calls
Outbound Call Flow
// Dial a phone number (E.164 format)
const call = await phone.call('+14155551234');
// Or dial an extension
const call = await phone.call('105');
// Optional: specify caller ID
const call = await phone.call('+14155551234', {
callerId: '+14155559876',
});
The call object emits events as the call progresses:
| Event | Description |
|---|---|
trying | Server is processing the call |
ringing | Remote party's phone is ringing |
answered | Call connected, audio is flowing |
held | Call placed on hold |
resumed | Call resumed from hold |
ended | Call ended |
Ringback Tone
When an outbound call starts ringing, the SDK plays an audible ringback tone so the caller hears that the call is progressing — you don't need to wire up any audio for this. The tone stops automatically when the call is answered or ends.
If the network already sends its own ringing audio (for example, a carrier ringback or a recorded announcement), the SDK detects that incoming audio and suppresses the synthetic tone, so the caller never hears two ringback sounds layered on top of each other.
Because outbound calls are placed in response to a user action (such as clicking "Dial"), the browser permits the tone to play. No extra configuration is required.
Inbound Call Flow
phone.on('incoming', (call) => {
// call.id — call identifier
// call.from — caller's number (E.164)
// call.fromName — caller's display name (if available)
// call.to — called number or extension
// Answer
call.answer();
// Or reject
call.reject(); // silent decline
call.reject('busy'); // send busy signal
});
Ring duration is controlled by the caller's routing (FMFM, dial plan, or upstream PBX). When the upstream gives up, the call ends with reason no-answer.
When a call arrives, it rings all of the user's registered devices simultaneously (SIP desk phones, WebRTC softphones). The first device to answer wins — all others stop ringing.
When the call is answered somewhere else (another of the user's devices, or another member of a ring group or queue), the softphone's ringing call ends with reason no-answer, the same reason as when the caller hangs up or ringing times out. There is no separate "answered elsewhere" reason.
Call Ended Reasons
call.on('ended', (reason) => {
switch (reason) {
case 'hangup':
// Normal hangup by either party
break;
case 'busy':
// Destination is busy
break;
case 'no-answer':
// Not answered. Incoming: ringing timed out, the caller hung up,
// or the call was answered somewhere else
break;
case 'failed':
// Call setup failed (invalid number, network error)
break;
case 'transferred':
// Call was transferred
break;
case 'rejected':
// Remote party rejected the call
break;
}
});
Call Control
Ending and Cancelling Calls
call.hangup() ends a call from your side. It works at every stage of an outbound call, so it's the one method you use both to hang up a connected conversation and to cancel an outbound call that hasn't been answered yet:
// End the call — works whether it's ringing or already answered
call.hangup();
For an inbound call that hasn't been answered, decline it with call.reject() instead — reject() can signal a busy or decline reason, which hangup() cannot.
Cancel a ringing outbound call
To cancel an outbound call that is still ringing (before the remote party picks up), call hangup() on the same call object returned by phone.call():
const call = await phone.call('+14155551234');
// The user changed their mind before the call was answered — cancel it
call.hangup();
You can always just call hangup() — the platform cancels the outbound attempt while the call is still trying or ringing, and hangs up the connected call once it's answered (or held). You don't need to check the state yourself.
In every case the call ends with reason hangup:
call.on('ended', (reason) => {
console.log(reason); // 'hangup'
});
Hold and Resume
// Place the call on hold (remote party hears hold music)
call.hold();
// Resume the call
call.resume();
// Listen for hold events (local or remote hold)
call.on('held', (heldBy) => {
if (heldBy === 'remote') {
console.log('The other party put you on hold');
}
});
call.on('resumed', () => {
console.log('Call resumed');
});
Mute
// Client-side mute (stops sending audio from the microphone)
call.mute();
call.unmute();
// Check mute state
console.log(call.isMuted); // true or false
The SDK both disables the local microphone track and sends a call.mute message to the server. Server-side mute is authoritative — even if the client sends audio, it is not forwarded to the remote party.
DTMF
Send DTMF tones (touch-tones for IVR menus, extensions, etc.) with call.sendDtmf(). Tones are sent inline with the audio stream (RFC 4733 telephone-event RTP payloads):
// Send a sequence of digits (0-9, *, #, A-D)
call.sendDtmf('1234#');
// Optionally tune the per-tone duration and inter-tone gap (milliseconds)
call.sendDtmf('1234#', 100, 70);
DTMF is only meaningful once the call is answered. On platforms whose WebRTC stack doesn't expose a DTMF sender (notably React Native), sending is unavailable — check call.canSendDtmf before offering a keypad, and note that sendDtmf() throws if called when it isn't available:
if (call.canSendDtmf) {
call.sendDtmf('1');
}
Blind Transfer
Transfer the call to another destination immediately:
// Transfer to an extension
call.transfer('105');
// Transfer to a phone number
call.transfer('+14155551234');
// The call ends for you after the transfer
call.on('ended', (reason) => {
console.log(reason); // 'transferred'
});
Attended Transfer
Consult with the transfer target before completing the transfer:
// Step 1: Consult — your current call is held, a new call is placed
const consultCall = await call.attendedTransfer('105');
consultCall.on('answered', () => {
console.log('Consulting with transfer target...');
// Step 2: Complete — connect the original caller to the target
call.completeTransfer();
// Or cancel — hang up the consultation call, original call resumes
// consultCall.hangup();
});
If the consultation call fails (busy, no-answer), the original call is automatically resumed.
Multi-Call Handling
A single WebSocket connection supports multiple simultaneous calls. This enables call waiting, call swap, and conferencing workflows.
Call Waiting
When a second call arrives while you're on a call, the incoming event fires as usual:
phone.on('incoming', (call) => {
if (phone.activeCalls.length > 0) {
// Already on a call — this is call waiting
showCallWaitingUI(call);
} else {
showIncomingCallUI(call);
}
});
Swapping Calls
Hold the current call and answer or resume another:
// Hold current call and answer the waiting call
currentCall.hold();
waitingCall.answer();
// Later, swap back
waitingCall.hold();
currentCall.resume();
Active Calls
// List all active calls
const calls = phone.activeCalls;
// [{ id, from, to, state: 'active' | 'held' | 'ringing', direction }]
// Find a specific call
const call = phone.getCall(callId);
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.
// `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:
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
| Property | Type | Description |
|---|---|---|
deviceId | string | Opaque id to pass to setAudioInputDevice / setSinkId |
label | string | Name, or '' when the browser withholds it |
groupId | string | Shared by the input and output halves of one device |
isDefault | boolean | True 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.audioInputDeviceId and audioOutputDeviceId. 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.
Direct WebSocket Usage
If you're not using the SDK, you can interact with the signalling protocol directly. See the Signalling Protocol reference for the complete message specification.
Making a Call (Raw WebSocket)
// 1. Create the peer connection and generate an SDP offer
const pc = new RTCPeerConnection({ iceServers });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 2. Initiate the call with the offer in-band
ws.send(
JSON.stringify({
type: 'call.create',
req_id: 'my-call-1',
destination: '+14155551234',
sdp: offer.sdp,
})
);
// 3. Handle server messages
ws.onmessage = async (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'call.trying':
// Server assigned a call_id
const callId = msg.call_id;
break;
case 'sdp.answer':
// The answer to our offer
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
break;
case 'ice.candidate':
await pc.addIceCandidate({
candidate: msg.candidate,
sdpMid: msg.sdp_mid,
sdpMLineIndex: msg.sdp_m_line_index,
});
break;
case 'call.ringing':
// Play ringback tone
break;
case 'call.answered':
// Call connected — audio is flowing
break;
case 'call.ended':
pc.close();
break;
}
};
// 4. Send local ICE candidates
pc.onicecandidate = (event) => {
if (event.candidate) {
ws.send(
JSON.stringify({
type: 'ice.candidate',
call_id: callId,
candidate: event.candidate.candidate,
sdp_mid: event.candidate.sdpMid,
sdp_m_line_index: event.candidate.sdpMLineIndex,
})
);
} else {
ws.send(JSON.stringify({ type: 'ice.done', call_id: callId }));
}
};