Warm Transfer with C# and ASP.NET MVC

January 10, 2017
Written by
Samuel Mendes
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Paul Kamp
Twilion
Kat King
Twilion

warm-transfer-csharp

Have you ever been disconnected from a support call while being transferred to someone else?

Warm transfer eliminates this problem. Using Twilio powered warm transfers your agents will have the ability to dial in another agent in real-time.

Here is how it works at a high level:

  1. The first agent becomes available when he/she connects through the web client.
  2. The second agent also becomes available when he/she connects through the web client.
  3. A client calls our support line.
  4. The client stays on hold while the first agent joins the call.
  5. While the first agent is on the phone with the client, he/she can dial a second agent into the call.
  6. Once the second agent is on the call, the first one can disconnect from it. The client and the second agent stay on the call.

Let's get started! Clone the sample application from Github.

Setting Up Voice Web Hook

First let's configure the voice webhook for the Twilio number that customers will dial when they want to talk to a support agent. 

Twilio Console for Warm Transfer

This action method is the one that ASP.NET framework will generate to the public-facing URL.

Editor: this is a migrated tutorial. Clone the original code from https://github.com/TwilioDevEd/warm-transfer-csharp/

using System.Threading.Tasks;
using System.Web.Mvc;
using Twilio.AspNet.Mvc;
using WarmTransfer.Web.Domain;
using WarmTransfer.Web.Models;
using WarmTransfer.Web.Models.Repository;

namespace WarmTransfer.Web.Controllers
{
    public class ConferenceController : TwilioController
    {
        private readonly ICallCreator _callCreator;
        private readonly ICallsRepository _callsRepository;
        private const string OriginHeader = "Origin";
        public static string WaitUrl = "http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical";

        public ConferenceController() : this(
            new CallCreator(),
            new CallsRepository(new WarmTransferContext())) { }

        public ConferenceController(
            ICallCreator callCreator, ICallsRepository callsRepository)
        {
            _callCreator = callCreator;
            _callsRepository = callsRepository;
        }

        [HttpPost]
        public async Task<TwiMLResult> ConnectClient(string callSid)
        {
            const string agentOne = "agent1";
            var conferenceId = callSid;
            var callBackUrl = GetConnectConfereceUrlForAgent(agentOne, conferenceId);
            await _callCreator.CallAgentAsync(agentOne, callBackUrl);
            await _callsRepository.CreateOrUpdateAsync(agentOne, conferenceId);
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, false, true);

            return TwiML(response);
        }

        [HttpPost]
        public TwiMLResult Wait()
        {
            return TwiML(TwiMLGenerator.GenerateWait());
        }

        [HttpPost]
        public TwiMLResult ConnectAgent1(string conferenceId)
        {
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, true, false);

            return TwiML(response);
        }

        [HttpPost]
        public TwiMLResult ConnectAgent2(string conferenceId)
        {
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, true, true);

            return TwiML(response);
        }

        [HttpPost]
        public async Task<ActionResult> CallAgent2(string agentId)
        {
            var call = await _callsRepository.FindByAgentIdAsync(agentId);
            var callBackUrl = GetConnectConfereceUrlForAgent(agentId, call.ConferenceId);
            await _callCreator.CallAgentAsync("agent2", callBackUrl);

            return new EmptyResult();
        }

        private string GetConnectConfereceUrlForAgent(string agentId, string conferenceId) {
            var action = agentId == "agent1" ? "ConnectAgent1" : "ConnectAgent2";
            var origin = Request.Headers[OriginHeader];
            var url = $"https://{origin}{Url.Action(action, new {conferenceId})}";

            return url;
        }
    }
}

Awesome, now you've got a webhook in place.  Next up, we'll look at some of the code.

Connect an Agent

Here you can see all front-end code necessary to connect an agent using Twilio's Voice Web Client.

We essentially need three things to have a live web client:

  • A capability token (provided by our ASP.NET MVC app)
  • A unique identifier (string) for each agent
  • Event listeners to handle different Twilio-triggered events
$(function () {
    var currentAgentId;
    var currentConnection;
    var $callStatus = $('#call-status');
    var $connectAgent1Button = $("#connect-agent1-button");
    var $connectAgent2Button = $("#connect-agent2-button");

    var $answerCallButton = $("#answer-call-button");
    var $hangupCallButton = $("#hangup-call-button");
    var $dialAgent2Button = $("#dial-agent2-button");

    $connectAgent1Button.on('click', { agentId: 'agent1' }, agentClickHandler);
    $connectAgent2Button.on('click', { agentId: 'agent2' }, agentClickHandler);
    $hangupCallButton.on('click', hangUp);
    $dialAgent2Button.on('click', dialAgent2);

    function fetchToken(agentId) {
        $.post('/token/generate', { agentId: agentId }, function (data) {
            currentAgentId = data.agentId;
            connectClient(data.token)
        }, 'json');
    }

    function connectClient(token) {
        Twilio.Device.setup(token);
    }

    Twilio.Device.ready(function (device) {
        updateCallStatus("Ready");
        agentConnectedHandler(device._clientName);
    });

    // Callback for when Twilio Client receives a new incoming call
    Twilio.Device.incoming(function (connection) {
        currentConnection = connection;
        updateCallStatus("Incoming support call");

        // Set a callback to be executed when the connection is accepted
        connection.accept(function () {
            updateCallStatus("In call with customer");
            $answerCallButton.prop('disabled', true);
            $hangupCallButton.prop('disabled', false);
            $dialAgent2Button.prop('disabled', false);
        });

        // Set a callback on the answer button and enable it
        $answerCallButton.click(function () {
            connection.accept();
        });
        $answerCallButton.prop('disabled', false);
    });

    /* Report any errors to the call status display */
    Twilio.Device.error(function (error) {
        updateCallStatus("ERROR: " + error.message);
        disableConnectButtons(false);
    });

    // Callback for when the call finalizes
    Twilio.Device.disconnect(function (connection) {
        callEndedHandler(connection.device._clientName);
    });

    function dialAgent2() {
        $.post('/conference/CallAgent2', { agentId: currentAgentId })
    }

    /* End a call */
    function hangUp() {
        Twilio.Device.disconnectAll();
    }

    function agentClickHandler(e) {
        var agentId = e.data.agentId;
        disableConnectButtons(true);
        fetchToken(agentId);
    }

    function agentConnectedHandler(agentId) {
        $('#connect-agent-row').addClass('hidden');
        $('#connected-agent-row').removeClass('hidden');
        updateCallStatus("Connected as: " + agentId);

        if (agentId === 'agent1') {
            $dialAgent2Button.removeClass('hidden').prop('disabled', true);
        }
        else {
            $dialAgent2Button.addClass('hidden')
        }
    }

    function callEndedHandler(agentId) {
        $dialAgent2Button.prop('disabled', true);
        $hangupCallButton.prop('disabled', true);
        $answerCallButton.prop('disabled', true)
        updateCallStatus("Connected as: " + agentId);
    }

    function disableConnectButtons(disable) {
        $connectAgent1Button.prop('disabled', disable);
        $connectAgent2Button.prop('disabled', disable);
    }

    function updateCallStatus(status) {
        $callStatus.text(status);
    }
});

In the next step we'll take a closer look at capability token generation.

Generating a Capability Token

In order to connect the Twilio Voice Web Client we need a capability token.

To allow incoming connections through the web client an identifier must be provided when generating the token.

using System.Collections.Generic;
using Twilio.Jwt;
using Twilio.Jwt.Client;

namespace WarmTransfer.Web.Domain
{
    public class CapabilityGenerator
    {
        public static string Generate(string agentId)
        {
            var scopes = new HashSet<IScope>
            {
                { new IncomingClientScope(agentId) }
            };
            var clientCapibility = new ClientCapability(Config.AccountSid, Config.AuthToken, scopes: scopes);
            return clientCapibility.ToJwt();
        }
    }
}

Next up, let's see how to handle incoming calls.

Handle Incoming Calls

For this tutorial we used fixed identifier strings like agent1 and agent2 but you can use any generated strings for your call center clients. These identifiers will be used to create outbound calls to the specified agent using the Twilio REST API.

When a client makes a call to our Twilio number the application receives a POST request asking for instructions. We'll use TwiML to instruct the client to join a conference room and the Twilio REST API client to start a call with the first agent, this way he will be able to join the same conference.

When providing instructions to the client, we also provide a waitUrl. This URL is another end point of our application, it returns more TwiML to say welcome to the user and also play some music while on hold. Take a look at the GenerateWait method.

We use the client's callSid as the conference identifier. Since all the participants need this identifier to join the conference we'll need to store it in a database so that we can grab it when we dial the second agent in.

using System.Threading.Tasks;
using System.Web.Mvc;
using Twilio.AspNet.Mvc;
using WarmTransfer.Web.Domain;
using WarmTransfer.Web.Models;
using WarmTransfer.Web.Models.Repository;

namespace WarmTransfer.Web.Controllers
{
    public class ConferenceController : TwilioController
    {
        private readonly ICallCreator _callCreator;
        private readonly ICallsRepository _callsRepository;
        private const string OriginHeader = "Origin";
        public static string WaitUrl = "http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical";

        public ConferenceController() : this(
            new CallCreator(),
            new CallsRepository(new WarmTransferContext())) { }

        public ConferenceController(
            ICallCreator callCreator, ICallsRepository callsRepository)
        {
            _callCreator = callCreator;
            _callsRepository = callsRepository;
        }

        [HttpPost]
        public async Task<TwiMLResult> ConnectClient(string callSid)
        {
            const string agentOne = "agent1";
            var conferenceId = callSid;
            var callBackUrl = GetConnectConfereceUrlForAgent(agentOne, conferenceId);
            await _callCreator.CallAgentAsync(agentOne, callBackUrl);
            await _callsRepository.CreateOrUpdateAsync(agentOne, conferenceId);
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, false, true);

            return TwiML(response);
        }

        [HttpPost]
        public TwiMLResult Wait()
        {
            return TwiML(TwiMLGenerator.GenerateWait());
        }

        [HttpPost]
        public TwiMLResult ConnectAgent1(string conferenceId)
        {
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, true, false);

            return TwiML(response);
        }

        [HttpPost]
        public TwiMLResult ConnectAgent2(string conferenceId)
        {
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, true, true);

            return TwiML(response);
        }

        [HttpPost]
        public async Task<ActionResult> CallAgent2(string agentId)
        {
            var call = await _callsRepository.FindByAgentIdAsync(agentId);
            var callBackUrl = GetConnectConfereceUrlForAgent(agentId, call.ConferenceId);
            await _callCreator.CallAgentAsync("agent2", callBackUrl);

            return new EmptyResult();
        }

        private string GetConnectConfereceUrlForAgent(string agentId, string conferenceId) {
            var action = agentId == "agent1" ? "ConnectAgent1" : "ConnectAgent2";
            var origin = Request.Headers[OriginHeader];
            var url = $"https://{origin}{Url.Action(action, new {conferenceId})}";

            return url;
        }
    }
}

Now let's see how to provide TwiML instructions to the client.

Provide TwiML Instruction For The Client

Here we create a TwiMLResponse that will contain a Dial verb with a Conference noun that will instruct the client to join a specific conference room.

using Twilio.TwiML;

namespace WarmTransfer.Web.Domain
{

    public static class TwiMLGenerator
    {
        public static VoiceResponse GenerateConnectConference(string conferenceId, 
                                                       string waitUrl, 
                                                       bool startConferenceOnEnter, 
                                                       bool endConferenceOnExit)
        {
            var response = new VoiceResponse();
            var conference = new Dial().Conference(
                conferenceId, 
                waitUrl: waitUrl, 
                startConferenceOnEnter: startConferenceOnEnter, 
                endConferenceOnExit: endConferenceOnExit);

            return response.Dial(conference);
        }

        public static VoiceResponse GenerateWait()
        {
            return new VoiceResponse()
                .Say("Thank you for calling. Please wait in line for a few seconds. " +
                     "An agent will be with you shortly.")
                .Play("http://com.twilio.music.classical.s3.amazonaws.com/BusyStrings.mp3");
        }
    }
}

Next up, we will look at how to dial our first agent into the call.

Dial First Agent Into the Call

For our app we created a CallCreator class to handle dialing our agents. This class uses Twilio's REST API to create a new call. The InitiateOutboundCall method receives the following parameters:

  1. from: Your Twilio number
  2. to : The agent web client's identifier (agent1 or agent2)
  3. url : A URL to ask for TwiML instructions when the call connects

Once the agent answers the call in the web client, a request is made to the callback URL instructing the agent's call to join the conference room where the client is already waiting.

using System;
using System.Threading.Tasks;
using Twilio;
using Twilio.Clients;
using Twilio.Rest.Api.V2010.Account;
using Twilio.Types;

namespace WarmTransfer.Web.Domain
{
    public interface ICallCreator
    {
        Task CallAgentAsync(string agentId, string callbackUrl);
    }

    public class CallCreator : ICallCreator
    {
        
        public CallCreator()
        {
            TwilioClient.Init(Config.AccountSid, Config.AuthToken);
        }

        public CallCreator(ITwilioRestClient restClient) : this()
        {
            TwilioClient.SetRestClient(restClient);
        }

        public async Task CallAgentAsync(string agentId, string callbackUrl)
        {
            var to = new PhoneNumber($"client:{agentId}");
            var from = new PhoneNumber(Config.TwilioPhoneNumber);
            await CallResource.CreateAsync(to, from, url: new Uri(callbackUrl));
        }
    }
}

With that in mind, let's see how to add the second agent to the call.

Dial Second Agent Into the Call

When the client and the first agent are both in the call, we are ready to perform a warm transfer to a second agent. The first agent makes a request passing its identifier that allows us to look for the call.ConferenceId needed to dial the second agent in. Since we already have a CallCreator class we can simply use the CallAgent method to connect the second agent in.

using System.Threading.Tasks;
using System.Web.Mvc;
using Twilio.AspNet.Mvc;
using WarmTransfer.Web.Domain;
using WarmTransfer.Web.Models;
using WarmTransfer.Web.Models.Repository;

namespace WarmTransfer.Web.Controllers
{
    public class ConferenceController : TwilioController
    {
        private readonly ICallCreator _callCreator;
        private readonly ICallsRepository _callsRepository;
        private const string OriginHeader = "Origin";
        public static string WaitUrl = "http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical";

        public ConferenceController() : this(
            new CallCreator(),
            new CallsRepository(new WarmTransferContext())) { }

        public ConferenceController(
            ICallCreator callCreator, ICallsRepository callsRepository)
        {
            _callCreator = callCreator;
            _callsRepository = callsRepository;
        }

        [HttpPost]
        public async Task<TwiMLResult> ConnectClient(string callSid)
        {
            const string agentOne = "agent1";
            var conferenceId = callSid;
            var callBackUrl = GetConnectConfereceUrlForAgent(agentOne, conferenceId);
            await _callCreator.CallAgentAsync(agentOne, callBackUrl);
            await _callsRepository.CreateOrUpdateAsync(agentOne, conferenceId);
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, false, true);

            return TwiML(response);
        }

        [HttpPost]
        public TwiMLResult Wait()
        {
            return TwiML(TwiMLGenerator.GenerateWait());
        }

        [HttpPost]
        public TwiMLResult ConnectAgent1(string conferenceId)
        {
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, true, false);

            return TwiML(response);
        }

        [HttpPost]
        public TwiMLResult ConnectAgent2(string conferenceId)
        {
            var response = TwiMLGenerator.GenerateConnectConference(conferenceId, WaitUrl, true, true);

            return TwiML(response);
        }

        [HttpPost]
        public async Task<ActionResult> CallAgent2(string agentId)
        {
            var call = await _callsRepository.FindByAgentIdAsync(agentId);
            var callBackUrl = GetConnectConfereceUrlForAgent(agentId, call.ConferenceId);
            await _callCreator.CallAgentAsync("agent2", callBackUrl);

            return new EmptyResult();
        }

        private string GetConnectConfereceUrlForAgent(string agentId, string conferenceId) {
            var action = agentId == "agent1" ? "ConnectAgent1" : "ConnectAgent2";
            var origin = Request.Headers[OriginHeader];
            var url = $"https://{origin}{Url.Action(action, new {conferenceId})}";

            return url;
        }
    }
}

Next up, we'll look at how to handle the first agent leaving the call.

The First Agent Leaves the Call

When the three participants have joined the same call, the first agent has served his purpose. Now he can drop the call, leaving agent two and the client to have a pleasant conversation.

It is important to notice the differences between the TwiML each one of the participants received when joining the call. Both, agent one and two, have startConferenceOnEnter set to true. This means the conference will start when any of them joins the call. For the client calling and for agent two, endConferenceOnExit is set to true. This causes the call to end when either one of these two participants drops the call.

using Twilio.TwiML;

namespace WarmTransfer.Web.Domain
{

    public static class TwiMLGenerator
    {
        public static VoiceResponse GenerateConnectConference(string conferenceId, 
                                                       string waitUrl, 
                                                       bool startConferenceOnEnter, 
                                                       bool endConferenceOnExit)
        {
            var response = new VoiceResponse();
            var conference = new Dial().Conference(
                conferenceId, 
                waitUrl: waitUrl, 
                startConferenceOnEnter: startConferenceOnEnter, 
                endConferenceOnExit: endConferenceOnExit);

            return response.Dial(conference);
        }

        public static VoiceResponse GenerateWait()
        {
            return new VoiceResponse()
                .Say("Thank you for calling. Please wait in line for a few seconds. " +
                     "An agent will be with you shortly.")
                .Play("http://com.twilio.music.classical.s3.amazonaws.com/BusyStrings.mp3");
        }
    }
}

That's it! We have just implemented warm transfers using ASP.NET MVC. Now your clients won't get disconnect from support calls while they are been transferred to some else.

Where to next?

If you're a ASP.NET MVC developer working with Twilio, you might also enjoy these tutorials:

Browser-Calls

Learn how to use Twilio Client to make browser-to-phone and browser-to-browser calls with ease.

ETA-Notifications

Learn how to implement ETA Notifications using ASP.NET MVC and Twilio.

Did this help?

Thanks for checking this tutorial out! Let us know on Twitter what you've built... or what you're building.