How to Handle No-Answer/Pickup Scenarios with Voicemail and Callback using Twilio Voice

March 16, 2026
Written by
Niels Swimberghe
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Phone icon with text about handling no-answer scenarios with voicemail and callbacks using programmable voice
Phone icon with text about handling no-answer scenarios with voicemail and callbacks using programmable voice

How to Handle No-Answer/Pickup Scenarios with Voicemail and Callback using Twilio Voice

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. 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

To follow this tutorial, 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 10 SDK (other versions of .NET 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 2026 (The Community edition is free.) with the following workloads enabled: ASP.NET and web development, .NET Core cross-platform development.
  • A Twilio 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.
This is a rewrite of a tutorial originally built for .NET 5.0. There is a companion repository available on GitHub for the original tutorial available here for your reference which contains the entire solution. Modify this solution as necessary for your application and chosen .NET framework.

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 to send instructions to Twilio. 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 a scenario where nobody picks up the call.

Since the client doesn't exist, Twilio will report the DialCallStatus as no-answer. Twilio will then 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. 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.

You can do this using the Twilio CLI or using the Twilio Console.

If you want to use the CLI, 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

You will be prompted to enter your Account SID and Auth Token. Both can be found on the right-hand side of the Twilio console home page.

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

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 TwiML (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.

ASP.Net generates a default program.cs with some dummy data that you will need to overwrite. For this tutorial you will keep this file as simple as possible and tell .NET to use your Controllers. Replace the code in your program.cs with the following:

using Microsoft.OpenApi;
using TwilioNoPickUp;
using TwilioNoPickUp.Controllers;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();

The project won't find your controllers yet, but you create them in this step.

Create a new folder called Controllers. In this folder, you will create a file called VoiceController.cs. To this new file, 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

Twilio won't be able to reach your local network directly. In order to allow calls to reach your application, you will have to use a tunneling program such as ngrok. With ngrok installed and authenticated, type this command into your terminal to open the port. If your application is not running on port 5000, replace the number here with the accurate port number:

ngrok http 5000

Use the following command to create a webhook. Take care to replace the placeholder with your actual Twilio phone number. For the URL, use the URL provided by your ngrok output.

twilio phone-numbers:update "+YOUR_PHONE_NUMBER" --voice-url="http://NGROK_URL/voice"

If you aren't using the CLI, you can also paste the ngrok URL, including the /voice hook, into your console under Webhook -> A Call Comes In as shown:

A Twilio Console screenshot
A Twilio Console screenshot

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.

You will also add this method to help build URIs easily in your project.

private Uri CreateActionUri(string actionName) => new Uri(this.Url.Action(actionName), UriKind.Relative);

Now, 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))
           {
               logger.LogInformation("Bad dial call status: {DialCallStatus}", request.DialCallStatus);
               var gather = new Gather(numDigits: 1, action: CreateActionUri(nameof(RequestCallback)));
               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(CreateActionUri(nameof(RequestCallback)));
           }
           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: CreateActionUri(nameof(CapturePhoneNumber)));
                   gather.Say("Please enter your 10 digit phone number");
                   response.Append(gather)
                       .Redirect(CreateActionUri(nameof(RequestCallback)));
                   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: CreateActionUri(nameof(RequestCallback)));
                   gather.Say("If you would like to receive a callback, press 1. If not, press 2 or hang up.");
                   response.Append(gather)
                       .Redirect(CreateActionUri(nameof(RequestCallback)));
                   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 called Services. Inside of that directory create a new file called 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 before you call the builder.Build() method:

builder.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.

To finish this tutorial, go into the VoiceController and add an action to handle the HTTP requests to the /CaptureVoiceMailTranscript route:

private readonly ICallbackService callbackService;
      [HttpPost]
      [Route("/CaptureVoiceMailTranscript")]
       public void CaptureVoiceMailTranscript([FromForm] VoiceRequest request)
       {
           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

If using the CLI, 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

Or set the webhook up on your dashboard as before.

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. 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.

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:

Transcribe Phone Calls in Real Time Using C# .NET with AssemblyAI and Twilio – Learn how to use AssemblyAI to transcribe Twilio Voice calls

TwiML 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

Amanda Lange is a .NET Engineer of Technical Content. She is here to teach how to create great things using C# and .NET programming. She can be reached at amlange [ at] twilio.com.