Skip to main content

Calling & Call Control

Make and receive phone calls, and control them with hold, transfer, mute, and DTMF.

Making Calls​

Outbound Call Flow​

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

EventDescription
tryingServer is processing the call
ringingRemote party's phone is ringing
answeredCall connected, audio is flowing
heldCall placed on hold
resumedCall resumed from hold
endedCall 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​

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

Multiple devices

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​

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

JavaScript
// 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():

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

JavaScript
call.on('ended', (reason) => {
console.log(reason); // 'hangup'
});

Hold and Resume​

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

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

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

JavaScript
if (call.canSendDtmf) {
call.sendDtmf('1');
}

Blind Transfer​

Transfer the call to another destination immediately:

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

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

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

JavaScript
// Hold current call and answer the waiting call
currentCall.hold();
waitingCall.answer();

// Later, swap back
waitingCall.hold();
currentCall.resume();

Active Calls​

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

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.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)​

JavaScript
// 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 }));
}
};