Build a Voicemail Service using Twilio Voice and ASP.NET Core

March 21, 2023
Written by
Volkan Paksoy
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by

Build a Voicemail Inbox using Twilio Voice and ASP.NET Core

Most mobile networks provide a voicemail service letting people who call you leave a message when you can't answer, and letting you listen to those messages by calling your voicemail number. When you have programmatic access to your calls using Twilio, you can implement your voicemail service and customize it to you or your business's needs.

In this tutorial, you will learn how to implement an Interactive Voice Response (IVR) app using ASP.NET Core Web API project to record and serve your voicemails and how to manage them (play/save/delete) by calling your Twilio phone number.  

Prerequisites

You'll need the following things in this tutorial:

Project overview

In this tutorial, you will implement an ASP.NET Core Web API to record and manage your voicemails. Before getting into the implementation details, let's take a look at what it does:

The flow of your IVR starts when someone calls your Twilio phone number which Twilio picks up.  Twilio passes the call details to your Web API and expects TwiML instructions to manage the call. If the caller's phone number is one of the "owners" phone numbers, the voicemail directory flow will be invoked, otherwise the voicemail recording flow will be invoked.

Voicemail recording flow:

  1. The IVR asks the caller to leave a message after the beep, after which the call is being recorded.
  2. Once the caller finishes recording, the IVR thanks the caller for leaving a message and ends the call.
  3. When Twilio saves the recording, Twilio will notify your Web API of the location of the recording and your Web API downloads the recording to disk.

Voicemail directory flow:

  1. The IVR informs the user how many new and saved messages are available, and creates a queue of the messages
  2. The IVR plays the current message in the queue and asks to press the dial pad buttons to either 1-replay, 2-save, or 3-delete the message.
  3. If 1 is pressed, the message and the queue are not changed. If 2 is pressed, the message is saved, and then the message is removed from the queue. If 3 is pressed, the message is deleted, and then the message removed from the queue. If there are more messages in the queue, go to step 7.
  4. The IVR informs the caller that there are no more messages and ends the call.

Now that you are more familiar with the end result, let's get started.

Project set up

The easiest way to set up the starter project is by cloning the sample GitHub repository.

Open a terminal, change to the directory you want to download the project and run the following command:

git clone https://github.com/Dev-Power/voicemail-service-using-twilio.git --branch starter-project

The project can be found in the src\VoicemailDirectory.WebApi subfolder. Open the project in your IDE.

The project comes with empty files that you will implement as you go along. Before starting the implementation, let's take a look at the key points in the starter project:

The API will download the audio recordings and store them under the wwwroot/Voicemails directory. To let Twilio have access to these files, static file hosting is enabled in the API by adding the following line to the Program.cs file:

app.UseStaticFiles();

The voicemail recordings will be stored on your web server and served as static files, meaning that anyone would be able to download them. In production, you should validate that the incoming HTTP requests, requesting these static files originate from Twilio, and not someone else. Follow the documentation for the Twilio helper library for ASP.NET on how to validate Twilio requests for static files and your webhooks.

The downloading operation and all local I/O operations will be handled via FileService, which is added to the IoC by the following statements in Program.cs:

builder.Services.AddHttpClient();
builder.Services.AddTransient<FileService>();

As explained in the project overview section, you must add the owner's phones to the configuration to access your voicemails. You can only listen to your voicemails by calling from one of those numbers.

Update your configuration by adding your phone number(s) to the appsettings.json file:

 "Voicemail": {
    "Owners": [
      "{ YOUR PHONE NUMBER }"
    ]
  }

Next, for Twilio to be able to send HTTP requests to your Web API, you'll need to make your locally running Web API publicly available over the internet. You can use ngrok for this, a free secure tunneling service. Run the following command to create a tunnel with ngrok:

ngrok http http://localhost:5096

http://localhost:5096 is the local URL that your Web API will listen to for HTTP requests.

Note the forwarding URL on your screen, which should look like this:

Forwarding                    https://{random-url}.{region}.ngrok.io -> http://localhost:5096

Ngrok has now created a secure tunnel that will accept HTTP requests at the temporary Forwarding URL and forward those requests to http://localhost:5096.

When your Twilio phone number receives a phone call, Twilio will send an HTTP request to your Web API passing in the call information and expecting instructions. You need to configure where this HTTP request is sent when a call comes in.

To do this, go to the Twilio Console, select your account, and then click Phone Numbers → Manage → Active Numbers on the left pane. (If Phone Numbers isn't on the left pane, click on Explore Products and then on Phone Numbers.)

Click on the phone number you want to use for your project and scroll down to the Voice section.

Under the A Call Comes In label, set the dropdown to Webhook, the text field next to it to the ngrok Forwarding URL suffixed with the /IncomingCall path, the next dropdown to HTTP POST, and click Save. It should look like this:

Twilio Console showing the incoming call webhook set to ngrok forwarding URL followed by /IncomingCall. Save button is highlighted in the image and needs to be clicked to update the settings.

Note that you have to use HTTPS as the protocol when setting the webhook URL.

Now that all the plumbing is done, move on to the next section to implement the application.

Project implementation

The first step to recording voicemails is to receive the calls. As you added the /IncomingCall path to your webhook URL, your controller must match this.

Update the IncomingCallController.cs file with the following code:

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Twilio.AspNet.Core;
using Twilio.TwiML;

namespace VoicemailDirectory.WebApi.Controllers;

[ApiController]
[Route("[controller]")]
public class IncomingCallController : TwilioController
{
    private readonly VoicemailOptions _voicemailOptions;

    public IncomingCallController(IOptionsSnapshot<VoicemailOptions> voicemailOptions)
    {
        _voicemailOptions = voicemailOptions.Value;
    }

    [HttpPost]
    public TwiMLResult Index([FromForm] string from)
    {
        var response = new VoiceResponse();

        var redirectUrl = _voicemailOptions.Owners.Contains(from)
            ? Url.Action("Index", "Directory")!
            : Url.Action("Index", "Record")!;

        response.Redirect(
            url: new Uri(redirectUrl, UriKind.Relative),
            method: Twilio.Http.HttpMethod.Post
        );

        return TwiML(response);
    }
}

The default action (Index) is invoked when somebody calls your Twilio phone number. The calling number is checked here to see if the caller is an owner or anybody else. If the caller is unknown, it redirects to the RecordController's Index action. If the caller is an owner, it redirects to the DirectoryController's Index action.

Using IOptionsSnapshot allows you to update the phone numbers configured in appsettings.json in the Voicemail:Owners array without having to stop the application. This is especially useful when testing because you can quickly change the mode of operation by updating the configuration.

Update the RecordController.cs with the following code:

using Microsoft.AspNetCore.Mvc;
using Twilio.AspNet.Core;
using Twilio.TwiML;
using VoicemailDirectory.WebApi.Services;

namespace VoicemailDirectory.WebApi.Controllers;

[ApiController]
[Route("[controller]/[action]")]
public class RecordController : TwilioController
{
    private readonly ILogger<IncomingCallController> _logger;
    private readonly FileService _fileService;

    public RecordController(
        ILogger<IncomingCallController> logger,
        FileService fileService
    )
    {
        _logger = logger;
        _fileService = fileService;
    }

    [HttpPost]
    public TwiMLResult Index()
    {
        var response = new VoiceResponse();
        response.Say("Hello, please leave a message after the beep.");
        response.Record(
            timeout: 10,
            action: new Uri(Url.Action("Bye")!, UriKind.Relative),
            method: Twilio.Http.HttpMethod.Post,
            recordingStatusCallback: new Uri(Url.Action("RecordingStatus")!, UriKind.Relative),
            recordingStatusCallbackMethod: Twilio.Http.HttpMethod.Post
        );
        return TwiML(response);
    }

    [HttpPost]
    public TwiMLResult Bye() => new VoiceResponse()
        .Say("Thank you for leaving a message, goodbye.")
        .ToTwiMLResult();

    [HttpPost]
    public async Task RecordingStatus(
        [FromForm] string callSid,
        [FromForm] string recordingUrl,
        [FromForm] string recordingSid,
        [FromForm] string recordingStatus
    )
    {
        _logger.LogInformation(
            "Recording status changed to {recordingStatus} for call {callSid}. Recording is available at {recordingUrl}",
            recordingStatus, callSid, recordingUrl
        );

        if (recordingStatus == "completed")
        {
            await _fileService.DownloadRecording(recordingUrl, recordingSid);
        }
    }
}

Recording the voicemails is a 3-step process:

  1. Respond with TwiML instruction to tell Twilio  to record the call, which is done in the Index action.
  2. When the caller is done recording, Twilio will send an HTTP request to the Bye action, because that's the action URL configured on the Record TwiML. The Bye action will acknowledge the message has been taken to the caller, and because there's no further TwiML instructions, the call will be ended.
  3. When the recording status changes, Twilio will send an HTTP request to the RecordingStatus action, because that's the recordingStatusCallback URL configured on the Record TwiML.  The HTTP request sent to the RecordingStatus action will contain data such as the recording URL, SID, status, and more. If the status is completed, the action will download the audio recording and store it locally.

The caller may hang up before reaching the timeout, in which case they will not hear the acknowledgement message, but the recording will still be downloaded.

By default, Recording URLs don’t require authentication, and recordings are not encrypted. However, you can require basic authentication to access the recordings and configure recordings to be encrypted in the voice settings (Voice → Settings → General). If you enable basic authentication, you'll need to provide the Twilio Account SID and Auth Token, or API Key SID and API Key Secret as the username and password. This tutorial assumes basic authentication is not enabled for recordings.

Next, update the third and final controller, DirectoryController, with the following code:

using Microsoft.AspNetCore.Mvc;
using Twilio.AspNet.Core;
using Twilio.TwiML;
using Twilio.TwiML.Voice;
using VoicemailDirectory.WebApi.Services;

namespace VoicemailDirectory.WebApi.Controllers;

[ApiController]
[Route("[controller]/[action]")]
public class DirectoryController : TwilioController
{
    private readonly ILogger<DirectoryController> _logger;
    private readonly FileService _fileService;

    public DirectoryController(
        ILogger<DirectoryController> logger,
        FileService fileService
    )
    {
        _logger = logger;
        _fileService = fileService;
    }

    [HttpPost]
    public TwiMLResult Index()
    {
        var newMessages = _fileService.GetRecordingSids(Constants.New);
        var savedMessages = _fileService.GetRecordingSids(Constants.Saved);

        var response = new VoiceResponse();

        string GetWordingSingularOrPlural(int messageCount) => messageCount == 1 ? "message" : "messages";
        string GetNumberOrNo(int messageCount) => messageCount == 0 ? "no" : messageCount.ToString();
        response.Say(
            $"Hello, you have {GetNumberOrNo(newMessages.Count)} new {GetWordingSingularOrPlural(newMessages.Count)} " +
            $"and {GetNumberOrNo(savedMessages.Count)} saved {GetWordingSingularOrPlural(savedMessages.Count)}. "
        );

        // If there are no new or saved messages, end the call
        if (newMessages.Count == 0 && savedMessages.Count == 0)
        {
            response.Say("Goodbye!");
            return TwiML(response);
        }

        // Start with the new messages if there are any
        string recordingType = newMessages.Count > 0 ? Constants.New : Constants.Saved;
        response.Say($"Playing {recordingType} messages.");

        // No filter to get all recordings. Order alphabetically so that the new ones come at top
        // Can prepend datetime as well to order more precisely
        var allMessages = _fileService.GetRecordingSids(string.Empty)
            .OrderBy(s => s)
            .ToList();

        response.Append(
            CreateGatherTwiml(allMessages)
                .Append(PlayNextMessage(allMessages))
                .Append(SayOptions())
        );

        return TwiML(response);
    }

    [HttpPost]
    public TwiMLResult Gather(
        [FromQuery] List<string> queuedMessages,
        [FromForm] int digits
    )
    {
        _logger.LogInformation(
            "QueuedMessages: {queuedMessages}, user entered: {digits}",
            queuedMessages, digits
        );

        var currentMessage = queuedMessages.First();
        var isCurrentMessageNew = currentMessage.StartsWith(Constants.New);

        var response = new VoiceResponse();

        switch (digits)
        {
            case 1: // Replay
                // No action. The existing message will stay at the top of the queue to be replayed
                break;

            case 2: // Save
                _fileService.SaveRecording(currentMessage);
                queuedMessages.Remove(currentMessage);
                break;

            case 3: // Delete
                _fileService.DeleteRecording(currentMessage);
                queuedMessages.Remove(currentMessage);
                break;

            default: // Invalid key. Play error message then say the valid options again.
                response.Say("Sorry, that key is not valid.");
                response.Append(
                    CreateGatherTwiml(queuedMessages)
                        .Append(SayOptions())
                );
                return TwiML(response);
        }

        if (queuedMessages.Count == 0)
        {
            response.Say("No more messages. Goodbye!");
            return TwiML(response);
        }

        if (isCurrentMessageNew && queuedMessages.First().StartsWith(Constants.Saved))
        {
            response.Say("No more new messages. Here are your saved messages.");
        }

        response.Append(
            CreateGatherTwiml(queuedMessages)
                .Append(PlayNextMessage(queuedMessages))
                .Append(SayOptions())
        );

        return TwiML(response);
    }

    private Gather CreateGatherTwiml(List<string> queuedMessages) => new Gather(
        input: new List<Gather.InputEnum> {Twilio.TwiML.Voice.Gather.InputEnum.Dtmf},
        timeout: 5,
        numDigits: 1,
        action: new Uri(
            Url.Action("Gather", new {queuedMessages})!,
            UriKind.Relative
        ),
        method: Twilio.Http.HttpMethod.Post
    );

    private Say SayOptions()
        => new Say("To replay press 1. To save the message press 2. To delete the message press 3.");

    private Play PlayNextMessage(List<string> queuedMessages)
    {
        var nextMessage = queuedMessages.First();
        return new Play(new Uri($"/Voicemails/{nextMessage}.mp3", UriKind.Relative));
    }
}

The response comprises three TwiML verbs:

  • Gather: Used to collect caller input which the caller provides by pressing a button in their dial keypad (DTMF)
  • Play: Used to play audio recordings to the caller
  • Say: Used to communicate the actions they can take after listening to the recordings. 

The queued messages are passed in the query string, so the number of voicemails the system supports is limited by the maximum length of a URL (which is around 2,000 characters). You may bump into issues after approximately 40 - 50 messages if you never delete them. If this is an issue you could store this in cookies, in session, or in some other data store.

When you call your Twilio phone number as the owner, the Index action will be executed. You haven't implemented the FileService yet (which is next), but from the function names, you can deduce that this action does the following:

  • Get the new and saved recordings separately
  • Prepare a welcome message to indicate how many new and how many saved messages are in the directory
  • Get a list of all recordings and prepare the TwiML response to play the first message in the queue, followed by prompting the available options.

After the first message is played to the caller and the caller has made their decision, Twilio passes this information to the Gather action of the controller. Now you have to decide what to do based on the caller's action, which is what the switch statement in the Gather action does.

Before going over the logic in this controller, implement the FileService as well, as the controller uses that service heavily.

Update the FileService.cs file with the following code:

namespace VoicemailDirectory.WebApi.Services;

public class FileService
{
    private readonly HttpClient _httpClient;
    private readonly string _rootVoicemailPath;

    public FileService(IHttpClientFactory httpClientFactory, IWebHostEnvironment webHostEnvironment)
    {
        _rootVoicemailPath = $"{webHostEnvironment.WebRootPath}/Voicemails";
        _httpClient = httpClientFactory.CreateClient();
    }

    public async Task DownloadRecording(string recordingUrl, string recordingSid)
    {
        using HttpResponseMessage response = await _httpClient.GetAsync($"{recordingUrl}.mp3");
        response.EnsureSuccessStatusCode();
        await using var fs = new FileStream(
            $"{_rootVoicemailPath}/{Constants.New}_{recordingSid}.mp3",
            FileMode.CreateNew
        );
        await response.Content.CopyToAsync(fs);
    }

    public List<string> GetRecordingSids(string recordingType)
        => Directory.GetFiles($"{_rootVoicemailPath}/", $"{recordingType}*.mp3")
            .Select(s => Path.GetFileNameWithoutExtension(s))
            .ToList();

    public void SaveRecording(string recordingSid)
    {
        var currentPath = GetRecordingPathBySid(recordingSid);
        var newPath = currentPath.Replace($"{Constants.New}", $"{Constants.Saved}");
        File.Move(currentPath, newPath);
    }

    public void DeleteRecording(string recordingSid) => File.Delete(GetRecordingPathBySid(recordingSid));

    private string GetRecordingPathBySid(string recordingSid)
        => Directory.GetFiles($"{_rootVoicemailPath}/", "*.mp3")
            .Single(s => s.Contains(recordingSid));
}

If the user has pressed 1 to replay the message, you don't have to do anything in your API other than return the same response. As long as the message is left at the top of the queue, it will be played to the caller.

If the user has pressed 2 to save the message, the controller calls the SaveRecording method of the FileService, which renames the file by replacing "New" with "Saved". This way, the next time you call your voicemail service, this recording will be treated as an old recording.

If the user has pressed 3 to delete the message, the controller calls the DeleteRecording method of the FileService, which deletes the file from the file system.

If the caller has pressed any other key,  the action returns the TwiML to say that the key was invalid, replays the valid options, and listens for the next dialpad button to be pressed, so that they can correct their mistake.

Test the application

First, to test leaving a voicemail, remove your phone number from appsettings.json and run the application with the following command:

dotnet run

Call your Twilio phone number. You should be greeted with "Hello, please leave a message after the beep.". Leave your message. After a few seconds, you should see a new MP3 file saved under wwwroot/Voicemails directory prefixed with "New_" indicating that it has not been played to the user before (at least not saved by the user, so it's treated as a new message).

Update the appsettings.json by adding your number in the owner's list and call your Twilio phone number again.

This time you should be greeted with a message telling you have 1 new message and no saved messages, followed by the recorded message. You can then take action and choose what to do with the messages.

Conclusion

When determining the scope and business logic of this voicemail service, I used my own phone provider as a guide. I was able to add all the features that they provide, so by implementing this project you created yourself a voicemail service that actually matches the features of a real voicemail service. You can deploy this to a cloud provider and give your Twilio number to receive voicemails when you don't want to distribute your own phone number.

Apart from that, you learned how to deal with call recordings and user input. If you'd like to keep learning, I recommend taking a look at these articles:

Volkan Paksoy is a software developer with more than 15 years of experience, focusing mainly on C# and AWS. He’s a home lab and self-hosting fan who loves to spend his personal time developing hobby projects with Raspberry Pi, Arduino, LEGO and everything in-between. You can follow his personal blogs on software development at devpower.co.uk and cloudinternals.net.