Dynamic IVR using JSON Menus - Python
This code will show you how you can use a JSON-defined menu in order to easily create an IVR Phone System with Python & Flask. We will be using the SignalWire Team as an example, but you can easily change the verbiage to fit your company's needs instead. Once you have modified this script to fit your company and point to the correct agents/departments, you only need to expose the script to the public and attach it as a webhook for handling inbound calls to one of your SignalWire DIDs.
What do I need to run this code?
View the full code on our Github here!
You will need the Flask framework and the SignalWire Python SDK downloaded.
You will need a SignalWire phone number as well as your API Credentials (API Token, Space URL, and Project ID) which can all be found in an easily copyable format within the API tab of your SignalWire portal.
How to Run Application
To run the application, execute export FLASK_APP=app.py
then run flask run
You may need to use an SSH tunnel for testing this code if running on your local machine. – we recommend ngrok. You can learn more about how to use ngrok here.
Step by Step Code Walkthrough
Within the Github repository you will find 3 files:
- app.py
- menus.json
- readme.MD
We can ignore the readme and we will start by walking through the configuration of the menus.json file and follow it up with a step-by-step walkthrough of app.py.
Configuring the menus.json file
In this file, you will need to define the different menus that you would like to rotate through, the different options within each menu, and the verbiage/action to say/take when a particular option is selected.
In this example, we have four menus: main
, sales
, tech
, and salesteam
. Each menu has dtmf options (1, 2, or 3) and a verbiage/action associated with each dtmf option. The verbiage specifies what message will be spoken using <Say>
in the app.py file. The action tells our code which Flask route to redirect and what parameters to pass on to the route. To modify this part to fit your needs, you will need to specify the verbiage you want to use but also change the menu names, parameter names, and the route names.
{
"main": {
"1": {
"verbiage": "If you'd like to connect to our Sales team, press 1",
"action": "/get_menu?menu=sales"
},
"2": {
"verbiage": "If you'd like to connect to our Support Engineering Team, press 2",
"action": "/get_menu?menu=tech"
}
},
"sales": {
"1": {
"verbiage": "If you are already a customer and would like to connect to your Account Executive, press 1",
"action": "/get_menu?menu=salesteam"
},
"2": {
"verbiage": "For assistance with a purchase or all other sales inquiries, press 2",
"action": "/dial_sales?name=general&group=sales_support"
}
},
"tech": {
"1": {
"verbiage": "If you are having a problem with SignalWire Work, press 1",
"action": "/dial_support?group=work_support"
},
"2": {
"verbiage": "If you are having a problem with SignalWire Cloud, press 2",
"action": "/dial_support?group=cloud_support"
},
"3": {
"verbiage": "If you are having a problem with SignalWire Stack, press 3",
"action": "/dial_support?group=stack_support"
}
},
"salesteam": {
"1": {
"verbiage": "If you would like to speak to Bob, press 1",
"action": "/dial_sales?name=bob&group=sale_partners"
},
"2": {
"verbiage": "If you would like to speak to Alice, press 2",
"action": "/dial_sales?name=alice&group=sale_partners"
},
"3": {
"verbiage": "If you would like to speak to Charlie, press 3",
"action": "/dial_sales?name=charlie&group=sale_partners"
}
}
}
Configuring app.py
We start with the necessary imports and instantiate a Flask app:
import json
from signalwire.voice_response import VoiceResponse, Say, Gather
from flask import Flask, request
app = Flask(__name__)
Before we begin our flask routes, we need to define a few functions that will be used later in the code first. The first function is choose_voicemail(args)
. In this function, we use a Python dictionary named switcher
to perform the same functions as a Switch statement in other languages. You can see here that the dictionary keys are the same as the group
parameter assigned in the JSON menus file. Each key corresponds with a diffent string that we will use to insert into a response.say()
later in the code. If for some reason the group
parameter is not defined within the dictionary, errorOccurred
is called instead, leading to a generic voicemail message.
def choose_voicemail(args):
switcher = {
"sale_partners": "Thank you for calling the SignalWire Sales Team. Your requested Account Executive is "
"unavailable at the moment. "
"Please leave us a detailed message including your account name, phone number, "
"and a description of how we can help. "
"A member of our team will reach out as soon as possible. "
"The recording will begin after the beep. Press the pound key when finished. "
,
"sales_support": "Thank you for calling the SignalWire Sales Team. We are unavailable to take your call at "
"this time. "
"Please leave us a detailed message including your name, phone number, "
"and the product you are interested in purchasing/learning more about. "
"A member of our team will reach out as soon as possible. "
"The recording will begin after the beep. Press the pound key when finished. ",
"work_support": "Thank you for calling the SignalWire Work Support Team. We are unavailable to take your call "
"at "
"this time. "
"Please leave us a detailed message including your name, phone number, "
"the name of your SignalWire Work instance, and a description of the issue that you're "
"encountering "
"A member of our team will reach out as soon as possible. "
"The recording will begin after the beep. Press the pound key when finished. ",
"cloud_support": "Thank you for calling the SignalWire Cloud Support Team. We are unavailable to take your "
"call at "
"this time. "
"Please leave us a detailed message including your name, phone number, "
"the name of your Account, SignalWire Space Name, and a description of the issue that you're "
"encountering "
"A member of our team will reach out as soon as possible. "
"The recording will begin after the beep. Press the pound key when finished. ",
"stack_support": "Thank you for calling the SignalWire Stack Support Team. We are unavailable to take your "
"call at "
"this time. "
"Please leave us a detailed message including your name, phone number, "
"the name of your SignalWire Stack Account, and a description of the issue that you're "
"encountering "
"A member of our team will reach out as soon as possible. "
"The recording will begin after the beep. Press the pound key when finished. ",
}
errorOccurred = "An error occurred. We could not route to the correct agent. Please record a message with detailed " \
"information and we will get back to you as soon as possible. "
return switcher.get(args, errorOccurred)
We need to do the exact same thing with the functions choose_salesman()
and choose_support()
, except in this case we will store a phone number as the value instead of a string. In these two functions, the parameters group
and name
are passed from the menus.json file.
# take chosen salespersons name and point to their SignalWire line
def choose_salesman(args):
switcher = {
"alice": '+12342186054',
"bob": '+12342186054',
"charlie": '+12342186054',
"general": '+12342186054',
}
errorOccurred = "An error occurred. We could not route to the correct agent. Please record a message with detailed " \
"information and we will get back to you as soon as possible. "
return switcher.get(args, errorOccurred)
# take chosen support department and point to their SignalWire line
def choose_support(args):
switcher = {
"cloud_support": '+12342186054',
"stack_support": '+12342186054',
"work_support": '+12342186054',
}
errorOccurred = "An error occurred. We could not route to the correct agent. Please record a message with detailed " \
"information and we will get back to you as soon as possible. "
return switcher.get(args, errorOccurred)
You will need to replace the phone numbers above with the correct phone number for each agent or department. However, for the sake of testing easily, you can always use a SignalWire DID with the incoming call handling webhook pointed at an XML Bin containing the XML below. This will simulate if the agent/department was busy and therefore unable to answer the phone, so you will redirect to the appropriate voicemail instead.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Reject reason="busy" />
</Response>
With these functions defined, we can now begin going through the 7 different routes. The first route /get_menu
accepts web requests from GET or POST. We first need to create an instance of VoiceResponse()
called response
. Next, we open menus.json
and load it as menus. We check to see if there is already a default menu specified, and if not, we route it to the main menu. Next we need to read the input_type
to determine if the user pressed any digits. If digits were pressed, we need to request the value of the digits pressed and use that to redirect to the correct next menu. If no digits were pressed yet, we will present the menu and loop through the options listed. Lastly, we need to add the menu to response
and return str(response
.
@app.route('/get_menu', methods=['GET', 'POST'])
def get_menu():
response = VoiceResponse()
with open('menus.json') as f:
menus = json.load(f)
menu = request.values.get("menu")
if menu not in menus:
response.say("Thank you for calling SignalWire! Please listen closely to the menu options in order to direct your "
"call to the correct department.")
menu = "main"
input_type = request.values.get("input_type")
if input_type == "dtmf":
# get digits pressed at menu
digits = request.values.get("Digits")
input_action = menus[menu][digits]["action"]
response.redirect(url=input_action)
else:
gather = Gather(action='/get_menu' + "?menu=" + menu, input='dtmf', timeout="6", method='GET')
for key in menus[menu]:
print(key, '->', menus[menu][key]["verbiage"])
gather.say(menus[menu][key]["verbiage"])
response.append(gather)
return str(response)
The corresponding XML for this depends on the options that you choose, however, below are two possible XML responses that might occur if you were to listen to the main menu and press 1 to connect to the Sales Team.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>
Thank you for calling SignalWire!
Please listen closely to the options in order to direct your call.
</Say>
<Gather action="/get_menu?menu=main" input="dtmf" method="GET" timeout="3">
<Say>If you'd like to connect to our Sales team, press 1</Say>
<Say>If you'd like to connect to our Support Engineering Team, press 2</Say>
</Gather>
</Response>
Above is the XML for the main menu. The input collected was 1, so next, we see a <Redirect>
to the action specified in the menus.json file for pressing 1.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Redirect>/get_menu?menu=sales</Redirect>
</Response>
This moves us to the next menu where you are presented with another option to direct to the right sales department.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Gather action="/get_menu?menu=sales" input="dtmf" method="GET" timeout="3">
<Say>If you are already a customer and would like to connect to your Account Executive, press 1</Say>
<Say>For assistance with a purchase or all other sales inquiries, press 2</Say>
</Gather>
</Response>
The input collected was 1, so now we move to the last possible Sales menu where you can select a salesman. This will lead us to our next routes.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Redirect>/get_menu?menu=salesteam</Redirect>
</Response>
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Gather action="/get_menu?menu=salesteam" input="dtmf" method="GET" timeout="3">
<Say>If you would like to speak to Bob, press 1</Say>
<Say>If you would like to speak to Alice, press 2</Say>
<Say>If you would like to speak to Charlie, press 3</Say>
</Gather>
</Response>
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Redirect>/dial_sales?name=charlie&group=sale_partners</Redirect>
</Response>
Great! As you can see, we selected a specific salesman and now we are being redirected to the route /dial_sales
with the parameters name and group. For the sake of this demo, I will only show the /dial_sales
route. However, if you were to select the support option instead, it would take you to /dial_support
which is nearly identical to /dial_sales
except for the fact that instead of passing the parameter name
to choose_salesman()
, it passes the parameter group
to choose_support()
. In the /dial_sales
route, we need to first assign the passed group
and name
parameters to a variable so that we can use them. We then create an instance of VoiceResponse()
called response
. Next, we need to execute response.dial
. We call the choose_salesman()
function with the name
parameter in order to return the correct number to dial and use an action URL pointing to the next route /handleDialCallStatus
passing our parameter of group
along again. If the agent answers, the call will be connected. If the agent does not answer, the action URL will be executed. Lastly, we return response
as a string.
@app.route('/dial_sales', methods=['GET', 'POST'])
def dial_sales():
group = request.args.get('group')
name = request.args.get('name')
response = VoiceResponse()
response.dial(choose_salesman(name), action='/handleDialCallStatus?group=' + group, methods=['GET', 'POST'])
return str(response)
The corresponding XML for this is displayed below.
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial action="/handleDialCallStatus?group=sale_partners" methods="['GET', 'POST']">
+12342186054
</Dial>
</Response>
In the next route /handleDialCallStatus
we take the call status from the previous section. We need to assign the group and status parameters passed to the web request to variables so that we can use them within our code. We also need to again instantiate VoiceResponse()
as response
. If the call is answered, nothing happens. If the caller does not answer, we use <Redirect>
to go to our next route which uses the group
parameter to send the call to a specific voicemail message based on who they were trying to reach. Lastly, we again return response
as a string.
@app.route('/handleDialCallStatus', methods=['GET', 'POST'])
def handle_dial():
group = request.args.get('group')
status = request.args.get('DialCallStatus')
response = VoiceResponse()
if status != 'answered':
print(status)
response.redirect(url='/get_voicemail?group=' + group)
else:
print(status)
return str(response)
The corresponding XML for this is displayed below:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Redirect>/get_voicemail?group=sale_partners</Redirect>
</Response>
The <Redirect>
above passes the group
parameter to our next route called /get_voicemail
. In this route, we set the group
parameter equal to a variable so that we can use it to choose the correct voicemail. We again instantiate VoiceResponse()
as response
. We now need to execute a response.say()
in order to play a specific voicemail message. We use choose_voicemail(group)
in order to return the string that we want to be said out loud to the caller. We then use response.record()
in order to record a message and on keypress #
, we execute the action URL to navigate to our very last route.
@app.route('/get_voicemail', methods=['GET', 'POST'])
def get_voicemail():
group = request.args.get('group')
response = VoiceResponse()
response.say(choose_voicemail(group))
response.record(action='/hangup', method='POST', max_length=15, finish_on_key='#')
return str(response)
The corresponding XML is below:
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Thank you for calling the SignalWire Sales Team. We are unavailable to take your call at this time. Please leave us a detailed message including your account name, phone number, and a description of how we can help. A member of our team will reach out as soon as possible. The recording will begin after the beep. Press the pound key when finished. </Say>
<Record action="/hangup" finishOnKey="#" maxLength="15" method="POST" />
</Response>
Now we can discuss our final route /hangup
! This route is very simple. We only need to instantiate VoiceResponse()
, play a message using response.say()
, hang up the call using response.hangup()
, and return response
as a string.
@app.route('/hangup', methods=['POST'])
def hangup():
response = VoiceResponse()
response.say("Thank you for your message. Goodbye!")
response.hangup()
return str(response)
Wrap Up
Almost every business that has a phone number to dial utilizes some type of IVR to help their customers navigate to the correct resource depending on their needs. The beauty of this application is that with the implementation of JSON menus, changing the script of the IVR for your specific business needs is a breeze!
Required Resources:
Sign Up Here
If you would like to test this example out, you can create a SignalWire account and space here.
Please feel free to reach out to us on our Community Slack or create a Support ticket if you need guidance!