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.

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':
// No one answered
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);

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':
// Server forwarded Asterisk's 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 }));
}
};