Building an Interactive Voice Response (IVR) System with Python, Django and Twilio

February 12, 2020
Written by
Haki Benita
Contributor
Opinions expressed by Twilio contributors are their own

Building an Interactive Voice Response (IVR) System with Python, Django and Twilio

IVR stands for Interactive Voice Response system. It's a way for you to communicate with your users over the phone. IVR is operated by voice and by the DTMF tones that phones produce when pressing keys on the keypad.

Just like your web site, mobile app or chatbot, IVR is another way for you to interact with your users. IVR holds a unique set of features that makes it ideal under some circumstances:

  • Visual impairments: Users that rely on screen readers to navigate websites and mobile apps are used to interact with voice based interfaces.
  • No Internet access: Users in remote areas or in places where an Internet connection is not constantly available or very expensive.
  • Technologically challenged: Users that have trouble dealing with websites and apps, like the elderly, often find IVR much easier to operate.
  • No access to smartphones or a computer: Users that don't own a computer or a smartphone at all, or users such as drivers that at certain times cannot handle a smartphone.

In this tutorial you are going to build an IVR system using Python, Django and Twilio IVR.

The IVR system you are going to build will provide your users with information about movie showtimes. Users will call your Twilio phone number, select a theater and a movie, and get the next showtimes. To give you a taste of what you are going to build, below you can play a few recorded calls in which a caller interacts with this application:

Requirements

To follow along with this tutorial you are going to need:

Ready to setup your project? Let's get started!

Project Setup

In the first part of this project you are going to set up your development environment.

Create a Python Virtual Environment

To follow Python best practices you are going to create a python virtual environment. Using a virtual environment you can install packages and dependencies specific to each project without affecting other projects on your system.

Create a new directory called "twilio-ivr-test" for your project:

$ mkdir twilio-ivr-test
$ cd twilio-ivr-test

Next, create the virtual environment:

$ python -m venv venv

After running this command you'll see that a new directory called venv was created. This is the name of your virtual environment.

To start using the virtual environment you need to activate it. From your terminal, activate the virtual environment for the current shell:

$ source venv/bin/activate

If you are using Windows, enter the following commands to activate the virtual environment:

$ venv\Scripts\activate

While activated, any Python package you install will be installed only in the virtual environment.

Create a Django Project

Now that you have a fresh virtual environment, it's time to install your first package. From your terminal, install Django:

(venv) $ pip install django
Installing collected packages: asgiref, sqlparse, pytz, django
Successfully installed asgiref-3.2.3 django-3.0.2 pytz-2019.3 sqlparse-0.3.0

Great! Django is installed and you can create your project.

From your terminal, use Django's command line utility django-admin to create a new project called "ivr":

(venv) $ django-admin startproject ivr
(venv) $ cd ivr

The command creates a new directory called "ivr". This directory contains project settings such as database configurations, installed apps etc. You are going to make some adjustments to these configurations in a moment, but first, you need to complete setting up Django.

Django comes with a few built-in apps such as authentication, admin and session, that require database tables. By default, Django will use a file based database called SQLite. For production environments you can configure Django to use other database engines such as PostgreSQL or MySQL.

To complete the setup, run the initial database migrations:

(venv) $ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying sessions.0001_initial... OK

To make sure everything is working correctly, run a local server:

(venv) $ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).
February 02, 2020 - 07:47:49
Django version 3.0.2, using settings 'ivr.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

Open a browser and navigate to http://localhost:8000. If everything is working correctly you should see the following page:

django installed screenshot

Congratulations! You just created a Django project.

Setup a Local Tunnel Using ngrok

Your new Django server is running on your computer at port 8000. To accept calls, you need to make this server reachable from the Internet. To safely expose a local service running on your computer, you are going to use ngrok.

First, download ngrok and unzip to the current directory. Leave the Django server running and in a new terminal window execute the following command:

$ ./ngrok http 8000
ngrok by @inconshreveable

Session Status    online
Session Expires   7 hours, 54 minutes
Version           2.3.35
Region            United States (us)
Web Interface     http://127.0.0.1:4040
Forwarding        http://29960fa6.ngrok.io -> http://localhost:8000
Forwarding        https://29960fa6.ngrok.io -> http://localhost:8000

Connections       ttl     opn     rt1     rt5     p50     p90
                  2       0       0.00    0.00    0.05    0.05

On Windows computers you may need to execute ngrok directly, without the leading ./.

Ngrok is now accepting HTTP requests at http://29960fa6.ngrok.io, and forwarding them to your local server at port 8000. On your system the address is going to be different, so let’s use YOUR-LOCAL-TUNNEL as a placeholder for the unique subdomain ngrok assigns.

To test the tunnel, open a browser and navigate to the address ngrok assigned to you. You should get the following error message from Django:

django disallowed host screenshot

This means that the local tunnel is working, but Django blocked the connection because it didn't recognize the host. As instructed in the error message, add the host to the list of allowed hosts in Django's ivr/settings.py file:

# ivr/settings.py

ALLOWED_HOSTS = [
    '.ngrok.io',
]

Ngrok assigns a random URL every time you activate it. To avoid having to update this setting for every ngrok URL, you can use “.ngrok.io” (note the leading dot) to allow all subdomains from ngrok.io.

Try to navigate again to your ngrok address. You should now see Django's welcome page.

Buy a Twilio Phone Number

Login to your Twilio console. In the console, go to the Programmable Voice Dashboard, select Numbers from the sidebar, and hit "get a number".

To accept calls you need to get a phone number. In the console, choose "phone number" from the side bar and go to "buy a number".

twilio buy a number screenshot

Phone numbers have a wide variety of capabilities. In this tutorial you'll be working only with voice, so make sure to check the "voice" option in the "Capabilities" section. If you intend to use this number for other services such as SMS or Fax, make sure to mark those as well.

Next, hit the "search" button and pick one of the suggested numbers. Once you complete the process you'll own a phone number, and you'll be able to accept calls. For a detailed walk-through check out how to sign up and get a phone number.

Accept a Call

To begin this application, you are going to add a webhook to accept a call and greet the user.

A Webhook is a URL in your server that is triggered in response to some action. In the case of IVR, when a call comes in Twilio will make a request to the webhook in your server.

Twilio supports two methods of communicating with your server: POST and GET. If you choose to use GET, the information about the call such as the called phone number, call unique identified and so on, will be passed as URL parameters. URL parameters are not a secure way of transferring information because they are not encrypted and they appear in logs. You should generally avoid GET and use POST instead. In this tutorial you’ll use POST.

Install Twilio Helper Library for Python

To pass instructions between the caller and your server, Twilio is using a special markup language called "TwiML". TwinML is an XML document with special tags for various voice and call commands.

To make it easier to work with TwinML, Twilio provides a Helper Python library. In a new terminal window, activate your virtual environment and install the "twilio" package:

(venv) $ pip install twilio
Collecting twilio
Collecting six (from twilio)
Collecting PyJWT>=1.4.2 (from twilio)
Collecting requests>=2.0.0 (from twilio)
Collecting idna<2.9,>=2.5 (from requests>=2.0.0->twilio)
Collecting chardet<3.1.0,>=3.0.2 (from requests>=2.0.0->twilio)
Collecting certifi>=2017.4.17 (from requests>=2.0.0->twilio)
Collecting urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1 (from requests>=2.0.0->twilio)
Installing collected packages: six, PyJWT, idna, chardet, certifi, urllib3, requests, twilio
  Running setup.py install for twilio ... done
Successfully installed PyJWT-1.7.1 certifi-2019.11.28 chardet-3.0.4 idna-2.8 requests-2.22.0 six-1.14.0 twilio-6.35.3 urllib3-1.25.8

Now that twilio is installed in your virtual environment you will use it to generate TwiML.

Create a Django App

Django projects are organized in units called "apps". Apps are roughly similar to Python modules, and they contain views, models, urls and other related objects.

To start your movies app, create a new Django app called "movies":

(venv) $ python manage.py startapp movies

After running this command you'll see that a new directory called "movies" was added. To register your new app with Django, add the "movies" app to the list of installed apps in Django's ivr/settings.py:

# ivr/settings.py

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'movies',   # <---- Add this
]

To respond to http requests in your Django project, you need to create a view. In your new movies app, open the file views.py and add the following content:

# movies/views.py
from django.http import HttpRequest, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from twilio.twiml.voice_response import VoiceResponse


@csrf_exempt
def answer(request: HttpRequest) -> HttpResponse:
    vr = VoiceResponse()
    vr.say('Hello!')
    return HttpResponse(str(vr), content_type='text/xml')

Let's break it down:

  1. You created a new Django view called answer(). The view accepts an HttpRequest object and returns an HttpResponse object.
  2. You used the TwiML support in Twilio’s package to create a VoiceResponse instance.
  3. You used the function say of the voice response object to create the TwiML markup to greet the user 'Hello' using text-to-speech.
  4. You disabled Django's CSRF protection for this specific view using the decorator csrf_exempt.

Note about CSRF in Django: Django includes a middleware to protect your website against cross site request forgeries (CSRF). The middleware uses special inputs embedded in the HTML document to prevent requests from external sites to your server. It's not a good idea to disable this middleware, so instead, you disable it just for specific views that answer calls from Twilio. Twilio provides other security measures which we'll discuss later.

To reach your new view, you want the URL /movies/answer to point to it. Create a new file called urls.py in the movies app, and add the following content:

# movies/urls.py
from django.urls import path

from . import views

urlpatterns = [
  path('answer', views.answer, name='answer'),
]

You registered the view answer with the movies app urls. To reference the view in the code, Django let’s you provide a name for the path. In this case you named the url answer.

You are going to have more than one view, and you want all of them to be under the URL /movies/. To achieve this, you register all the views in the app’s url, and then register the app under the URL /movies/ in the project’s url list. This pattern is very common and it helps keep your project and URLs organized.

Add the URLs of the movies app to the project:


# ivr/urls.py
from django.contrib import admin
from django.urls import path, include

from movies.urls import urlpatterns as movies_urlpatterns

urlpatterns = [
    path('admin/', admin.site.urls),
    path('movies/', include(movies_urlpatterns)),
]

Django already registers its own admin app under admin/. Now, you registered your movies app under movies/.

To test your new view, first make sure your Django server is running in one terminal, and ngrok in another. Then, open a browser and navigate to http://YOUR-LOCAL-TUNNEL.ngrok.io/movies/answer. You should get the following response:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
    <Say>Hello!</Say>
</Response>

Great! Your view is working.

The response is using the TwinML markup. As you can see, it's just an XML document with special tags.

Connect the Twilio Phone Number to Your Application

From your Twilio console, choose "Phone Numbers” from the sidebar select your new number.

Scroll down to the "Voice & Fax" section and make sure “Accept Incoming” is set to “Voice Calls”. In the "A Call Comes In" add your ngrok address "http://YOUR-LOCAL-TUNNEL.ngrok.io/movies/answer" and hit “Save”:

twilio voice webhook configuration

Now when a new call to your Twilio number comes in, Twilio will issue a POST request to this URL.

For the moment of truth, call your Twilio number from your phone. If everything is working as expected, you should be greeted by a text-to-speech “Hello!”.

Congratulations, you just made your server talk!

Build an IVR System

Now that you have your local environment all set up to receive phone calls, the fun part starts!

Creating a Movie Database

Your IVR movie system is going to provide information about movie showtimes. To store the information about the movies and the showtimes you need to create some database models.

In your editor of choice, edit the file movies/models.py, and enter the following models:

# movies/models.py
from django.db import models


class Theater(models.Model):
    class Meta:
        verbose_name = 'Theater'
        verbose_name_plural = 'Theaters'

    name = models.CharField(max_length=50)
    address = models.TextField()
    digits = models.PositiveSmallIntegerField(unique=True)


class Movie(models.Model):
    class Meta:
        verbose_name = 'Movie'
        verbose_name_plural = 'Movies'

    title = models.CharField(max_length=50)
    digits = models.PositiveSmallIntegerField(unique=True)


class Show(models.Model):
    class Meta:
        verbose_name = 'Show'
        verbose_name_plural = 'Shows'

    movie = models.ForeignKey(Movie, on_delete=models.CASCADE)
    theater = models.ForeignKey(Theater, on_delete=models.CASCADE)
    starts_at = models.DateTimeField()

You created three models:

  • Theater: movie theater with a name and an address.
  • Movie: movie with a title.
  • Show: specific showtimes of movies in theatres.

The Movie and the Theater models also include a unique field called “digits”. You are going to use this field to mark the digits to enter for choosing a movie or theater in the IVR menu.

To create the tables in the database, generate migrations:

(venv) $ python manage.py makemigrations
Migrations for 'movies':
  movies/migrations/0001_initial.py
    - Create model Movie
    - Create model Theater
    - Create model Show

Migrations are part of Django ORM. They are used to create tables from models.

From your terminal, apply the migrations:

(venv) $ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, movies, sessions
Running migrations:
  Applying movies.0001_initial... OK

When applying migrations Django creates the tables in the database.

You are now ready to create some data. Enter Django shell from your terminal:

(venv) $ python manage.py shell

Create some data:

from movies.models import Theater

castro_theatre = Theater.objects.create(name='Castro Theatre', address='San Francisco, California', digits=1)
alamo_drafthouse = Theater.objects.create(name='Alamo Drafthouse', address='Austin, Texas', digits=2)

from movies.models import Movie

clockwork_orange = Movie.objects.create(title='Clockwork Orange', digits=1)
godfather = Movie.objects.create(title='The Godfather', digits=2)

import datetime
from movies.models import Show
from django.utils import timezone
now = timezone.now()
hour = datetime.timedelta(hours=1)
Show.objects.bulk_create([
  Show(theater=castro_theatre,   movie=clockwork_orange, starts_at=now + hour),
  Show(theater=castro_theatre,   movie=clockwork_orange, starts_at=now + 2.5 * hour),
  Show(theater=castro_theatre,   movie=godfather,        starts_at=now + 3 * hour),
  Show(theater=castro_theatre,   movie=godfather,        starts_at=now + 6 * hour),
  Show(theater=castro_theatre,   movie=godfather,        starts_at=now + 9 * hour),
  Show(theater=alamo_drafthouse, movie=clockwork_orange, starts_at=now),
  Show(theater=alamo_drafthouse, movie=clockwork_orange, starts_at=now + hour * 4),
  Show(theater=alamo_drafthouse, movie=clockwork_orange, starts_at=now + hour * 8),
  Show(theater=alamo_drafthouse, movie=godfather,        starts_at=now + hour),
  Show(theater=alamo_drafthouse, movie=godfather,        starts_at=now + 6 * hour),
])

The statements create your movie database:

  • Two theaters: Castro Theatre in San Francisco and Alamo Drafthouse in Austin, Texas.
  • Two movies: Clockwork Orange and The Godfather.
  • Several shows for each movie in each theater.

Now that you have movies, theaters and shows you can provide this data to your users.

Implementing the IVR View

If you are used to developing APIs or websites, then IVR is going to require some getting used to. One way of thinking about IVR is like an HTML form where the user submits one input at a time. Another way of thinking about IVR is like a conversation: you ask a question, the user responds, you ask another question and so on.

Accepting Input

To get a sense of how your movie IVR system is going to work, pretend it's already working and imagine how the conversation would go:

  • User: calls your number
  • IVR: say "Welcome to movie info. Please select a theatre: for T1 press 1, for T2 press 2"
  • User: select 1
  • IVR: say "Please select a movie: for M1 press 1, for M2 press 2"
  • User: select 2
  • IVR: "The movie M2 will be playing at theater T1 at t1, t2 and t3. Thank you for calling."

Now that you have a better sense of the mechanics, you can start writing some code. Open the file movies/views.py file, and replace the previous answer() function with the first part of the conversation:

# movies/views.py
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from django.views.decorators.csrf import csrf_exempt
from twilio.twiml.voice_response import VoiceResponse

from .models import Theater, Movie, Show


@csrf_exempt
def choose_theater(request: HttpRequest) -> HttpResponse:
   vr = VoiceResponse()
   vr.say('Welcome to movie info!')

   with vr.gather(
       action=reverse('choose-movie'),
       finish_on_key='#',
       timeout=20,
   ) as gather:
       gather.say('Please choose a theater and press #')
       theaters = (
           Theater.objects
           .filter(digits__isnull=False)
           .order_by('digits')
       )
       for theater in theaters:
           gather.say(f'For {theater.name} in {theater.address} press {theater.digits}')

   vr.say('We did not receive your selection')
   vr.redirect('')

   return HttpResponse(str(vr), content_type='text/xml')

Like before, you created a VoiceResponse object and used it to greet the user with the verb say.

You also used a new TwiML verb called gather to accept input from the user. To control how the input will be gathered from the user, you provided the following attributes:

  • finish_on_key: what key indicates the user has finished entering digits. Another option to finish is to use the attribute numDigits that finishes automatically after the user enters a specific number of digits. Since your movie database can have more than 10 theaters it is best to use the key "#" to indicate the finish.
  • action: What URL to go to next. In this case, after selecting a theater, the user will be directed to choosing movie. The URL can be relative. Twilio will use the base URL of the request as the base for the next step URL.
  • timeout: How long to wait for input from the user. In this case you'll wait for 20 seconds.

An interesting thing about gather is that it's implemented as a Python context processor. To better understand why a context processor is used here, take a look at the TwiML markup this view produces:

<?xml version="1.0" encoding="UTF-8"?>
<Response>
   <Say>Welcome to movie info!</Say>
   <Gather action="/movies/choose-movie" finishOnKey="#" timeout="20">
       <Say>Please choose a theater and press #</Say>
       <Say>For Castro Theatre in San Francisco, California press 1</Say>
       <Say>For Alamo Drafthouse in Austin, Texas press 2</Say>
   </Gather>
   <Say>We did not receive your selection</Say>
   <Redirect />
</Response>

The say commands that list the options are all nested inside the gather verb. When using this nested structure, Twilio will accept input from the user while the say commands are played out. If the user found the theater they were looking for they can press the button and move immediately to the next action. Commands after the gather command will not be executed in this case.

If the user did not select any option for the amount of time you set as timeout, the commands after the gather block are executed. This is a good place to instruct the user they should make a selection, and redirect them back to the same view.

Before you move on, update the URL /movies/answer to reference the new view. Replace the contents of movies/urls.py with this content:

from django.urls import path

from . import views

urlpatterns = [
    path('answer', views.choose_theater, name='choose-theater'),
]


The URL /movies/answer now references the view choose_theater. When a call comes in to Twilio, Django will use this view to process the request.

Processing Actions

At this point your system is reading the list of theaters to the user, and waiting for them to pick one. Once the user press "#", Twilio will make a request to the next action URL http://YOUR-LOCAL-TUNNEL.ngrok.io/movies/choose-movie.

Add another view in movies/views.py to handle the user’s theater selection:


# movies/views.py

@csrf_exempt
def choose_movie(request: HttpRequest) -> HttpResponse:
   vr = VoiceResponse()

   digits = request.POST.get('Digits')
   try:
       theater = Theater.objects.get(digits=digits)

   except Theater.DoesNotExist:
       vr.say('Please select a theater from the list.')
       vr.redirect(reverse('choose-theater'))

   else:
       with vr.gather(
           action=f'{reverse("list-showtimes")}?theater={theater.id}',
           finish_on_key='#',
           timeout=20,
       ) as gather:
           gather.say('Please choose a movie and press #')
           movies = (
               Movie.objects
               .filter(digits__isnull=False)
               .order_by('digits')
           )
           for movie in movies:
               gather.say(f'For {movie.title} press {movie.digits}')

       vr.say('We did not receive your selection')
       vr.redirect(reverse('choose-theater'))

   return HttpResponse(str(vr), content_type='text/xml')

Next, add the view in the movies/urls.py file:

# movies/urls.py
from django.urls import path

from . import views

urlpatterns = [
   path('answer', views.choose_theater, name='choose-theater'),
   path('choose-movie', views.choose_movie, name='choose-movie'),  # <--- add this
]

Just like the previous view, you created a VoiceReponse object that you’ll use throughout the view to generate TwiML.

This view also accepts the user’s theater selection. You have the digits the user entered from the request body in request.POST['Digits']. So first, you validate the data entered by the user by trying to fetch the Theater object identified by the digits.

If you did not find a theater matching the digits the user entered you redirect the user back to the previous view using the redirect verb.

If you find a matching theater, you can proceed to choosing a movie. Choosing a movie is very similar to choosing a theater. You use gather to accept a selection from the user, and then list the movie options.

Unlike before, your action URL now contains the selected theater in the theater query string argument. The next view is going to need this information in order to find showtimes. Passing previously selected values in the request is a way of maintaining state between requests.

Finishing Up

The last action you need to handle is when the user selected a movie. Create another view that accepts the user movie selection and lists showtimes:  

# movies/views.py
import datetime
from django.utils import timezone

@csrf_exempt
def list_showtimes(request: HttpRequest) -> HttpResponse:
   vr = VoiceResponse()

   digits = request.POST.get('Digits')
   theater = Theater.objects.get(id=request.GET['theater'])

   try:
       movie = Movie.objects.get(id=digits)

   except Movie.DoesNotExist:
       vr.say('Please select a movie from the list.')
       vr.redirect(f'{reverse("choose-movie")}?theater={theater.id}')

   else:
       # User selected movie and theater, search shows in the next 12 hours:
       from_time = timezone.now()
       until_time = from_time + datetime.timedelta(hours=12)
       shows = list(
           Show.objects.filter(
               theater=theater,
               movie=movie,
               starts_at__range=(from_time, until_time),
           ).order_by('starts_at')
       )
       if len(shows) == 0:
           vr.say('Sorry, the movie is not playing any time soon in this theater.')
       else:
           showtimes = ', '.join(show.starts_at.time().strftime('%I:%M%p') for show in shows)
           vr.say(f'The movie {movie.title} will be playing at {theater.name} at {showtimes}')

       vr.say('Thank you for using movie info!')
       vr.hangup()

   return HttpResponse(str(vr), content_type='text/xml')

Don’t forget to register the view in urls.py:

from django.urls import path

from . import views

urlpatterns = [
   path('answer', views.choose_theater, name='choose-theater'),
   path('choose-movie', views.choose_movie, name='choose-movie'),
   path('list-showtimes', views.list_showtimes, name='list-showtimes'),  # <--- Add this
]

You first got the ID of the selected theater from the query parameter theater. You then  validated the user’s movie selection. If the movie selection is invalid, you let the user know and redirect them back to the movie selection view.

If the movie selection is valid, you fetch the showtime in the selected theater in the next 12 hours, and read them to the user.

Securing the IVR View

Now that you have a functional IVR system it's time to tighten things up a bit. Twilio provides a few features to keep your IVR system secure.

Authenticating Requests From Twilio

To prevent any user from interacting with your view, it's necessary that you make sure requests to your view are originating from Twilio.

The Twilio helper library for Python includes a class named RequestValidator for this purpose.

To validate a request coming from Twilio you first need to get your auth token. Go to your Programmable Voice Dashboard, click the little link on the top right "show api credentials" and copy the auth token.

twilio account sid and auth token screenshot

The auth token should be kept secret and secure. One way to keep it safe is using an environment variable. Add a new variable at the bottom of Django’s settings.py file that loads the token from an environment variable:

# ivr/settings.py
# Twilio
TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN', '')

Now add the auth token as an environment variable in your computer.

Once the token is available to Django you can create a function to validate requests. Add the following at the top of your movies/views.py:

# movies/views.py
from django.conf import settings
from django.http import HttpRequest
from django.core.exceptions import SuspiciousOperation
from twilio.request_validator import RequestValidator
request_validator = RequestValidator(settings.TWILIO_AUTH_TOKEN)


def validate_django_request(request: HttpRequest):
   try:
       signature = request.META['HTTP_X_TWILIO_SIGNATURE']
   except KeyError:
       is_valid_twilio_request = False
   else:
       is_valid_twilio_request = request_validator.validate(
           signature = signature,
           uri = request.get_raw_uri(),
           params = request.POST,
       )
   if not is_valid_twilio_request:
       # Invalid request from Twilio
       raise SuspiciousOperation()

Twilio adds a special header called "X-Twilio-Signature" to every request made to your server. The header contains a signature that Twilio generated based on the URL and the contents of the request. To validate requests you create a RequestValidator object with your private auth token. When you get a request, you provide the validator with the URL, the contents of the request and the signature. If the request was made by Twilio it will validate successfully, otherwise it will fail.

To integrate this check into your view, validate the request at the beginning of your view:

# movies/views.py
@csrf_exempt
def answer(request: HttpRequest) -> HttpResponse:
    validate_django_request(request)
    # the rest of the view…

After adding this check, only Twilio will be able to issue requests to your view.

Going Farther

This tutorial highlights some of Twilio IVR features, but there are plenty more features you can use to build your IVR system.

Text-to-Speech

In this tutorial you used the say verb to generate speech from text. Twilio's Text-to-Speech (TTS) contains many more languages, voice and pronunciation features and support for SSML, a speech synthesis markup language.

Accept Payments

TwiML has a nice verb called "pay" that you can use to accept credit card payments. Credit card information is considered sensitive, so you need to be extra careful with it. Accepting payments require some additional configuration in the dashboard and is compliant with various regulations.

Play and Record

Using the TwiML "play" and "record" you can play audio files directly from your server and record the user’s response. For example, to make your IVR system special you can play a unique sound at the beginning of the call.

Conclusion

In this tutorial you learned how to:

  • Create a Python virtual environment.
  • Setup a new Django project.
  • Run a secure tunnel to your local Django server using ngrok.
  • Accept phone calls from Twilio.
  • Build an IVR system to interact with your users over the phone using TwiML markup.
  • Secure your IVR view.

You are now ready to build your own IVR system!

The source files for this tutorial are available in this Gist.

Haki is a software developer and a technical lead. He takes special interest in databases, web development, software design and performance tuning.