Amazon Connect IVR: Contact Flows, Routing & Business-Hours Logic
Build production IVR contact flows in Amazon Connect — DTMF menus, business-hours checks, Lambda data dips, queue transfers, voicemail, and exporting flows as JSON for version control.
What Is a Contact Flow?
A Contact Flow is the script that runs when a patient calls your phone number. It's a directed graph of blocks connected by branches.
Common block types: | Block | What It Does | |-------|-------------| | Play prompt | Say something to the caller (TTS or audio file) | | Get customer input | Wait for DTMF digits or speech | | Check hours of operation | Branch on whether a queue is open | | Set working queue | Point to the queue to transfer to | | Transfer to queue | Actually send the call to agents | | Invoke AWS Lambda | Look up patient data, check schedules | | Set contact attributes | Store data that flows through the call | | Disconnect | End the call |
A Typical Optometry IVR Flow
Inbound call
↓
Play greeting: "Thank you for calling Sunrise Eye Care"
↓
Check hours of operation
├─ Open → Get input (menu)
│ ├─ 1: Schedule appointment → Transfer to scheduling queue
│ ├─ 2: Insurance questions → Transfer to insurance queue
│ ├─ 3: Existing patient → Lambda: look up patient → transfer
│ └─ Timeout/Invalid → Repeat menu (max 3x) → Transfer to general
└─ Closed → Play after-hours message → Offer callback → DisconnectContact Flow JSON Structure
Connect stores flows as JSON (importable/exportable). This enables version control:
{
"Version": "2019-10-30",
"StartAction": "greeting-block-id",
"Actions": [
{
"Identifier": "greeting-block-id",
"Type": "MessageParticipant",
"Parameters": {
"Text": "Thank you for calling Sunrise Eye Care. Your call is important to us."
},
"Transitions": {
"NextAction": "hours-check-id",
"Errors": [{ "NextAction": "hours-check-id", "ErrorType": "NoMatchingError" }]
}
},
{
"Identifier": "hours-check-id",
"Type": "CheckHoursOfOperation",
"Parameters": {
"HoursOfOperationId": "arn:aws:connect:us-east-1:123:instance/xxx/operating-hours/yyy"
},
"Transitions": {
"NextAction": "main-menu-id",
"Conditions": [
{ "NextAction": "after-hours-id", "Condition": { "Operator": "Equals", "Operands": ["False"] } }
],
"Errors": [{ "NextAction": "main-menu-id", "ErrorType": "NoMatchingError" }]
}
},
{
"Identifier": "main-menu-id",
"Type": "GetParticipantInput",
"Parameters": {
"Text": "Press 1 to schedule an appointment. Press 2 for insurance questions. Press 3 for our after-hours team.",
"InputTimeLimitSeconds": "5",
"DTMF": {
"NumberOfDigits": "1",
"DisableCancel": "false"
}
},
"Transitions": {
"NextAction": "transfer-general-id",
"Conditions": [
{ "NextAction": "set-scheduling-queue-id", "Condition": { "Operator": "Equals", "Operands": ["1"] } },
{ "NextAction": "set-insurance-queue-id", "Condition": { "Operator": "Equals", "Operands": ["2"] } },
{ "NextAction": "after-hours-id", "Condition": { "Operator": "Equals", "Operands": ["3"] } }
],
"Errors": [
{ "NextAction": "transfer-general-id", "ErrorType": "InputTimeLimitExceeded" },
{ "NextAction": "main-menu-id", "ErrorType": "NoMatchingCondition" }
]
}
}
]
}Lambda Data Dip Mid-Call
One of the most powerful Connect features: invoke Lambda during a call to personalize the experience.
Use case: Caller presses "3" (existing patient). Look up the patient by phone number, get their next appointment, and announce it.
# lambda/connect_patient_lookup/handler.py
import boto3
import os
from datetime import datetime
table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
def handler(event, context):
"""
Amazon Connect passes the call context in event.
Return a flat dict of string attributes.
"""
# The caller's phone number (E.164 format: +12125551234)
caller_phone = event["Details"]["ContactData"]["CustomerEndpoint"]["Address"]
# Normalize to just digits for lookup
normalized = caller_phone.replace("+1", "").replace("-", "").replace(" ", "")
try:
# Look up patient by phone number (GSI)
response = table.query(
IndexName="PhoneIndex",
KeyConditionExpression="phone = :phone",
ExpressionAttributeValues={":phone": normalized},
Limit=1
)
if not response["Items"]:
return {"Found": "false"}
patient = response["Items"][0]
next_appt = get_next_appointment(patient["id"])
# Connect requires ALL values to be strings
result = {
"Found": "true",
"PatientName": patient.get("first_name", ""),
"ClinicName": patient.get("clinic_name", ""),
}
if next_appt:
dt = datetime.fromisoformat(next_appt["datetime"])
result["NextAppointment"] = dt.strftime("%A %B %d at %I:%M %p")
result["AppointmentId"] = next_appt["id"]
else:
result["NextAppointment"] = ""
return result
except Exception as e:
# Never crash the call — Connect will use error branch
print(f"Patient lookup failed: {e}")
return {"Found": "false"}In the contact flow, after the Lambda block:
{
"Type": "InvokeLambdaFunction",
"Parameters": {
"LambdaFunctionARN": "arn:aws:lambda:us-east-1:123:function:connect-patient-lookup",
"InvocationTimeLimitSeconds": "3"
},
"Transitions": {
"NextAction": "greet-patient-id",
"Conditions": [
{ "NextAction": "new-patient-flow-id", "Condition": { "Operator": "Equals", "Operands": ["false"], "Variable": "$.External.Found" } }
]
}
},
{
"Type": "MessageParticipant",
"Identifier": "greet-patient-id",
"Parameters": {
"TextToSpeechType": "ssml",
"Text": "<speak>Welcome back, <say-as interpret-as='name'>$.External.PatientName</say-as>. Your next appointment is $.External.NextAppointment. How can we help you today?</speak>"
}
}Business Hours + Overflow Logic
{
"Type": "CheckHoursOfOperation",
"Parameters": {
"HoursOfOperationId": "..."
},
"Transitions": {
"NextAction": "main-menu-id",
"Conditions": [
{ "NextAction": "after-hours-handler-id", "Condition": {"Operator": "Equals", "Operands": ["False"]} }
]
}
},
{
"Type": "CheckMetricData",
"Identifier": "check-queue-capacity-id",
"Parameters": {
"Metric": { "Name": "CONTACTS_IN_QUEUE" },
"QueueId": "arn:...:queue/appointment-scheduling",
"ComparisonOperator": "LT",
"Threshold": 10
},
"Transitions": {
"NextAction": "transfer-to-queue-id",
"Conditions": [
{ "NextAction": "queue-full-handler-id", "Condition": {"Operator": "Equals", "Operands": ["False"]} }
]
}
}Voicemail / Callback Flow
When agents are unavailable, offer a callback:
{
"Identifier": "offer-callback-id",
"Type": "GetParticipantInput",
"Parameters": {
"Text": "All agents are currently busy. Press 1 to leave a voicemail and we'll call you back within 2 hours. Press 2 to hold.",
"DTMF": { "NumberOfDigits": "1" }
},
"Transitions": {
"Conditions": [
{ "NextAction": "start-recording-id", "Condition": {"Operator": "Equals", "Operands": ["1"]} },
{ "NextAction": "transfer-to-queue-id", "Condition": {"Operator": "Equals", "Operands": ["2"]} }
]
}
},
{
"Identifier": "start-recording-id",
"Type": "MessageParticipant",
"Parameters": {
"Text": "Please leave your name, callback number, and a brief message after the tone."
},
"Transitions": { "NextAction": "play-beep-id" }
},
{
"Type": "StartMediaStreaming",
"Parameters": {
"MediaStreamingStartCondition": "AfterTransfer"
}
}Version Control for Flows
Export flows to JSON and store in Git:
# Export all flows
aws connect list-contact-flows \
--instance-id $INSTANCE_ID \
--contact-flow-types CONTACT_FLOW \
--query 'ContactFlowSummaryList[*].Id' \
--output text | \
while read id; do
name=$(aws connect describe-contact-flow --instance-id $INSTANCE_ID --contact-flow-id $id --query 'ContactFlow.Name' --output text)
aws connect describe-contact-flow --instance-id $INSTANCE_ID --contact-flow-id $id \
--query 'ContactFlow.Content' --output text > "flows/${name}.json"
done
# Import (update) a flow
aws connect update-contact-flow-content \
--instance-id $INSTANCE_ID \
--contact-flow-id $FLOW_ID \
--content file://flows/main-inbound.jsonThis lets you review IVR logic changes in pull requests just like code.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.