I’m implementing warm transfer using JsSIP 3.10.0 with Asterisk 20.9.0 as the SIP server.
The warm transfer REFER is sent successfully from the WebRTC client, Asterisk responds with 202 Accepted, and I also see NOTIFY events from Asterisk with SIP/2.0 100 Trying and SIP/2.0 200 OK. However, on the WebRTC client, the transfer does not complete, and I’m not receiving any transfer completion event (e.g., no new session created or handled after REFER).
Refer-To: <sip:6001@13.201.73.27?Replaces=callid;to-tag=xyz;from-tag=abc&Referred-By=webrtc_client%4013.201.73.27>
SIP/2.0 202 Accepted
NOTIFY with 100 Trying
NOTIFY with 200 OK
BYE issued for consultation call
Asterisk shows bridges being swapped and calls ending, but no new incoming call triggered on the original WebRTC client after REFER.
What’s Working:
-
JsSIP client successfully initiates and accepts calls
-
Consultation call works
-
REFER with Replaces header is constructed correctly
-
Asterisk processes REFER, replies with 202, and sends NOTIFYs
What’s Missing:
-
After REFER, JsSIP client is not receiving or recognizing a new incoming call or session
-
No
newRTCSessionis triggered for the final leg of the transfer
Key relevant code from the client:
``originalSession.refer(referToUri, {
extraHeaders: [
‘Referred-By: <sip:webrtc_client@13.201.73.27>’,
‘X-Transfer-Type: warm’
]
});
``
referToUri is built like this:
``
const replaces = ${call_id};to-tag=${remote_tag};from-tag=${local_tag};
const referToUri = ${targetUri}?Replaces=${encodeURIComponent(replaces)}&Referred-By=${encodeURIComponent(SIP_CONFIG.SIP_URI)};
```
I’m using newRTCSession to catch incoming calls, and it works for all other calls except this REFER scenario.
How can I get JsSIP to properly handle the final leg of a warm transfer using REFER with Replaces? Is there something else required for JsSIP to recognize the new session triggered by REFER?
My main.js is adding below
//working WT code
const SIP_CONFIG = {
WS_SERVER: ‘wss://>/ws’,
SIP_URI: ‘webrtc_client@’,
PASSWORD: ‘webrtc_client’,
REGISTER_EXPIRES: 120
};
const socket = new JsSIP.WebSocketInterface(SIP_CONFIG.WS_SERVER);
// WebSocket Debug
socket.onconnect = () => console.log(‘[WebSocket]
Connected to SIP Server’);
socket.ondisconnect = (e) => console.warn(‘[WebSocket]
Disconnected from SIP Server’, e);
const userAgent = new JsSIP.UA({
sockets: [socket],
uri: SIP_CONFIG.SIP_URI,
password: SIP_CONFIG.PASSWORD,
register_expires: SIP_CONFIG.REGISTER_EXPIRES,
// Add these settings:
session_timers: true,
no_answer_timeout: 60,
rel100: ‘supported’,
// Important for NOTIFY handling:
hack_via_tcp: true,
hack_via_ws: true,
use_preloaded_route: true
});
userAgent.start();
// ======================
// Application State
// ======================
let activeSession = null;
let newSession = null;
let consultationSession = null; // Track the consultation call
let originalSession = null;
// ======================
// Event Handlers
// ======================
userAgent.on(‘registered’, () => updateStatus(‘
Registered’, ‘green’));
userAgent.on(‘unregistered’, () => updateStatus(‘
Unregistered’, ‘orange’));
userAgent.on(‘registrationFailed’, (e) => {
console.error(‘[SIP]
Registration Failed:’, e.cause);
updateStatus(‘
Registration Failed’, ‘red’);
});
const allEvents = [
‘connected’,
‘disconnected’,
‘registered’,
‘unregistered’,
‘registrationFailed’,
‘newRTCSession’,
‘newMessage’,
‘sipEvent’,
‘transportCreated’
];
// Register all UA-level events
allEvents.forEach(eventName => {
userAgent.on(eventName, (event) => {
console.log(`[JsSIP UA Event] ${eventName}:`, event);
});
});
userAgent.on(‘newRTCSession’, (event) => {
activeSession = event.session;
console.log(‘[SIP] New RTC session:’, {
direction: activeSession.direction,
id: activeSession.id,
originator: event.originator
});
if (activeSession.direction === ‘outgoing’ &&
(!originalSession || originalSession.isEnded())) {
originalSession = activeSession;
console.log(‘[SIP] Set as original session’, {
callId: originalSession._dialog?._id.call_id,
direction: originalSession.direction
});
}
// Add dialog update handler
activeSession.on(‘accepted’, () => {
console.log(‘[Call] Dialog identifiers:’, {
callId: activeSession._dialog._id.call_id,
localTag: activeSession._dialog._id.local_tag,
remoteTag: activeSession._dialog._id.remote_tag
});
});
// .
const isIncoming = activeSession.direction === ‘incoming’;
const callType = isIncoming ? ‘Incoming’ : ‘Outgoing’;
updateStatus(`📞 ${callType} Call`, ‘blue’);
// Log SDP exchange
activeSession.on(‘sdp’, (data) => {
console.log(`[SDP] SDP ${data.originator}:`, data.sdp);
});
// Log call state
activeSession.on(‘accepted’, () => console.log(‘[Call]
Call accepted’));
activeSession.on(‘confirmed’, () => console.log(‘[Call]
Call confirmed’));
activeSession.on(‘ended’, () => console.log(‘[Call]
Call ended’));
activeSession.on(‘failed’, (e) => console.error(‘[Call]
Call failed:’, e.cause));
// ICE & Peer Connection Logs
activeSession.on(‘peerconnection’, (data) => {
console.log(‘[PeerConnection] Created:’, data.peerconnection);
});
handleMediaStream();
});
// ======================
// Media Setup
// ======================
const handleMediaStream = () => {
if (!activeSession || !activeSession.connection) return;
activeSession.connection.ontrack = (event) => {
const remoteStream = new MediaStream();
remoteStream.addTrack(event.track);
console.log(‘[Media]
Remote Track Received:’, {
track: event.track.kind,
id: activeSession.id,
direction: activeSession.direction
});
document.getElementById(‘remoteAudio’).srcObject = remoteStream;
console.log(‘[Debug] activeSession._dialog:’, activeSession._dialog && activeSession._dialog._id);
};
};
// ======================
// CALL CONTROL FUNCTIONS
// ======================
const initiateCall = (target) => {
console.log(`[Call]
Dialing ${target}`);
userAgent.call(target, {
mediaConstraints: { audio: true, video: false }
});
};
const terminateCall = () => {
if (activeSession) {
console.log(‘[Call]
Terminating call…’);
activeSession.terminate();
}
};
const putCallOnHold = () => {
if (activeSession) {
console.log(‘[Call]
Putting call on hold’);
activeSession.hold();
}
};
const resumeCallFromHold = () => {
if (activeSession) {
console.log(‘[Call]
Resuming call from hold’);
activeSession.unhold();
}
};
const acceptIncomingCall = () => {
if (activeSession) {
console.log(‘[Call]
Accepting incoming call’);
activeSession.answer();
}
};
// ======================
// UI FUNCTIONS
// ======================
const updateStatus = (message, color = ‘green’) => {
console.log(‘[UI] Status Update:’, message);
const statusElement = document.getElementById(‘status’);
if (statusElement) {
statusElement.textContent = message;
statusElement.style.color = color;
}
};
const handleCallInitiation = () => {
const targetInput = document.getElementById(‘targetNumber’);
const target = targetInput.value.trim();
if (target) {
initiateCall(target);
} else {
alert(‘Please enter a valid SIP URI or number’);
}
};
const muteAudio = () => {
if (activeSession) {
activeSession.mute();
console.log(‘[Media]
Audio muted’);
}
};
const unmuteAudio = () => {
if (activeSession) {
activeSession.unmute();
console.log(‘[Media]
Audio unmuted’);
}
};
const sendDTMF = (digit) => {
if (activeSession && activeSession.isEstablished()) {
activeSession.sendDTMF(digit);
console.log(`[DTMF]
Sent DTMF tone: ${digit}`);
} else {
console.warn(‘[DTMF]
Cannot send DTMF, no active call’);
}
};
const blindTransfer = (target) => {
console.log(`[Transfer]
Initiating blind transfer to ${target}`);
if (activeSession && activeSession.isEstablished()) {
activeSession.refer(target);
console.log(`[Transfer]
Sent REFER to transfer call to ${target}`);
} else {
console.warn(‘[Transfer]
No active call to transfer’);
}
};
const warmTransfer = () => {
const targetURI = ‘sip:6001@’;
if (!originalSession || !originalSession.isEstablished()) {
console.warn(‘[Transfer]
No active established call to transfer’);
updateStatus(‘
No active call to transfer’, ‘red’);
return;
}
console.log(`[Transfer]
Initiating warm transfer to ${targetURI}`);
updateStatus(‘
Starting warm transfer…’, ‘blue’);
try {
originalSession.hold(); // No .then() here
console.log(‘[Transfer] Original call on hold’);
updateStatus(‘
Original call held - dialing transfer target’, ‘blue’);
// Proceed with consultation call
consultationSession = userAgent.call(targetURI, {
mediaConstraints: { audio: true, video: false },
sessionTimersExpires: 120
});
console.log(‘[Debug] consultationSession._dialog:’, consultationSession && consultationSession._dialog && consultationSession._dialog._id);
// Consultation handlers…
consultationSession.on(‘confirmed’, () => {
console.log(‘[Transfer]
Consultation call answered’);
updateStatus(‘
Consultation connected - ready to transfer’, ‘green’);
document.getElementById(‘completeTransferBtn’).style.display = ‘block’;
});
consultationSession.on(‘failed’, (e) => {
console.error(‘[Transfer]
Consultation call failed:’, e);
updateStatus(‘
Consultation failed’, ‘red’);
try {
originalSession.unhold(); // Put original call back
} catch (e) {
console.error(‘Failed to unhold:’, e);
}
});
consultationSession.on(‘ended’, () => {
console.log(‘[Transfer] Consultation call ended’);
document.getElementById(‘completeTransferBtn’).style.display = ‘none’;
try {
if (activeSession.isOnHold()) {
console.log(‘[Transfer] Unholding original call after consultation ended’);
originalSession.unhold();
}
} catch (e) {
console.error(‘Failed to unhold:’, e);
}
});
} catch (e) {
console.error(‘[Transfer]
Transfer initiation failed:’, e);
updateStatus(‘
Transfer failed to start’, ‘red’);
}
};
// Add this function to complete the transfer
const completeWarmTransfer = async () => {
if (!originalSession || !consultationSession) {
console.warn(‘[Transfer]
No active sessions’);
return;
}
// Verify dialog information exists
if (!originalSession._dialog || !originalSession._dialog._id) {
console.error(‘[Transfer]
Original call missing dialog info’);
return;
}
const { call_id, local_tag, remote_tag } = consultationSession._dialog._id;
if (!call_id || !local_tag || !remote_tag) {
console.error(‘[Transfer]
Missing dialog identifiers’, {
callId: call_id,
fromTag: local_tag,
toTag: remote_tag
});
return;
}
// Build Replaces header with proper encoding
const replacesValue = `${call_id};to-tag=${remote_tag};from-tag=${local_tag}`;
// const replacesValue = `${call_id};to-tag=${local_tag};from-tag=${remote_tag}`;
const targetUri = consultationSession.remote_contact?.uri.toString() ||
consultationSession.remote_identity?.uri.toString();
const referToUri = `${targetUri}?Replaces=${encodeURIComponent(replacesValue)}` +
`&Referred-By=${encodeURIComponent(SIP_CONFIG.SIP_URI)}`;
console.log(‘[Transfer] Preparing to complete warm transfer to:’, referToUri);
console.log(‘[Transfer] Using identifiers:’, {
callId: call_id,
fromTag: local_tag,
toTag: remote_tag
});
try {
// Ensure original call is active
if (originalSession.isOnHold()) {
await originalSession.unhold();
}
// Send REFER with proper headers
originalSession.refer(referToUri, {
extraHeaders: [
`Referred-By: <${SIP_CONFIG.SIP_URI}>`,
‘X-Transfer-Type: warm’
\]
});
console.log(‘[Transfer]
Transfer initiated successfully’);
} catch (error) {
console.error(‘[Transfer]
Transfer failed:’, error);
updateStatus(‘Transfer failed’, ‘red’);
}
};
const cleanupAfterTransfer = () => {
// Hide complete transfer button
document.getElementById(‘completeTransferBtn’).style.display = ‘none’;
// Clean up session references
consultationSession = null;
// Optional: Unhold if still on hold
if (activeSession.isOnHold()) {
activeSession.unhold().catch(e => console.error(‘Unhold failed:’, e));
}
};
// ======================
// FUNCTION EXPORTS
// ======================
window.startCallFromUI = handleCallInitiation;
window.answerCall = acceptIncomingCall;
window.endCall = terminateCall;
window.holdCall = putCallOnHold;
window.unHoldCall = resumeCallFromHold;
window.muteAudio = muteAudio;
window.unmuteAudio = unmuteAudio;