JsSIP Warm Transfer with REFER and Replaces Headers - Not Receiving Transfer Complete Event

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.


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


:cross_mark: What’s Missing:

  • After REFER, JsSIP client is not receiving or recognizing a new incoming call or session

  • No newRTCSession is 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] :white_check_mark: Connected to SIP Server’);

socket.ondisconnect = (e) => console.warn(‘[WebSocket] :cross_mark: 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(‘:white_check_mark: Registered’, ‘green’));

userAgent.on(‘unregistered’, () => updateStatus(‘:warning: Unregistered’, ‘orange’));

userAgent.on(‘registrationFailed’, (e) => {

console.error(‘[SIP] :cross_mark: Registration Failed:’, e.cause);

updateStatus(‘:cross_mark: 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] :white_check_mark: Call accepted’));

activeSession.on(‘confirmed’, () => console.log(‘[Call] :counterclockwise_arrows_button: Call confirmed’));

activeSession.on(‘ended’, () => console.log(‘[Call] :cross_mark: Call ended’));

activeSession.on(‘failed’, (e) => console.error(‘[Call] :cross_mark: 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] :headphone: 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] :rocket: Dialing ${target}`);

userAgent.call(target, {

mediaConstraints: { audio: true, video: false }

});

};

const terminateCall = () => {

if (activeSession) {

console.log(‘[Call] :stop_sign: Terminating call…’);

activeSession.terminate();

}

};

const putCallOnHold = () => {

if (activeSession) {

console.log(‘[Call] :pause_button: Putting call on hold’);

activeSession.hold();

}

};

const resumeCallFromHold = () => {

if (activeSession) {

console.log(‘[Call] :play_button: Resuming call from hold’);

activeSession.unhold();

}

};

const acceptIncomingCall = () => {

if (activeSession) {

console.log(‘[Call] :white_check_mark: 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] :studio_microphone: Audio muted’);

}

};

const unmuteAudio = () => {

if (activeSession) {

activeSession.unmute();

console.log(‘[Media] :studio_microphone: Audio unmuted’);

}

};

const sendDTMF = (digit) => {

if (activeSession && activeSession.isEstablished()) {

activeSession.sendDTMF(digit);

console.log(`[DTMF] :pager: Sent DTMF tone: ${digit}`);

} else {

console.warn(‘[DTMF] :cross_mark: Cannot send DTMF, no active call’);

}

};

const blindTransfer = (target) => {

console.log(`[Transfer] :up_right_arrow: Initiating blind transfer to ${target}`);

if (activeSession && activeSession.isEstablished()) {

activeSession.refer(target);

console.log(`[Transfer] :repeat_button: Sent REFER to transfer call to ${target}`);

} else {

console.warn(‘[Transfer] :cross_mark: No active call to transfer’);

}

};

const warmTransfer = () => {

const targetURI = ‘sip:6001@’;

if (!originalSession || !originalSession.isEstablished()) {

console.warn(‘[Transfer] :cross_mark: No active established call to transfer’);

updateStatus(‘:cross_mark: No active call to transfer’, ‘red’);

return;

}

console.log(`[Transfer] :up_right_arrow: Initiating warm transfer to ${targetURI}`);

updateStatus(‘:counterclockwise_arrows_button: Starting warm transfer…’, ‘blue’);

try {

originalSession.hold(); // No .then() here

console.log(‘[Transfer] Original call on hold’);

updateStatus(‘:pause_button: 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] :white_check_mark: Consultation call answered’);

updateStatus(‘:white_check_mark: Consultation connected - ready to transfer’, ‘green’);

document.getElementById(‘completeTransferBtn’).style.display = ‘block’;

    });

consultationSession.on(‘failed’, (e) => {

console.error(‘[Transfer] :cross_mark: Consultation call failed:’, e);

updateStatus(‘:cross_mark: 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] :cross_mark: Transfer initiation failed:’, e);

updateStatus(‘:cross_mark: Transfer failed to start’, ‘red’);

}

};

// Add this function to complete the transfer

const completeWarmTransfer = async () => {

if (!originalSession || !consultationSession) {

console.warn(‘[Transfer] :cross_mark: No active sessions’);

return;

}

// Verify dialog information exists

if (!originalSession._dialog || !originalSession._dialog._id) {

console.error(‘[Transfer] :cross_mark: 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] :cross_mark: 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] :white_check_mark: Transfer initiated successfully’);

} catch (error) {

console.error(‘[Transfer] :cross_mark: 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;

What new session?

From what I can gather you are completing an attended transfer. There wouldn’t be any new call in that scenario after the REFER, they would have already been established before.

The semicolons, and possibly other characters, are not properly escaped. See the examples in RFC 5589.

Thanks for the response. Basically, I am trying to get the warm transfer complete event on the client side, but I am not receiving any event after the warm transfer in the WebRTC client. I need to know whether the warm transfer was successful or not.

const referSubscriber = originalSession.refer(referToUri, {
    extraHeaders: [
        `Referred-By: <${SIP_CONFIG.SIP_URI}>`,
        'X-Transfer-Type: warm'
    ]
});
referSubscriber.on('notify', (notify) => {
    console.log('[Transfer] 📡 Received NOTIFY:', notify.body);
});

I tried to get the notify events like this, but no luck, not receiving the notify events on the client side?
It would be really helpful if you could suggest a way to achieve this.

@david551 I have checked that, seems like all characters are properly escaped.

<``sip:6001@52.66.239.231``?Replaces=r3utmuoj7qlfujip82r5%3Bto-tag%3D3ae02a53-82cd-4445-9617-f8c0a13efc2b%3Bfrom-tag%3Dictp1fd2h3&Referred-By=webrtc_client%{IP_ADDRESS}>

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.