Respond to SMS and Phone Calls using FastEndpoints and Twilio

January 17, 2023
Written by
Reviewed by

Respond to SMS and Phone Calls using FastEndpoints and Twilio

Twilio's messaging and voice webhooks let you respond to messages and voice calls using TwiML. You can implement these webhooks using different frameworks in ASP.NET Core such as Minimal APIs, MVC, Web APIs, and more. While Minimal APIs are a great way to handle HTTP requests, some developers prefer alternative frameworks such as FastEndpoints.

FastEndpoints is focused on accepting JSON via requests and responding back with JSON, and thus doesn't have a built-in way to respond with TwiML (or XML). In this tutorial, you'll learn how to extend FastEndpoints to respond with TwiML.

Prerequisites

Here’s what you will need to follow along:

You can find the source code for this tutorial on GitHub. Use it if you run into any issues, or submit an issue if you run into problems.

Create a new FastEndpoints project

Open your preferred shell and use the .NET CLI to create a new ASP.NET Core project and add the FastEndpoints package.

dotnet new web -n TwilioFastEndpoints
cd TwilioFastEndpoints
dotnet add package FastEndpoints

Now, open Program.cs and add the following lines to add FastEndpoints to your app:


global using FastEndpoints;

var builder = WebApplication.CreateBuilder();

builder.Services.AddFastEndpoints();

var app = builder.Build();

app.UseFastEndpoints(c => {
    // everything is anonymous for this sample
    c.Endpoints.Configurator = epd => epd.AllowAnonymous();
});

app.Run();

This will configure FastEndpoints which will use reflection to find all the endpoints it needs to wire up.

Now, create a new file named MessageEndpoint.cs and add the following code:

namespace TwilioFastEndpoints;

public class MessageEndpoint : Endpoint<EmptyRequest>
{
    public override void Configure()
    {
        Post("/message");
    }

    public override async Task HandleAsync(EmptyRequest request, CancellationToken ct)
    {
        await SendStringAsync("Ahoy!");
    }
}

This creates a new endpoint that listens for HTTP POST requests at /message and replies with "Ahoy!".

Let's test it out. Start your application using dotnet run. In the upcoming commands, replace [YOUR_LOCALHOST_URL] with the localhost URL you can find in the output of dotnet run.

Open another shell and run either the following cURL command or PowerShell:

curl -X POST [YOUR_LOCALHOST_URL]/message

You should see the response is "Ahoy!".

Respond with Messaging TwiML

You'll need some of the classes and APIs from the Twilio and Twilio.AspNet.Core package to respond with TwiML. Add the packages using the .NET CLI:

dotnet add package Twilio
dotnet add package Twilio.AspNet.Core

To implement the messaging or voice webhook, you'll need to respond with TwiML instead of plain-text. In this particular case, the messaging flavor of TwiML.

Update MessageEndpoint.cs with the following code:


using System.Xml.Linq;
using Twilio.AspNet.Common;
using Twilio.TwiML;

namespace TwilioFastEndpoints;

public class MessageEndpoint : Endpoint<SmsRequest>
{
    public override void Configure()
    {
        Post("/message");
        Description(b => b.Accepts<SmsRequest>("application/x-www-form-urlencoded"));
    }

    public override async Task HandleAsync(SmsRequest request, CancellationToken ct)
    {
        var messagingResponse = new MessagingResponse();
        messagingResponse.Message($"Ahoy {request.From}!");

        var httpResponse = HttpContext.Response;
        httpResponse.StatusCode = StatusCodes.Status200OK;
        httpResponse.Headers.ContentType = "application/xml";
        var xdocument = messagingResponse.ToXDocument();
        await xdocument.SaveAsync(
            httpResponse.Body,
            SaveOptions.DisableFormatting,
            ct
        );
        ResponseStarted = true;
    }
}

Instead of accepting an EmptyRequest, the MessageEndpoint now accepts an SmsRequest, a class provided by the helper library for ASP.NET. When Twilio sends an HTTP POST request to your endpoint, Twilio encodes the data as a form. This is why inside the Configure method, the endpoint is configured to accept the application/x-www-form-urlencoded content-type.

HandleAsync now generates TwiML that looks like this:

<?xml version="1.0" encoding="utf-8"?>
<Response>
  <Message>Ahoy +1234567890!</Message>
</Response>

Since there's no built-in way to write XML or TwiML, you could serialize messagingResponse to a string and use the SendStringAsync method again. Instead of doing that, I decided to get the XDocument and save it directly to the HTTP response body for better performance. Finally, to tell FastEndpoints that the response is already handled, the ResponseStarted is set to true.

Stop your .NET application by pressing ctrl + c and then run it again using dotnet run.

To quickly test this without configuring your Twilio Phone Number, use the following cURL command or PowerShell:

curl -X POST [YOUR_LOCALHOST_URL]/message -d From=%2B1234567890

Create a SendTwiML extension method

If you need to have multiple endpoints that respond with TwiML, you wouldn't want to duplicate this code over and over. So you could either create a base class and add a protected SendTwiML method containing the reusable logic, or create an extension method. Either solution would be fine, but I opted for an extension method, just in case you're already inheriting from another base class.

Create a new file EndpointExtensions.cs and add the following code:

using System.Xml.Linq;
using Twilio.TwiML;

namespace TwilioFastEndpoints;

public static class EndpointExtensions
{
    public static async Task SendTwiML<TRequest, TResponse>(
        this Endpoint<TRequest, TResponse> endpoint,
        TwiML twiml,
        CancellationToken ct
    ) where TRequest : notnull, new()
    {
        var httpResponse = endpoint.HttpContext.Response;
        httpResponse.StatusCode = StatusCodes.Status200OK;
        httpResponse.Headers.ContentType = "application/xml";
        var xdocument = twiml.ToXDocument();
        await xdocument.SaveAsync(
            httpResponse.Body,
            SaveOptions.DisableFormatting,
            ct
        );
        endpoint.ResponseStarted = true;
    }
}

This extension method encapsulates the same logic for writing the TwiML to the response body.

Now back in MessageEndpoint.cs, use the extension method:


using Twilio.AspNet.Common;
using Twilio.TwiML;

namespace TwilioFastEndpoints;

public class MessageEndpoint : Endpoint<SmsRequest>
{
    public override void Configure()
    {
        Post("/message");
        Description(b => b.Accepts<SmsRequest>("application/x-www-form-urlencoded"));
    }

    public override async Task HandleAsync(SmsRequest request, CancellationToken ct)
    {
        var messagingResponse = new MessagingResponse();
        messagingResponse.Message($"Ahoy {request.From}!");
        await this.SendTwiML(messagingResponse, ct);
    }
}

Respond with Voice TwiML

You learned how to respond with message TwiML, but to handle voice calls you need to use Voice TwiML. It's time to create another endpoint. Create a new file named VoiceEndpoint.cs, and add the following code:

using Twilio.AspNet.Common;
using Twilio.TwiML;

namespace TwilioFastEndpoints;

public class VoiceEndpoint : Endpoint<VoiceRequest>
{
    public override void Configure()
    {
        Post("/voice");
        Description(b => b.Accepts<VoiceRequest>("application/x-www-form-urlencoded"));
    }
    
    public override async Task HandleAsync(VoiceRequest request, CancellationToken ct)
    {
        var voiceResponse = new VoiceResponse();
        voiceResponse.Say($"Ahoy {AddSpacesBetweenCharacters(request.From)}!");
        await this.SendTwiML(voiceResponse, ct);
    }

    // to spell out individual numbers in <Say>, add space between each number
    public string AddSpacesBetweenCharacters(string s) 
        => s.Aggregate("", (c, i) => c + i + ' ');
}

Stop and start your application again, and then use the following cURL or PowerShell command to test the new endpoint:

curl -X POST [YOUR_LOCALHOST_URL]/voice -d From=%2B1234567890

The response should look like this:

<?xml version="1.0" encoding="utf-8"?>
<Response>
  <Say>Ahoy + 1 2 3 4 5 6 7 8 9 0 !</Say>
</Response>

Note how the AddSpacesBetweenCharacters method adds spaces in between each character of the phone number. By formatting the response like this, the text-to-speech translation will spell each individual number out instead of saying it as one big number.

Configure the webhooks on your Twilio Phone Number

To see how this would behave in your Twilio application, you'll need to configure the message and voice webhook on your Twilio Phone Number. But before you can do that, you'll need to make your locally running project accessible to the internet. You can quickly do this using ngrok which creates a secure tunnel to the internet for you.

Leave your .NET application running, and run the following command in a separate shell:

ngrok http [YOUR_LOCALHOST_URL]

ngrok will print the Forwarding URL you'll need to publicly access your local application.

Now, go to the Twilio Console in your browser, use the left navigation to navigate to Phone Numbers > Manage > Active Numbers, and then select the Twilio Phone Number you want to test with.

On the phone number configuration page, locate the "A CALL COMES IN" section. Underneath that, set the first dropdown to Webhook, the text box to the ngrok Forwarding URL, adding on the /voice path. Then, set the second dropdown to "HTTP POST". Follow the same steps at the "A MESSAGE COMES IN" section, but use the /message path instead, and then click Save.

Now call and text the Twilio Phone Number. When calling, you should hear "Ahoy plus one two three four five …!". When texting, you should receive a response like "Ahoy +1234567890!".

Securing your webhooks

Now that your web application is publicly accessible, it's also accessible to malicious actors. You can use the RequestValidationHelper from the Twilio.AspNet.Core package to validate that the incoming HTTP request originates from Twilio. To do this in a reusable fashion, you can create a pre-processor.

Create a new file ValidateTwilioRequestProcessor.cs and add the following code:

using FluentValidation.Results;
using Microsoft.Extensions.Options;
using Twilio.AspNet.Core;

namespace TwilioFastEndpoints;

public class ValidateTwilioRequestProcessor<TRequest> : IPreProcessor<TRequest>
{
    public Task PreProcessAsync(
        TRequest request, 
        HttpContext httpContext, 
        List<ValidationFailure> failures, 
        CancellationToken ct
    )
    {
        var httpRequest = httpContext.Request;
        var options = httpContext.Resolve<IOptions<TwilioRequestValidationOptions>>().Value;
        if(string.IsNullOrEmpty(options.AuthToken)) 
            throw new Exception("Twilio Auth Token not configured.");
        
        var baseUrlOverride = options.BaseUrlOverride?.TrimEnd('/');

        string? urlOverride = null;
        if (options.BaseUrlOverride != null)
        {
            urlOverride = $"{baseUrlOverride}{httpRequest.Path}{httpRequest.QueryString}";
        }
        
        if (!RequestValidationHelper.IsValidRequest(httpContext, options.AuthToken, urlOverride, options.AllowLocal ?? true))
        {
            return httpContext.Response.SendForbiddenAsync(ct);
        }

        return Task.CompletedTask;
    }
}

This code will respond with a 403 Forbidden response if the request did not originate from Twilio. , Otherwise, the endpoint will handle the request.

This code uses the TwilioRequestValidationOptions which still needs to be configured.

Add the builder.Services.AddTwilioRequestValidation() method to the Program.cs file:


global using FastEndpoints;
using Twilio.AspNet.Core;

var builder = WebApplication.CreateBuilder();

builder.Services.AddFastEndpoints();
builder.Services.AddTwilioRequestValidation();

var app = builder.Build();

app.UseFastEndpoints(c => {
    // everything is anonymous for this sample
    c.Endpoints.Configurator = epd => epd.AllowAnonymous();
});

app.Run();

This will load the TwilioRequestValidationOptions from the .NET configuration.

Next, update the appsettings.json file:

{
  ...
  "Twilio": {
    "RequestValidation": {
      "AuthToken": "[USE_USER_SECRETS]",
      "BaseUrlOverride": "[NGROK_FORWARDING_URL]",
      "AllowLocal": false
    }
  }
}

Replace [NGROK_FORWARDING_URL] with the Forwarding URL given by ngrok.

Since the Auth Token is a secret that you should not share, you should avoid hard-coding it such as putting it in your appsettings.json file, or any other way it could end up in your source control history. Instead, use the Secrets Manager aka user-secrets, environment variables, or a vault service.

Run the following commands to initialize user-secrets:

dotnet user-secrets init

Then grab the Auth Token from your Twilio Account and set it using this command:

dotnet user-secrets set Twilio:RequestValidation:AuthToken [YOUR_AUTH_TOKEN]

Lastly, you need to add the pre-processor inside the Configure method of your endpoints:

MessageEndpoint.cs:

public override void Configure()
{
    ...
    PreProcessors(new ValidateTwilioRequestProcessor<SmsRequest>());
}

VoiceEndpoint.cs:

public override void Configure()
{
    ...
    PreProcessors(new ValidateTwilioRequestProcessor<VoiceRequest>());
}

Now that everything is configured, restart your application and try sending the same HTTP requests using cURL or PowerShell as before. The response should now be 403 Forbidden. However, when you text or call your Twilio Phone Number, everything should continue to work.

Next steps

You learned how to extend FastEndpoints to respond with TwiML and how to secure your FastEndpoints so only Twilio can send it HTTP requests.

Here are a couple more resources to further your learning on ASP.NET Core and Twilio:

We can't wait to see what you build. Let us know!

Niels Swimberghe is a Belgian American software engineer and technical content creator at Twilio. Get in touch with Niels on Twitter @RealSwimburger and follow Niels’ personal blog on .NET, Azure, and web development at swimburger.net.