Twilio Voice: Building an IVR That Doesn't Make Callers Hate You
Twilio Voice gives you enough rope to build a great phone IVR — or a nightmare. Here's what I've learned from building real ones for clients.
Most phone IVRs are bad on purpose — designed to exhaust callers into giving up. Twilio Voice gives you the tools to build something that actually helps people. The question is whether you'll use them well or just automate the same bad patterns everyone else ships.
I've built Twilio Voice IVRs for a handful of clients over the years — a healthcare clinic routing patients to the right department, a real estate office handling after-hours inquiries, an industrial equipment company triaging service calls. Every single one taught me something I wish I'd known at the start.
What Twilio Voice Actually Is
Twilio Voice is a programmable telephony API. You buy a phone number through Twilio, and when someone calls it, Twilio makes an HTTP request to a URL you control. Your server responds with TwiML — Twilio Markup Language, which is just XML — and Twilio executes it. Say something, gather a keypress, record a voicemail, dial a real person. The loop continues until the call ends.
That's it. It's a state machine driven by HTTP. Which sounds simple, and mostly is — until the phone part kicks in and reminds you that telephony has decades of weird edge cases baked in.
A Real IVR in Laravel
Here's a stripped-down version of the routing IVR I built for a medical clinic. The caller hears a greeting, presses a number, and gets routed appropriately.
First, install the SDK:
composer require twilio/sdk
Then the controller:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Twilio\TwiML\VoiceResponse;
class IvrController extends Controller
{
public function greeting(): string
{
$response = new VoiceResponse();
$gather = $response->gather([
'numDigits' => 1,
'action' => route('ivr.route'),
'method' => 'POST',
'timeout' => 5,
]);
$gather->say(
'Thank you for calling Northgate Family Clinic. '
. 'For appointments, press 1. '
. 'For the nurse line, press 2. '
. 'For billing, press 3. '
. 'To repeat these options, press 9.',
['voice' => 'Polly.Joanna', 'language' => 'en-US']
);
// If they don't press anything, say it again once.
$response->redirect(route('ivr.greeting'), ['method' => 'POST']);
return response($response, 200)
->header('Content-Type', 'text/xml');
}
public function route(Request $request): string
{
$digit = $request->input('Digits');
$response = new VoiceResponse();
match ($digit) {
'1' => $response->dial()->number(config('clinic.appointments_line')),
'2' => $response->dial()->number(config('clinic.nurse_line')),
'3' => $response->dial()->number(config('clinic.billing_line')),
'9' => $response->redirect(route('ivr.greeting'), ['method' => 'POST']),
default => $this->invalidInput($response),
};
return response($response, 200)
->header('Content-Type', 'text/xml');
}
private function invalidInput(VoiceResponse $response): void
{
$response->say(
'Sorry, I didn\'t get that. Let me repeat the options.',
['voice' => 'Polly.Joanna']
);
$response->redirect(route('ivr.greeting'), ['method' => 'POST']);
}
}
Routes in web.php:
Route::post('/ivr/greeting', [IvrController::class, 'greeting'])->name('ivr.greeting');
Route::post('/ivr/route', [IvrController::class, 'route'])->name('ivr.route');
Make sure these routes are excluded from CSRF middleware — Twilio's webhook has no session. Add them to the $except array in VerifyCsrfToken.
Also: in your Twilio console, set the webhook URL for your number to https://yourdomain.com/ivr/greeting with method POST. And set up webhook signature validation in production — it's a few lines with Twilio\Security\RequestValidator and it keeps random people from driving your IVR.
The Gotchas That Will Bite You
The timeout trap. The default timeout on <Gather> is 5 seconds. That sounds reasonable until your caller is elderly, on a bad cell connection, or just a little slow. I set mine to 8 for healthcare clients. Getting this wrong means you're looping callers back through the greeting over and over, which is infuriating. Bump the timeout. Callers aren't in a hurry to hang up — they called you.
The redirect loop. Notice the <Redirect> at the bottom of the greeting response, outside the <Gather>. That's what runs if the gather times out without input. I've seen developers leave this off entirely, which means Twilio just hangs up on callers who didn't press anything in time. Silently. The caller thinks you hung up on them. Don't do this.
TwiML verb ordering matters. The XML is executed top to bottom, sequentially. If you put a <Say> inside a <Gather>, Twilio reads it while waiting for input — that's correct. If you put the <Say> outside and before the <Gather>, Twilio finishes saying it completely, then opens the gather, and the caller who pressed a key during the speech has to wait. I've made this mistake. It sounds like a broken phone system.
Amazon Polly voices vs. the default voice. The default TTS voice is fine for testing and sounds like a robot from 2008. For anything client-facing, use an Amazon Polly neural voice — Polly.Joanna or Polly.Matthew are solid. You specify it in the voice attribute of <Say>. It costs a bit more but the difference is night and day. I've never had a client complain it sounds too human.
Webhook timeouts. Twilio waits 15 seconds for your webhook response before giving up and reading the caller an error message. If your route() action does anything slow — a database lookup, an API call to check business hours — you can hit this. Keep the webhook fast. Do the slow work asynchronously, or cache it. For the clinic IVR I cached business hours so the webhook never made an outbound HTTP call in the hot path.
Testing with real phones. You can't fully test an IVR in a browser. Buy a $3 Twilio number for development, forward it to your local ngrok tunnel, and call it from your actual cell phone. You'll immediately notice things that didn't show up in your head: the pacing of the speech, whether the options are too long before the first one, whether the prompt is confusing. I test every IVR change by calling it myself before showing the client.
Call status webhooks are separate. The action URL on <Dial> fires when the dialed call ends, not when the outer call ends. If you want to know what happened to the whole call — completed, no-answer, busy — you need to set a statusCallback on the <Dial> verb or at the number level. I've had clients ask why their call logs were incomplete and it's always this.
After-Hours Routing
One thing I add to almost every client IVR is business-hours awareness. Here's the pattern I use:
private function isBusinessHours(): bool
{
$now = now()->setTimezone('America/Los_Angeles');
$hour = (int) $now->format('G');
$day = (int) $now->format('N'); // 1 = Monday, 7 = Sunday
return $day <= 5 && $hour >= 8 && $hour < 17;
}
In the greeting, I check this before building the response. If it's after hours, skip the routing menu entirely and go straight to voicemail or an on-call number. Giving callers a menu of options when nobody's there to answer any of them is a waste of their time.
When I'd Reach for Twilio Voice
Twilio Voice is my first call (sorry) when a client needs:
- A routing IVR for a small business — clinics, real estate offices, service companies
- After-hours voicemail-to-email with transcription (Twilio's transcription is decent; for anything where accuracy matters I pipe the recording to Whisper instead)
- Outbound notification calls — appointment reminders, service alerts
- Click-to-call from a web app where you want the call to look like it comes from a business number
I would not reach for Twilio Voice if the client needs something sophisticated in natural language — "press 1 or say appointments" style. That works fine in demos and falls apart with accents, background noise, and people who say "uh, appointments?" Twilio has speech recognition built in but for anything beyond simple commands I'd layer in something purpose-built or just skip speech recognition entirely and go touch-tone only. Callers are used to it.
I also wouldn't use it if the call volume is high enough that per-minute costs matter. At a certain scale you're looking at SIP trunking and a more traditional telephony stack. For the small-to-midsize clients I work with, Twilio's pricing is never an issue.
The Real Lesson
Most bad IVRs aren't bad because the technology failed. They're bad because whoever built them never actually called the thing from a real phone and paid attention to what the experience felt like as a caller who doesn't know how it works.
Twilio Voice makes it easy to build something professional. It doesn't make you do it. Call your IVR from your cell phone, every time you change something, and ask yourself honestly: would I stay on the line, or would I just Google the address and walk in?
If the answer is walk in, fix it before the client sees it.
Need help shipping something like this? Get in touch.