Handle No-Answer Scenarios with Voicemail and Callback
Niels Swimberghe - - .NET
Follow me on Twitter, buy me a coffee
This blog post was written for Twilio and originally published at Twilio's blog here.
Have you ever called a company but instead of being connected to a representative, you were told no one was available and the call abruptly ended? Have you ever had to stay on the phone waiting for hours until a customer representative finally is able to take your call?
Unfortunately, those bad user experiences are too common and is why people hate calling businesses, but it doesn't have to be this way. Using Twilio Programmable Voice, you can build a better experience! Even if there's nobody available to take the call right now, you could ask them to leave a message and their phone number so you can give them a callback later on.
Prerequisites #
This tutorial is for developers at any experience level. Prior experience with the following technologies is recommended, but not required:
- C#
- .NET
- ASP.NET Core
You’ll need the following development resources to build and run the project:
- .NET 5 SDK (other versions of .NET Core may work too)
- A code editor – Any editor will suffice, but for an optimal experience use:
- Visual Studio Code with the C# for Visual Studio Code extension.
– or –
- Visual Studio 2019 (The Community edition is free.) with the following workloads enabled: ASP.NET and web development, .NET Core cross-platform development.
- Twilio account – Sign up for a free trial account (no credit card required) with this link and get an additional $10 credit when you upgrade to a regular account.
- Twilio CLI – The Twilio command-line interface requires Node.js and npm (included with the Node.js installation).
- ngrok – A free account is all that’s necessary.
There is a companion repository available on GitHub for this tutorial. It contains the complete code for the solution you’re going to build.
Understanding the case study #
When a customer calls your Twilio Phone Number and nobody picks up, you need to have a graceful fallback. The fallback you will build will ask if the caller wants to receive a callback, asking for their phone number and message.
To do this, you will build a set of webhooks from which Twilio will request instructions from. When your Twilio phone number is dialed, Twilio will send an HTTP request to your webhook, and your webhook will instruct Twilio to dial a non-existent client. This is to simulate the scenario where nobody picks up the call.
Since the client doesn't exist, Twilio will report the DialCallStatus
as no-answer
. Twilio will send another HTTP request asking what to do as a result of the change in status.
Your webhook will ask the caller if they would like a callback and if a callback is requested, the caller will be prompted to provide their phone number and a message.
The phone number and message will automatically be inserted into a Customer Relationship Management (CRM) system so that customer representatives can make the callback later. This tutorial will use a dummy implementation of the CRM integration. A specific CRM integration will not be covered in this tutorial.
Purchase a Twilio Phone Number #
You need to create a Twilio Phone Number to be able to receive calls for this demo.
For this tutorial, you will use the Twilio CLI, but you can also accomplish the same tasks using the Twilio Console.
Install the Twilio CLI on your machine by following the Twilio CLI Quickstart instructions.
Log in using the Twilio CLI using the following command:
twilio login
Use the following Twilio CLI command to buy a phone number if you don't have one already. You can get a local phone number using the following command, replacing “US” with the appropriate country code for your location:
twilio phone-numbers:buy:local --country-code=US
Note: Although you’ll be buying a phone number, if you're using a trial account, the credit in your trial account will be applied to the charge. No credit card is required.
If you already have a Twilio phone number, you can list your phone numbers using the following command:
twilio phone-numbers:list
Copy the Twilio Phone Number for this application someplace handy so you can update the phone number resource later. You will also need to call this phone number later to test the application.
Develop the Twilio webhook server #
Create the ASP.NET Core WebAPI #
The Twilio webhook server will be implemented as an ASP.NET Core WebAPI project.
Use the following commands to create an empty WebAPI project:
mkdir TwilioNoPickUp cd TwilioNoPickUp # creates folder and a webapi project inside the new folder dotnet new webapi # optionally, create a solution and add the server project to the solution dotnet new sln dotnet sln add TwilioNoPickUp.csproj
You can run your .NET project using this command:
dotnet run
For your convenience, you can also use the dotnet watch run
alternative. This will automatically reload the .NET application as you edit. You can use this command to watch your project:
dotnet watch run
By default, the ASP.NET Core Web API template comes with an existing controller and a model class. Remove the files for these classes:
rm Controllers/WeatherForecastController.cs rm WeatherForecast.cs
You will need to provide the URL of the locally running web project later. Take note of the HTTP-URL when running the web project. The default HTTP URL is usually http://localhost:5000.
Receive and respond to inbound phone calls #
When your Twilio Phone Number is dialed, Twilio will send an HTTP request to the webhook URL that you will configure later. You will create a new controller and action to handle this HTTP request and provide instructions to Twilio.
Run the following command to add the Twilio.AspNet.Core NuGet package:
dotnet add package Twilio.AspNet.Core
This Twilio library will add some helpful classes and methods to create TwiMLTM (the Twilio Markup Language).TwiML is a set of specific XML elements and attributes you can use to instruct Twilio what to do when a call or text is received.
Create a new file Controllers/VoiceController.cs and add the following code:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Twilio.AspNet.Common; using Twilio.AspNet.Core; using Twilio.TwiML; using Twilio.TwiML.Voice; namespace TwilioNoPickUp.Controllers { [ApiController] public class VoiceController : TwilioController { private readonly ILogger<VoiceController> logger; public VoiceController(ILogger<VoiceController> logger) { this.logger = logger; } [HttpPost] [Route("/Voice")] public TwiMLResult Voice() { var response = new VoiceResponse(); var dial = new Dial(); dial.Client("NON-EXISTENT-CLIENT"); response.Append(dial); return TwiML(response); } } }
Note that the VoiceController class is inheriting from the TwilioController
class. The TwilioController
class adds the TwiML
method. The TwiML
method converts the VoiceResponse
object to TwiML
(XML) and uses it as the body of the HTTP response.
The Incoming
action will generate the following TwiML
:
<?xml version="1.0" encoding="utf-8"?> <Response> <Dial> <Client>NON-EXISTENT-CLIENT</Client> </Dial> </Response>
When Twilio receives this HTTP response, Twilio will attempt to dial the client named 'NON-EXISTENT-CLIENT'. Since there are no clients with that name connected to Twilio, Twilio isn't able to dial the client, and Twilio will change the dial status to 'no-answer'.
To test this out you'll need a phone that can make phone calls to your Twilio phone number. Switch back to your shell and run the following command to run the ASP.NET project:
dotnet run
Use the following command to create a webhook, taking care to replace the placeholder with your actual Twilio phone number:
twilio phone-numbers:update "+TWILIO_NUMBER" --voice-url=http://localhost:5000/voice
Twilio won't be able to reach your local network directly, that's why when you use a localhost URL for the --voice-url
argument, the Twilio CLI will use ngrok to tunnel your local URL to a public URL. Instead of using your local URL, the voice URL on the phone number resource will be using the ngrok public URL.
Call your Twilio phone number to hear the result. You should hear a dialing sound while Twilio is trying to dial the non-existent client, but then the call abruptly ends without warning.
If you look at the details of the phone call in the Twilio Console, you can see that the duration of the phone call is very short and the status is "Completed". The dial instruction created a child call and the status of the child call is "No Answer".
Handle the no-pick up scenario #
You can add the action
attribute to provide additional instructions to Twilio after the dialed call ends. You need to set a URL as the action
attribute value which Twilio will use to make an HTTP request to when the dialed call ends. You can respond with TwiML to provide additional instructions for Twilio to execute.
Update the Voice
action with the highlighted code:
public TwiMLResult Voice() { var response = new VoiceResponse(); var dial = new Dial(action: new Uri("/VoiceAction", UriKind.Relative)); dial.Client("NON-EXISTENT-CLIENT"); response.Append(dial); return TwiML(response); }
Take note of the optional parameter action
passed into the constructor of Dial
.
Now the resulting TwiML looks like this:
<?xml version="1.0" encoding="utf-8"?> <Response> <Dial action="/incoming"> <Client>NON-EXISTENT-CLIENT</Client> </Dial> </Response>
When the dialed call ends, Twilio will send another HTTP request to ask for instructions to the /incoming route.
To handle this subsequent HTTP request, you need to create a new action at /incoming.
Add the following static field at the top of the VoiceController
class:
private static readonly HashSet<string> badStatusCodes = new HashSet<string>{ "busy", "no-answer", "canceled", "failed", };
This is a hashset of status codes you want to handle using the callback scenario.
Add the following action to VoiceController
after the Voice
method:
[HttpPost] [Route("/VoiceAction")] public TwiMLResult VoiceAction([FromForm] StatusCallbackRequest request) { var response = new VoiceResponse(); if (!badStatusCodes.Contains(request.DialCallStatus)) { return TwiML(response); } logger.LogInformation("Bad dial call status: {DialCallStatus}", request.DialCallStatus); var gather = new Gather(numDigits: 1, action: new Uri("/RequestCallback", UriKind.Relative)); gather.Say("The person you are trying to reach is unavailable. If you would like to receive a callback, press 1. If not, press 2 or hang up."); response.Append(gather).Redirect(new Uri("/RequestCallback", UriKind.Relative)); return TwiML(response); }
Twilio will pass along a bunch of data using form encoding which you can capture using the FromForm
attribute. The data will be serialized into the request
parameter as a StatusCallbackRequest
instance.
The code checks whether the .DialCallStatus
is one of the status codes from the badStatusCodes
HashSet. If not, an empty TwiML response is returned which will end the call.
If so, the following TwiML is returned:
<?xml version="1.0" encoding="utf-8"?> <Response> <Gather action="/RequestCallback" numDigits="1"> <Say>The person you are trying to reach is unavailable. If you would like to receive a callback, press 1. If not, press 2 or hang up.</Say> </Gather> <Redirect>/RequestCallback</Redirect> </Response>
This TwiML will prompt the caller to press '1' or '2'. When they press a digit on their phone, Twilio will send an HTTP request with the pressed digits to the /RequestCallback route.
If the caller doesn't press any number, the Redirect
node will be executed which will also have Twilio send another HTTP request to the /RequestCallback route.
Add the following action after the previous actions to handle requests for the /RequestCallback route:
[HttpPost] [Route("/RequestCallback")] public TwiMLResult RequestCallback([FromForm] VoiceRequest request) { var response = new VoiceResponse(); Gather gather; switch (request.Digits) { case "1": gather = new Gather(numDigits: 10, action: new Uri("/CapturePhoneNumber", UriKind.Relative)); gather.Say("Please enter your 10 digit phone number"); response.Append(gather).Redirect(new Uri("/RequestCallback", UriKind.Relative)); break; case "2": response.Say("Goodbye!").Hangup(); break; default: response.Say("Sorry, I don't understand that choice.").Pause(); gather = new Gather(numDigits: 1, action: new Uri("/RequestCallback", UriKind.Relative)); gather.Say("If you would like to receive a callback, press 1. If not, press 2 or hang up."); response.Append(gather).Redirect(new Uri("/RequestCallback", UriKind.Relative)); break; } return TwiML(response); }
The HTTP request to /RequestCallback also sends a bunch of data using form encoding which is being serialized to the request
parameter. You can access the number pressed by the caller using request.Digits
.
When the caller presses "2", Twilio will respond with the message "Goodbye" and hang up.
When the caller doesn't press a number, they will be prompted to press "1" or "2" again which will send another HTTP request to the current URL as a result.
When the caller presses "1", the following TwiML is constructed:
<?xml version="1.0" encoding="utf-8"?> <Response> <Gather action="/CapturePhoneNumber" numDigits="10"> <Say>Please enter your 10 digit phone number</Say> </Gather> <Redirect>/RequestCallback</Redirect> </Response>
Twilio will ask the caller to enter their 10 digit phone number. When the caller enters their phone number, Twilio will send an HTTP request with the digits to the /CapturePhoneNumber route.
Before creating a new action to handle the /CapturePhoneNumber route, you will need to add a couple of files. Follow these instructions to create an interface and class to handle the CRM integration.
Create a new directory Services and inside of that directory a new file ICallbackService.cs. Place the following code in the file:
public interface ICallbackService { void CreateCallback(string callSid, string phoneNumber); void AddTranscriptToCallback(string callSid, string transcript); }
Create a new file DummyCrmCallbackService.cs within the Services directory and place the following code in the file:
using Microsoft.Extensions.Logging; public class DummyCrmCallbackService : ICallbackService { private readonly ILogger<DummyCrmCallbackService> logger; public DummyCrmCallbackService(ILogger<DummyCrmCallbackService> logger) { this.logger = logger; } public void CreateCallback(string callSid, string phoneNumber) { logger.LogInformation("CreateCallback(callSid: {CallSid}, phoneNumber: {PhoneNumber})", callSid, phoneNumber); } public void AddTranscriptToCallback(string callSid, string transcript) { logger.LogInformation("AddTranscriptToCallback(callSid: {CallSid}, transcript: {Transcript})", callSid, transcript); } }
Open the Startup.cs file and add the following line at the beginning of the ConfigureServices
method:
services.AddTransient<ICallbackService, DummyCrmCallbackService>();
This will wire up the dependency injection so that DummyCrmCallbackService
will be injected whenever you request an instance of ICallbackService
.
As the name suggests, the DummyCrmCallbackService
won't actually integrate with a real CRM. Instead, it will simply log the information. This way you can keep building this proof of concept without involving the complexity of integrating a CRM.
If you do want to integrate with a real CRM, you need to create another implementation of ICallbackService
and swap DummyCrmCallbackService
with your new class.
Back in your VoiceController
, add the following two actions to handle HTTP requests to the /CapturePhoneNumber and /FinishCall routes:
[HttpPost] [Route("/CapturePhoneNumber")] public TwiMLResult CapturePhoneNumber([FromForm] VoiceRequest request, [FromServices] ICallbackService callbackService) { var response = new VoiceResponse(); if (request.Digits.Length != 10) { response.Say($"You entered {request.Digits.Length} digits.") .Pause(); var gather = new Gather(numDigits: 10, action: new Uri("/CapturePhoneNumber", UriKind.Relative)); gather.Say("Please enter your 10 digit phone number."); response.Append(gather) .Redirect(new Uri("/CapturePhoneNumber", UriKind.Relative)); return TwiML(response); } else { callbackService.CreateCallback(request.CallSid, request.Digits); response.Say("Please let us know what you are calling about by leaving a message after the beep.") .Pause() .Record( action: new Uri("/FinishCall", UriKind.Relative), timeout: 5, transcribe: true, transcribeCallback: new Uri("/CaptureVoiceMailTranscript", UriKind.Relative) ) .Say("Your callback has been requested. Goodbye.") .Hangup(); } return TwiML(response); } [HttpPost] [Route("/FinishCall")] public TwiMLResult FinishCall([FromForm] VoiceRequest request) { var response = new VoiceResponse(); response.Say("Your callback has been requested. Goodbye.") .Hangup(); return TwiML(response); }
In the highlighted line, take note of how the second parameter of the CapturePhoneNumber
is the ICallbackService
interface you just created. By using the FromServices
attribute, ASP.NET Core's built-in dependency injection will create an instance of DummyCrmCallbackService
and will inject it into the parameter.
The CapturePhoneNumber
action will re-prompt the user to give their 10 digit phone number. If the Digits
property isn't 10 characters long, the subsequent HTTP request will go back to /CapturePhoneNumber.
However, if the Digits
property is 10 characters long, the callback is created through callbackService.CreateCallback
and the following TwiML is returned:
<?xml version="1.0" encoding="utf-8"?> <Response> <Say>Please let us know what you are calling about by leaving a message after the beep.</Say> <Pause></Pause> <Record action="/FinishCall" timeout="5" transcribe="true" transcribeCallback="/CaptureVoiceMailTranscript"></Record> <Say>Your callback has been requested. Goodbye.</Say> <Hangup></Hangup> </Response>
Twilio will ask the caller to leave a message after the beep, pause for a second, and then start recording the message. If the caller leaves a message, Twilio will send an HTTP request to the /FinishCall route which will send a simple confirmation message and hang up. If the caller doesn't leave a message, Twilio will say, "Your callback has been requested. Goodbye." and hang up.
The transcribe
and transcribeCallback
attributes tell Twilio to transcribe the message left by the caller and which URL to send the transcription to when it is ready. The transcription data will be sent to the /CaptureVoiceMailTranscript route, but unlike previous webhooks, the response of this webhook does not control the conversation with the caller. Twilio takes a little bit of time to transcribe the voice mail recording, so the conversation has already ended at that point.
Add an action to handle the HTTP requests to the /CaptureVoiceMailTranscript route:
[HttpPost] [Route("/CaptureVoiceMailTranscript")] public void CaptureVoiceMailTranscript([FromForm] VoiceRequest request, [FromServices] ICallbackService callbackService) { callbackService.AddTranscriptToCallback(request.CallSid, request.TranscriptionText); }
The CaptureVoiceMailTranscript
action will receive the data from Twilio and pass the TranscriptionText
to the callbackService
. The AddTranscriptToCallback
method will take care of finding the previously created callback by using the CallSid
and update it with the TranscriptionText
.
Test the no-pick up scenario #
To test this out you'll need a phone that can make phone calls to your Twilio phone number. Switch back to your shell and run the following command to run the ASP.NET project:
dotnet run
Use the following command to create a webhook, taking care to replace the placeholder with your actual Twilio phone number:
twilio phone-numbers:update "+TWILIO_NUMBER" --voice-url=http://localhost:5000/voice
Now you can test out your callback logic by calling your Twilio Phone Number. The call should go like this:
- Twilio: The person you are trying to reach is unavailable. If you would like to receive a callback, press 1. If not, press 2 or hang up.
- Caller: *press 1*
- Twilio: Please enter your 10 digit phone number
- Caller: *presses 10 digits*
- Twilio: Please let us know what you are calling about by leaving a message after the beep.
- Caller: I need help resolving a very urgent issue related to your product. Please call me back as soon as you can.
- Twilio: Your callback has been requested. Goodbye.
After the conversation, Twilio will finish transcribing the voice mail and post it back to /CaptureVoiceMailTranscript where it will be saved to a CRM.
Integrating with Dynamics CRM #
Currently, you are logging the callback information instead of integrating with a real CRM.
This is sufficient for this proof of concept, but you can swap the DummyCrmCallbackService
with your own implementation to integrate with your preferred CRM.
If you're interested in learning how to implement this integration with Dynamics CRM or Microsoft Dataverse, keep an eye out for a follow-up post that will walk you through implementing the callback integration with Dataverse.
Here are some screenshots of the resulting data in Dynamics CRM:
This screenshot shows the contact who has requested the callback. On the right side, you can see the Timeline which is listing the callback activities. If you open the callback, you will navigate to the screen below:
This screen shows you the data of the callback including the phone number and transcript of the voicemail.
Potential enhancements #
These webhooks always need to be public for Twilio to be able to reach them. That also means that anyone can call them and potentially pretend to be Twilio making HTTP calls. To verify the HTTP calls are authentic and originate from Twilio, see Secure your C# / ASP.NET Core app by validating incoming Twilio requests in the Twilio docs.
Instead of configuring the URL for the action using the Route
attribute, you can configure it on the controller to just use the action name as the URL like this [Route("[action]")]
.
Also, instead of hardcoding the URL in all the Twilio callbacks, you can use the URL helpers to generate the correct URL like this Url.Action(nameof(YourAction))
.
Additional resources #
Check out the following resources for more information on the topics and tools presented in this tutorial:
Making Phone Calls from Blazor WebAssembly with Twilio Voice – Learn how to dial clients and how to build a Twilio Voice client using Blazor WebAssembly
TwiMLTM for Programmable Voice – Learn how about all the available TwiML verbs and nouns available for Twilio Voice
Dependency injection in ASP.NET Core – Learn more about the built-in dependency injection container that comes with ASP.NET Core
Routing to controller actions in ASP.NET Core – Learn how routing works in an ASP.NET Core MVC/WebAPI application