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 |
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.
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':
// 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
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
DTMF tones are sent inline with the audio stream using the browser's
built-in RTCDTMFSender
API (RFC 4733 telephone-event RTP payloads). The SDK does not
expose a separate DTMF method; use RTCDTMFSender.insertDTMF() on
the audio sender of the underlying RTCPeerConnection:
const sender = call.peerConnection.getSenders().find((s) => s.track && s.track.kind === 'audio');
sender.dtmf.insertDTMF('1234#');
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);
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':
// 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 }));
}
};