Respond to SMS and Voice webhooks using F# and Minimal APIs

February 08, 2023
Written by
Reviewed by

Respond to  SMS and Voice webhooks  with F# and Minimal APIs

Twilio's SMS and voice webhooks let you respond to messages and voice calls using TwiML (the Twilio Markup Language). You can implement these webhooks using any F# web framework, but in this tutorial, you'll use the built-in ASP.NET Core Minimal API template.

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.

Set up your F# Minimal API project

Open your preferred shell and use the .NET CLI to create a new empty ASP.NET Core project with the F# language argument:

dotnet new web -o FsharpTwilioWebhook --language F#
cd FsharpTwilioWebhook

Twilio provides some libraries to speed up the development of your Twilio applications, which you'll use for this project. Add the Twilio helper library for .NET and the Twilio helper library for ASP.NET Core using the following commands:

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

Now you are ready to write some code!

Implement the SMS and Voice webhook

When implementing the SMS or voice webhook, you're expected to respond with TwiML, which instructs Twilio how to respond to a message or call.

Open your project using your preferred IDE and update the Program.fs file with the following code:

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Twilio.AspNet.Core
open Twilio.TwiML

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapPost("/message", Func<TwiMLResult>(
        fun () -> MessagingResponse().Message("Ahoy!").ToTwiMLResult())
    ) |> ignore

    app.MapPost("/voice", Func<TwiMLResult>(
        fun () -> VoiceResponse().Say("Ahoy!").ToTwiMLResult())
    ) |> ignore

    app.Run()

    0 // Exit code

Start your application using this command:

dotnet run

Your application will print out the localhost URL(s) the web server is listening to, including the port number. In the upcoming commands, replace [YOUR_LOCALHOST_URL] with one of those localhost URLs.

To quickly test this without configuring your Twilio phone number, use the following cURL commands or PowerShell:

curl -X POST [YOUR_LOCALHOST_URL]/message
curl -X POST [YOUR_LOCALHOST_URL]/voice

You should get the following TwiML as the responses:

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

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, which 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. (If Phone Numbers isn't on the left pane, click Explore Products and then on Phone Numbers.)

The voice form on the Twilio phone number configuration page. Under the label "A CALL COMES IN", there&#x27;s a dropdown set to "Webhook", with a text field next to it, set to the public webhook URL, with another dropdown next to it, which is set to "HTTP POST".

Then, on the phone number configuration page, locate the "A CALL COMES IN" section. Underneath that:

  • set the first dropdown to Webhook,
  • set the text box next to it to the ngrok forwarding URL — adding on the /voice path,
  • and set the dropdown after the text box to "HTTP POST".

Follow the same steps at the "A MESSAGE COMES IN" section, but use the /message path instead. Then, click Save.

Now, call and text the Twilio phone number. When calling, you should hear "Ahoy". When texting, you should receive a response like "Ahoy!".

Read the incoming message and call details

Responding with a hard-coded message is a great first step, but you'd probably want to respond dynamically instead. Let's add some code to read the incoming message body and repeat it back.
Update the Program.fs file:

open System
open System.Threading
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Hosting
open Twilio.AspNet.Core
open Twilio.TwiML

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapPost("/message", Func<HttpRequest, CancellationToken, Task<TwiMLResult>>(fun request ctx -> 
        task {
            let! form = request.ReadFormAsync(ctx).ConfigureAwait(false)
            let body = form["Body"]
            return MessagingResponse().Message($"You said: {body}!").ToTwiMLResult()
        })
    ) |> ignore

    app.MapPost("/voice", Func<TwiMLResult>(
        fun () -> VoiceResponse().Say("Ahoy!").ToTwiMLResult())
    ) |> ignore

    app.Run()

    0 // Exit code

Now, the /message endpoint reads the data that is passed in as a form and retrieves the Body parameter, which holds the body of the text message. That body is then echoed back to the sender.

Let's also make the /voice webhook more interesting by asking a question and then responding differently based on the answer. Update the code as shown below:

open System
open System.Threading
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Hosting
open Twilio.AspNet.Core
open Twilio.TwiML

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapPost("/message", Func<HttpRequest, CancellationToken, Task<TwiMLResult>>(fun request ctx -> 
        task {
            let! form = request.ReadFormAsync(ctx).ConfigureAwait(false)
            let body = form["Body"]
            return MessagingResponse().Message($"You said: {body}!").ToTwiMLResult()
        })
    ) |> ignore

    app.MapPost("/voice", Func<TwiMLResult>(fun () -> 
      VoiceResponse()
       .Say("Which is better? Press 1 for cake, 2 for pie.")
       .Gather(
           action = Uri("/voice/response", UriKind.Relative),
           numDigits = 1
        )
       // redirect back to current endpoint if no response
       .Redirect(Uri("/voice", UriKind.Relative)) 
       .ToTwiMLResult()
    )) |> ignore
    
    app.MapPost("/voice/response", Func<HttpRequest, CancellationToken, Task<TwiMLResult>>(fun request ctx -> 
        task {
            let! form = request.ReadFormAsync(ctx).ConfigureAwait(false)
            let result = form["Digits"].ToString()
            return VoiceResponse()
               .Say(if result = "1" then "The cake is a lie."
                    else if result = "2" then "Yum, pie."
                    else "You do you.")
               .ToTwiMLResult()
        })
    ) |> ignore

    app.Run()

    0 // Exit code

Stop the application by pressing ctrl + c and start it again using dotnet run.
Text and call your Twilio number to try out the new functionality.

Secure your webhooks

Now that your web application is publicly accessible, it's also accessible to malicious actors. Luckily, the Twilio helper library for ASP.NET Core has a ValidateTwilioRequestFilter that validates that the HTTP request originates from Twilio. 

Update Program.fs as below to use it:

open System
open System.Threading
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.HttpOverrides
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Twilio.AspNet.Core
open Twilio.TwiML

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)

    builder.Services.AddTwilioRequestValidation() |> ignore
    builder.Services.Configure<ForwardedHeadersOptions>(
      fun (options: ForwardedHeadersOptions) -> options.ForwardedHeaders <- ForwardedHeaders.All
    ) |> ignore
    
    let app = builder.Build()
    
    app.UseForwardedHeaders() |> ignore

    let twilioEndpoints = app.MapGroup("").ValidateTwilioRequest()

    twilioEndpoints.MapPost("/message", Func<HttpRequest, CancellationToken, Task<TwiMLResult>>(fun request ctx -> 
        task {
            let! form = request.ReadFormAsync(ctx).ConfigureAwait(false)
            let body = form["Body"]
            return MessagingResponse().Message($"You said: {body}!").ToTwiMLResult()
        })
    ) |> ignore

    twilioEndpoints.MapPost("/voice", Func<TwiMLResult>(fun () -> 
      VoiceResponse()
       .Say("Which is better? Press 1 for cake, 2 for pie.")
       .Gather(
           action = Uri("/voice/response", UriKind.Relative),
           numDigits = 1
        )
       // redirect back to current endpoint if no response
       .Redirect(Uri("/voice", UriKind.Relative)) 
       .ToTwiMLResult()
    )) |> ignore
    
    twilioEndpoints.MapPost("/voice/response", Func<HttpRequest, CancellationToken, Task<TwiMLResult>>(fun request ctx -> 
        task {
            let! form = request.ReadFormAsync(ctx).ConfigureAwait(false)
            let result = form["Digits"].ToString()
            return VoiceResponse()
               .Say(if result = "1" then "The cake is a lie."
                    else if result = "2" then "Yum, pie."
                    else "You do you.")
               .ToTwiMLResult()
        })
    ) |> ignore

    app.Run()

    0 // Exit code

The code creates an endpoint group for all the Twilio related endpoints, and adds ValidateTwilioRequestFilter as an EndpointFilter for the group. As a result, any requests that did not originate from Twilio will get a 403 Forbidden response. Otherwise, the endpoint will handle the request as before.

The request validation uses the URL of the incoming request as part of the validation check. The forwarded headers middleware is used to make sure the request URL uses the ngrok forwarding URL and not the localhost URL.

The request validator will use the auth token configured at Twilio:RequestValidation:AuthToken. 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]

When you restart your application, everything should continue to work, but any HTTP request not coming from Twilio or your localhost will be rejected.

Next steps

You learned how to read incoming SMS and calls, how to respond to them, and how to secure your Twilio webhooks, all using F# and ASP.NET Core Minimal APIs.

In addition to receiving SMS and responding to incoming SMS, you can also send SMS from your application. Learn how to send SMS from F# using Twilio.

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.