What you will have at the end
One Twilio phone number with a three-option menu: sales, support, and opening hours, plus 0 for a message. It behaves the way a small business needs a menu to behave:
- Hours and holidays are evaluated in the business’s own time zone, so daylight saving time and a server in another region do not move your opening time.
- English and French. The greeting offers French on 9; the French menu uses a Canadian French voice and recognises spoken French as well as keys.
- Sales rings two phones at once; support rings one phone, then the next. Both fall back to voicemail if nobody takes the call.
- Call screening on the answering phone, so a team member’s mobile voicemail cannot silently “answer” the business call.
- No input and wrong keys get a short apology and the menu again, twice, then a voicemail, never an endless loop or a dead line.
- Voicemail arrives by email with the audio attached. Twilio records it; your code sends the mail. None of the TwiML verbs or the Recording resource documented below sends email.
The code is Node.js with Express and Twilio’s helper library. It was run locally for this guide against twilio 6.2.0 and Express 5.3.0, and the TwiML samples below are what it actually printed. The helper library’s own package.json declares version 6.2.0 and Node 20 or later.31 If you would rather design the call flow before writing code, our call flow templates are a useful first step, and what an auto attendant is covers the vocabulary.
Everything in this guide is Twilio’s public documentation, read on 26 October 2026, plus our own testing and opinion, which is labelled. It is an editorial developer guide; it says nothing about how any particular hosted service, ours included, is built.
How an inbound call reaches your code
Each Twilio number has a voice URL: “The URL we call when the phone number receives a call.”2 You set it in the Console, through the IncomingPhoneNumber REST resource, or with the Twilio CLI (below). There is also a voice_fallback_url, “The URL that we call when an error occurs retrieving or executing the TwiML requested by url”.2 Point it somewhere that is not your main server, even a static TwiML file that says you are having trouble and takes a message; it is the cheapest outage insurance in the whole build.
When a call arrives, Twilio makes an HTTP request to that URL. With POST (the usual choice, and the method this guide uses) the parameters arrive as a form-encoded body; with GET they arrive as query parameters.1 The ones a phone menu cares about:
| Parameter | What Twilio documents |
|---|---|
CallSid | “A unique identifier for this call, generated by Twilio.” Use it as the key for anything you log.1 |
From, To | The calling and called parties, in E.164 for phone numbers. Caller ID can be withheld, so From may be a value such as “anonymous”.1 |
CallStatus | One of queued, initiated, ringing, in-progress, completed, busy, failed, no-answer, canceled.1 |
Direction | inbound for the call coming in; outbound-dial for legs your <Dial> creates.1 |
ForwardedFrom | Set only when the call was forwarded to Twilio and the forwarding carrier passed the information, which “not all carriers” do.1 |
CallerName | Only when caller-ID name lookup is enabled on the number, which the API reference prices at “$0.01 per look up”.12 |
FromCity, FromState, FromZip, FromCountry (and To…) | “Twilio also attempts to look up geographic data based on the To and From phone numbers.” Best effort; never route on them alone.1 |
Scroll the table sideways to see every column.
Your response is TwiML: an XML document whose verbs Twilio executes “in order from top to bottom”.1 Two rules that bite every first-time builder: “TwiML elements (verbs and nouns) have case-sensitive names”, and attribute names are “case sensitive and camelCased”; Twilio’s own example is that <say> instead of <Say> “will result in an error.”1
Twilio “imposes a hard upper timeout of 15 seconds on all call-related HTTP requests”, and that cap overrides any timeout you configure.3 A menu webhook that waits on a slow CRM lookup or a cold-starting container leaves the caller in silence and then fails. Keep the call path to pure logic and configuration; do slow work (email, CRM writes, transcription) in the status callbacks after you have already replied.
The TwiML verbs a phone menu uses
A phone menu needs eight pieces of TwiML. This is the reference we wished we had: each attribute this guide uses, its default, and the source. Defaults matter because the helper library omits any attribute you do not set, so whatever Twilio defaults to is what your callers get.
| Verb or noun | Attribute | Default (as documented) | Notes |
|---|---|---|---|
<Say> | voice | man in the table; the text adds that the default “depends on your account-level Text-to-Speech settings” | Basic voices man/woman are free; Amazon Polly and Google voices are named Provider.Voice.4 |
language | en-US | The voice must support the language, or the <Say> may fail.4 | |
loop | 1 | 0 loops until hang-up or 1,000 iterations.4 | |
<Play> | loop | 1 | MP3, WAV, AIFF, GSM and μ-law are accepted; Twilio transcodes and caches.6 |
<Gather> | input | dtmf | Or speech, or dtmf speech.7 |
numDigits | unlimited | Set it to 1 for a single-key menu so the caller does not have to press #.7 | |
timeout | 5 seconds | Starts after the nested prompt finishes playing.7 | |
finishOnKey | # | 7 | |
action / method | current document URL / POST | 7 | |
actionOnEmptyResult | false | When true, Twilio calls action even if nothing was entered.7 | |
language | en-US | Speech recognition language; the supported set depends on speechModel.7 | |
<Dial> | timeout | 30 seconds | Minimum 5, maximum 600.8 |
action | none | Without it, Twilio moves to the next verb, and hangs up if there is none.8 | |
callerId | the caller’s caller ID | 8 | |
answerOnBridge | false | true keeps the caller hearing ringing until the dialled party answers.8 | |
timeLimit | 14,400 seconds (4 hours) | 8 | |
<Number> | url | none | TwiML run on the answering phone before the parties are connected.9 |
<Record> | timeout | 5 seconds of silence | 0 disables it.10 |
maxLength | 3600 (1 hour) | 10 | |
finishOnKey | 1234567890*# | Any key ends the recording unless you narrow it.10 | |
playBeep / trim | true / trim-silence | 10 | |
transcribe | false | Paid, American English only, 2 to 120 seconds.10 | |
recordingStatusCallbackEvent | completed | Also in-progress, absent.10 | |
<Redirect> | method | POST | “All verbs after <Redirect> are unreachable and ignored.” Relative URLs allowed.11 |
<Hangup> | none | “The <Hangup> verb ends a call.”12 |
Scroll the table sideways to see every column.
Two details from that table change how you write a menu. First, <Record>’s default finishOnKey is every key, so a caller who presses a key while leaving a message ends the recording; we narrow it to #. Second, <Hangup> answers the call before ending it: Twilio notes that putting it first does not stop the call being answered and billed, and that only <Reject> avoids that.12 If you block a number, reject it rather than hang up on it.
Gather: the menu itself, and the two ways it goes wrong
<Gather> plays its nested <Say> or <Play> (it accepts those two and <Pause>)7 and listens while it plays: the documentation describes the timeout as starting “either after the audio completes or when the caller presses their first key.”7 So a repeat caller who knows the menu can press 2 during the greeting and skip the rest. That is the single biggest usability win in a phone menu, and you get it simply by nesting the prompt inside <Gather> instead of placing a <Say> before it.
The two failure paths are what separates a demo from a menu you can put on a business line.
The caller presses nothing. “By default, if <Gather> times out while waiting for input, it continues to the next TwiML instruction.”7 In a document that ends with the <Gather>, the next instruction is nothing, and the call ends. Set actionOnEmptyResult="true" and Twilio requests your action URL “regardless of whether input is received”,7 so every outcome arrives at the same handler and the decision is in one place. That is the pattern this guide uses.
The caller presses a key you do not use. Twilio sends it to you as Digits like any other key; there is no notion of a valid key in TwiML. Your handler has to treat anything outside the menu as invalid, say so, and replay the menu. Count the attempts in the URL (?tries=1) and after the third failure route to a person or to voicemail. Our view: three plays is the most a caller should hear; somebody who has not chosen after three is lost, on a rotary-style line, or a robot, and none of them is helped by a fourth.
Speech as well as keys. input="dtmf speech" accepts either, and “Twilio gives precedence to the first input it detects.”7 Recognised speech arrives as SpeechResult with a Confidence score between 0.0 and 1.0, which Twilio says your code “shouldn’t expect” to be present.7 The hints attribute takes the words you expect, which for a menu is short and known. The default phone_call model lists fr-CA among its languages, alongside en-US.7 One trap: speechTimeout="auto" ends recognition at the first pause, but the documentation says that if you set speechModel you must use a positive integer and must not use auto.7 The example below leaves speechModel at its default.
<Response>
<Gather input="dtmf speech" numDigits="1" timeout="5" speechTimeout="auto"
language="fr-CA" hints="ventes, soutien, heures, message"
action="/menu/choice?lang=fr&tries=0" actionOnEmptyResult="true">
<Say voice="Polly.Gabrielle-Neural" language="fr-CA">Pour les ventes, faites le 1 ou dites ventes. …</Say>
</Gather>
</Response>
Note the &: an ampersand in an attribute must be escaped in XML. The helper library does it for you, which is one more reason not to build TwiML by string concatenation.
Voices, and a bilingual English and French menu
<Say> voices come in tiers. Basic voices (man, woman) are free; Amazon Polly and Google provide Standard, Neural and Generative voices, named with the provider as a prefix, for example Polly.Joanna-Generative.4 Generative voices are a Public Beta, which Twilio says is not covered by its SLA.5 If you set both voice and language, the pair must appear in Twilio’s table: “Combinations of voice and language not listed as available may result in an error and <Say> instruction failure.”4
For Canadian French, Twilio’s voice table lists, under fr-CA, Polly’s Chantal (Standard), Gabrielle-Neural and Liam-Neural, and Google Standard, WaveNet, Neural2 and Chirp3-HD voices.5 We use Polly.Gabrielle-Neural with language="fr-CA" and, for English, Polly.Joanna-Neural with en-US. We found no en-CA row in the voice table, so an English menu for Canadian callers uses a US (or British) voice. Two further limits from the same page: the SSML tags Twilio lists, <break> and <phoneme>, can be used “only for the en-US language”, and Basic voices “can process no more than 4,000 characters.”5
How to offer two languages. There are three common designs: a language choice before anything else (“for English press 1, pour le français faites le 2”), a bilingual greeting that continues in the majority language with a key for the other, or a separate number per language. Our view: the second is the best default for a business whose callers are mostly one language, because the majority caller hears no extra step. The example greets in English, says “Pour le service en français, faites le 9” in a French voice, and waits four seconds. No key, or any key other than 9, continues in English. Choose the opposite default if most of your callers speak French. The language then travels in every URL (?lang=fr), so each later step, including voicemail, stays in the caller’s language.
Recorded prompts instead of text-to-speech. <Play> takes a URL to an audio file. Twilio caches it “when HTTP headers allow it (via ETag and Last-Modified headers)”, and warns that high-bitrate lossy files such as 128 kbps MP3 “will take longer to transcode and potentially sound worse”.6 A professionally recorded greeting in each language sounds better than any synthetic voice, but every wording change becomes a studio session. Our view: launch on neural text-to-speech, keep the words in configuration, and record the final wording once the menu has stopped changing.
Ringing people: simultaneous, sequential, and the voicemail trap
Simultaneous ring. Put several <Number> nouns inside one <Dial>: “You can use up to ten <Number> nouns within a <Dial> verb to simultaneously call all of them at once. The first call to pick up is connected to the current call and the rest are hung up.”9
Sequential ring (one phone, then the next) is done with the action callback. When the <Dial> ends, Twilio requests the action URL with the outcome and continues the call with whatever TwiML you return.8 Your handler looks at the result and either ends the call or dials the next number. The parameters it receives:
DialCallStatus:completed,answered(conferences),busy,no-answer,failedorcanceled.8DialBridged: “Boolean indicator of whether or not the dialing call has been connected to the dialed destination.”8DialCallSidandDialCallDuration.8
Twilio’s <Dial> page also lists a sequential attribute: setting it to true “allows you to call up to ten agents one after the other.”8 As of today the page gives no default, no example and does not say how timeout applies to each number. We use the action-callback pattern because every step of it is documented and it lets you decide between attempts (skip a person who is off today, play a message, stop at voicemail). If you try sequential, test what it does when a phone is busy or unreachable before relying on it.
Timeout and caller ID. timeout is how long Twilio rings before giving up: default 30 seconds, minimum 5, maximum 600.8 By default the phone you ring “sees the inbound caller’s number as the caller ID”,8 which is usually what a team wants: they see who is calling. Set callerId to your Twilio number if they would rather know that it is a business call. answerOnBridge="true" keeps the caller hearing ringing until somebody actually answers.8
A mobile phone that does not answer sends the call to its own voicemail, and to Twilio that is an answered call. With a simple <Dial> your caller ends up in one employee’s personal mailbox, your sequential fallback never runs, and with simultaneous ring the other phones are hung up because the voicemail “picked up” first.9
The fix is screening. The <Number> noun’s url attribute returns TwiML “to be run on the called party’s end, after they answer, but before the parties are connected”, and Twilio suggests exactly this use: “You could also provide a chance to decline the phone call using <Gather> and <Hangup>.”9 The answering phone hears “Business line call. Press 1 to accept.” A human presses 1; a voicemail greeting does not, so the screening TwiML hangs up that leg before it is ever connected to the caller, and your action handler gets the chance to try the next phone or take a message.
We decide success on DialBridged, not on DialCallStatus. Twilio does not document which status a leg receives when the called party answers and then fails screening, and “bridged or not” is the question we actually care about. Log both during testing so you see what your carrier and handsets produce. Screening helps less with simultaneous ring. Twilio’s rule is that the first call to pick up is connected and the rest are hung up;9 the documentation does not say whether that happens when a phone answers or only once its screening TwiML has finished. If it is the former, a mobile voicemail that answers first costs you the other phones, and the caller goes to your voicemail even though a colleague might have answered. Test it with your own handsets. Our view: use screening on sequential groups, and on simultaneous groups only where phones reliably have personal voicemail switched off or set to answer after longer than your timeout.
Business hours and holidays, in the right time zone
Opening hours belong to the business, not to the server. A server in UTC, a Functions runtime somewhere else, or a laptop in Vancouver testing a Montréal menu will each give a different answer to “is it 9 a.m.?” Use an IANA time zone name such as America/Toronto with JavaScript’s Intl.DateTimeFormat, which accepts “any IANA time zone name” and otherwise falls back to the runtime’s own zone.27 Never store a fixed offset like -05:00: it is wrong for half the year.
The example converts “now” into the business’s local weekday, date and 24-hour time (hourCycle: 'h23'27), then checks a holiday list and a per-day table of opening and closing times. We tested it either side of the end of daylight saving time on 1 November 2026: 08:31 local was open both on 26 October (12:30 UTC) and on 2 November (13:31 UTC), and 17:01 local was closed.
Holidays are your list to maintain. There is no universal list to import. In Canada, the federal revenue agency’s own list notes that “several provinces and territories have their own unique holidays”: in 2026, Saint-Jean-Baptiste Day on 24 June is Quebec-only and the Civic Holiday on 3 August excludes Quebec.28 In the United States, the federal list is set by 5 U.S.C. 6103 for federal employees, and OPM notes that private businesses may use other names; for 2026 it also moves Independence Day to Friday 3 July because 4 July is a Saturday.29 Which days a private business closes is the business’s decision. Our view: keep the list in configuration next to the opening hours, add next year’s days each autumn, and add a manual “closed now” switch for snow days and emergencies, because those are the days callers most need the right message.
Voicemail, and getting it to someone’s inbox
<Record> records the caller and, when the recording ends, requests its action URL with RecordingUrl, RecordingDuration and Digits, the key that ended the recording or hangup.10 Set an action: “If no action is provided, <Record> will request the current document’s URL”, which Twilio warns “can lead to unwanted looping behavior”.10 In a handler like ours that would mean the voicemail prompt and beep starting again after every message.
The detail that breaks most voicemail-to-email code: “The recording file may not yet be accessible when the action callback is sent.” Twilio’s advice is to use recordingStatusCallback “for reliable notification on when the recording is available”.10 So the action handler only thanks the caller and hangs up; the email is sent from the status callback, which receives RecordingSid, RecordingUrl, RecordingStatus (completed or failed) and RecordingDuration, among others.1013
Why we attach the audio instead of linking it. “Twilio enforces HTTP Basic Authentication for all media URLs”, using an API key SID and secret; append .mp3 or .wav to the recording URL to choose the format (MP3 at 32 kbps, WAV at 128 kbps).13 A bare link in an email therefore will not play for the person reading it. The example downloads the MP3 with an API key and attaches it with Nodemailer, whose attachment content can be “a string, a Buffer, or a Node.js readable stream”.30 A two-minute voicemail at 32 kbps is about 480 KB. Send it through the mail service you already use; nothing in this step involves Twilio.
Transcription. transcribe="true" is the simple option and it is narrow: limited to recordings “with a duration greater than two seconds and less than 120 seconds”, “only supported in American English”, and “a paid feature”.10 The text arrives later at transcribeCallback as TranscriptionText with a TranscriptionStatus of completed or failed.10 The example turns it on only for English and sends the transcript as a second email. For French voicemail you need another speech-to-text service, run on the MP3 you already downloaded; that is outside this guide.
Retention. Stored recordings are billed per minute per month (see the costs below), and the recording reference notes that metadata is kept for 40 days after deletion.13 If email is the record of the message, delete the Twilio copy once the email has been sent. Voicemail is personal information, and our call recording checklist covers the obligations around it.
Twilio Functions, your own server, or Studio
The same call flow can run in three places. Here is what each one means in practice.
| Twilio Functions | Your own server | Twilio Studio | |
|---|---|---|---|
| What it is | Node.js handlers hosted by Twilio, exports.handler = (context, event, callback)17 | Any HTTPS service that returns TwiML (this guide) | “a low-code/no-code visual builder”21 with voice widgets such as Gather Input On Call, Connect Call To and Record Voicemail22 |
| Hard limits | 10-second execution limit; 30 concurrent invocations per account18 | Twilio’s 15-second webhook limit only3 | Not stated on the overview page21 |
| Webhook security | “Protected” visibility requires a valid X-Twilio-Signature19 | You validate the signature yourself (below) | Handled inside Twilio |
| Price (Twilio’s page, “current as of August 2026”) | First 10,000 invocations a month free, then $0.0001 each20 | Your hosting | 1,000 flow executions a month free, then $0.0025 each20 |
| Version control | Code files | Code files | Flow is JSON; the REST API can create and update a flow from a JSON definition and tracks a revision number23 |
Scroll the table sideways to see every column.
All three are billed on top of the call minutes; Twilio says of Studio that “Additional fees for the underlying Twilio services are not included in Studio pricing.”21
Two Functions details matter for this build. After callback() “the runtime terminates execution, including any asynchronous operations that are still running”,17 so the respond-first-then-send-the-email pattern used below does not work there: the email must be sent before calling callback(), inside the 10 seconds. And Functions support CommonJS only, which rules out ESM-only npm packages.18
Our view. Studio is the right choice when the person who owns the menu is not a developer and the logic is a handful of branches; it is the wrong choice once you want tests, code review, or holiday logic, because a visual flow is hard to diff. Functions are excellent for a menu like this one: no server and signature checking built in. Each webhook request is one invocation, and this flow makes eight to ten per answered call (menu, choice, dial result, screening, recording callbacks), so the 10,000 free invocations cover roughly a thousand calls a month.20 Run your own server when the menu has to read your own systems (a CRM, an on-call rota), when you need work that outlives 10 seconds, or when you want the call logic beside the rest of your code. The example is written for Express, but every handler is a pure function from request to TwiML, so porting it to Functions is mostly a matter of replacing req.body with event and res.send with callback.
Testing locally: ngrok, the Twilio CLI and signatures
Twilio has to reach your laptop over the public internet, so you need a tunnel. Twilio’s CLI documentation is direct about it: “The Twilio CLI does not accept localhost or 127.0.0.1 URLs directly. You must use a tunneling service like ngrok”.24
# terminal 1: the app (NODE_ENV=test switches off signature checks)
NODE_ENV=test node server.js
# terminal 2: a public URL for port 3000
ngrok http 3000
# terminal 3: point your number's voice URL at the tunnel
twilio phone-numbers:update PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX --voice-url https://<your-tunnel>.ngrok.app/voice
Twilio’s guide suggests watching each incoming request in your server logs and in ngrok’s web interface at http://127.0.0.1:4040,24 which is the fastest way to see what Twilio sent and what you answered. Twilio also warns that a tunnel “temporarily exposes your computer to the internet”; stop it when you are done.24
Before you ever call the number, test the TwiML with plain HTTP. Every handler is a POST with form fields, so you can drive the whole menu from a terminal:
curl -s -X POST 'localhost:3000/menu/choice?lang=en&tries=0' -d Digits=7
# <Response><Say …>Sorry, that is not one of the options.</Say>
# <Redirect>/menu?lang=en&tries=1</Redirect></Response>
Signature validation. Twilio signs every request: it hashes the full URL plus the alphabetically sorted parameters with HMAC-SHA1, keyed with your auth token, and sends the result in X-Twilio-Signature. It recommends using the SDK’s validator and says “Don’t implement your own signature validation.”14 In Express that is twilio.webhook(), which returns 403 for requests that fail and reads the token from TWILIO_AUTH_TOKEN.15 Twilio’s tutorial warns that validation fails behind ngrok or a TLS-terminating proxy because “the request URL that your Express application sees does not match the URL Twilio used”.15 The library rebuilds the URL from request.protocol, the Host header and originalUrl, and accepts protocol, host or a full url as overrides.16 In our test, app.set('trust proxy', true) was enough: with an https X-Forwarded-Proto header and the tunnel’s host name, a correctly signed request passed and a bad signature got 403. Only trust proxy headers when a proxy you control sits in front of the app.
Finally, test the paths that do not happen on a happy-path call: let the menu time out three times, press 7, let both support phones ring out, answer one and do not press 1, hang up halfway through a voicemail, and call on a date in your holiday list (temporarily add today). Each should end somewhere deliberate.
The complete example, annotated
One file, about 300 lines. Install with npm install twilio express nodemailer. Environment variables: TWILIO_AUTH_TOKEN (signature validation), TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET (to download recordings and look up the caller), SMTP_URL, VOICEMAIL_FROM and VOICEMAIL_TO (your mail service), and optionally TWILIO_NUMBER. The phone numbers are placeholders. How the pieces fit together:
- Configuration (section 1): time zone, hours, holidays, the two groups, the ring time, every prompt in both languages. A business changes this section and nothing else.
- Hours (section 2):
isOpen()evaluates now in the business’s time zone. - Entry (section 4): closed means a bilingual closed message and a recording; open means the bilingual greeting with 9 for French, then
/menu. - Menu and choice (section 5): one
<Gather>withactionOnEmptyResult, so no input, wrong keys and valid keys all land in/menu/choice, which counts tries. - Sales rings both numbers at once with screening; support (section 6) walks its list one number at a time through
/support/next. Both treatDialBridgedas the test of success. - Screening (section 7) runs on the answering phone.
- Voicemail (sections 8 and 9): record, thank, hang up; the email goes out from the recording status callback, after Twilio has had its reply.
// server.js: a three-option business phone menu on Twilio Programmable Voice.
// Node 20+, Express 5, twilio 6. Run: node server.js
'use strict';
const express = require('express');
const twilio = require('twilio');
const nodemailer = require('nodemailer');
const { VoiceResponse } = twilio.twiml;
// ---- 1. Configuration: everything a business would change lives here ----
const CONFIG = {
timeZone: 'America/Toronto', // IANA name, never a fixed UTC offset
// Opening hours per weekday, 24-hour local time. Missing day = closed.
hours: {
Mon: ['08:30', '17:00'], Tue: ['08:30', '17:00'], Wed: ['08:30', '17:00'],
Thu: ['08:30', '17:00'], Fri: ['08:30', '16:00'],
},
// Days you are closed, in local time. You maintain this list yourself.
holidays: new Set(['2026-11-11', '2026-12-25', '2026-12-26', '2027-01-01']),
sales: ['+15145550101', '+15145550102'], // ring together
support: ['+15145550111', '+15145550112'], // ring one after the other
ringSeconds: 18, // per attempt; Twilio allows 5-600
callerId: process.env.TWILIO_NUMBER, // unset: your phones see the caller's number
};
const VOICE = {
en: { voice: 'Polly.Joanna-Neural', language: 'en-US' },
fr: { voice: 'Polly.Gabrielle-Neural', language: 'fr-CA' },
};
const TEXT = {
en: {
closed: 'Thank you for calling. We are closed right now. Please leave a message after the tone.',
menu: 'For sales, press 1 or say sales. For support, press 2 or say support. ' +
'For our hours, press 3. To leave a message, press 0.',
hours: 'We are open Monday to Thursday from 8:30 a.m. to 5 p.m., and Friday until 4 p.m., Eastern time.',
invalid: 'Sorry, that is not one of the options.',
noInput: 'Sorry, I did not hear a choice.',
connecting: 'Connecting you now.',
voicemail: 'Nobody is available. Please leave your name, number and a short message after the tone, then hang up.',
thanks: 'Thank you. Goodbye.',
hints: 'sales, support, hours, message',
},
fr: {
closed: 'Merci de votre appel. Nos bureaux sont fermés. Laissez-nous un message après le signal sonore.',
menu: 'Pour les ventes, faites le 1 ou dites ventes. Pour le soutien technique, faites le 2 ou dites soutien. ' +
'Pour nos heures d’ouverture, faites le 3. Pour laisser un message, faites le 0.',
hours: 'Nous sommes ouverts du lundi au jeudi de 8 h 30 à 17 h, et le vendredi jusqu’à 16 h, heure de l’Est.',
invalid: 'Désolé, ce choix n’est pas valide.',
noInput: 'Désolé, je n’ai pas entendu votre choix.',
connecting: 'Nous vous mettons en communication.',
voicemail: 'Personne n’est disponible. Laissez votre nom, votre numéro et un court message après le signal sonore, puis raccrochez.',
thanks: 'Merci. Au revoir.',
hints: 'ventes, soutien, heures, message',
},
};
// ---- 2. Business hours, evaluated in the business's time zone ----
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: CONFIG.timeZone, weekday: 'short', year: 'numeric', month: '2-digit',
day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
});
function isOpen(now = new Date()) {
const p = Object.fromEntries(fmt.formatToParts(now).map((x) => [x.type, x.value]));
if (CONFIG.holidays.has(`${p.year}-${p.month}-${p.day}`)) return false;
const span = CONFIG.hours[p.weekday];
if (!span) return false;
const hhmm = `${p.hour}:${p.minute}`; // zero-padded, so string compare works
return hhmm >= span[0] && hhmm < span[1];
}
// ---- 3. Helpers ----
const lang = (req) => (req.query.lang === 'fr' ? 'fr' : 'en');
const say = (node, l, text) => node.say(VOICE[l], text);
const send = (res, twiml) => res.type('text/xml').send(twiml.toString());
const app = express();
app.set('trust proxy', true); // ngrok / load balancer terminates TLS
app.use(express.urlencoded({ extended: false })); // Twilio posts form-encoded bodies
// Reject anything not signed by Twilio with your auth token (TWILIO_AUTH_TOKEN).
const fromTwilio = twilio.webhook({ validate: process.env.NODE_ENV !== 'test' });
// ---- 4. Entry point: the number's "A call comes in" webhook ----
app.post('/voice', fromTwilio, (req, res) => {
const twiml = new VoiceResponse();
if (!isOpen()) {
say(twiml, 'en', TEXT.en.closed);
say(twiml, 'fr', TEXT.fr.closed);
recordVoicemail(twiml, 'en');
return send(res, twiml);
}
// Bilingual greeting, then the English menu. 9 switches to French.
const g = twiml.gather({
input: 'dtmf', numDigits: 1, timeout: 4, action: '/language', actionOnEmptyResult: true,
});
say(g, 'en', 'Thank you for calling.');
say(g, 'fr', 'Pour le service en français, faites le 9.');
return send(res, twiml);
});
app.post('/language', fromTwilio, (req, res) => {
const twiml = new VoiceResponse();
twiml.redirect(req.body.Digits === '9' ? '/menu?lang=fr' : '/menu?lang=en');
send(res, twiml);
});
// ---- 5. The menu itself ----
app.post('/menu', fromTwilio, (req, res) => {
const l = lang(req);
const tries = Number(req.query.tries || 0);
const twiml = new VoiceResponse();
const g = twiml.gather({
input: 'dtmf speech', numDigits: 1, timeout: 5, speechTimeout: 'auto',
language: VOICE[l].language, hints: TEXT[l].hints,
action: `/menu/choice?lang=${l}&tries=${tries}`, actionOnEmptyResult: true,
});
say(g, l, TEXT[l].menu);
send(res, twiml);
});
const SPOKEN = {
1: /\b(sales|ventes?)\b/i, 2: /\b(support|soutien)\b/i,
3: /\b(hours|heures?)\b/i, 0: /\b(message|messagerie)\b/i,
};
function choiceFrom(body) {
if (body.Digits) return body.Digits;
const heard = body.SpeechResult || '';
return Object.keys(SPOKEN).find((k) => SPOKEN[k].test(heard)) || (heard ? 'unknown' : '');
}
app.post('/menu/choice', fromTwilio, (req, res) => {
const l = lang(req);
const tries = Number(req.query.tries || 0);
const twiml = new VoiceResponse();
const choice = choiceFrom(req.body);
switch (choice) {
case '1': { // sales: simultaneous ring
say(twiml, l, TEXT[l].connecting);
const d = twiml.dial({
timeout: CONFIG.ringSeconds, callerId: CONFIG.callerId,
action: `/dial/done?lang=${l}`, answerOnBridge: true,
});
for (const n of CONFIG.sales) d.number({ url: `/screen?lang=${l}` }, n);
return send(res, twiml);
}
case '2': // support: one after the other
say(twiml, l, TEXT[l].connecting);
twiml.redirect(`/support?lang=${l}&i=0`);
return send(res, twiml);
case '3':
say(twiml, l, TEXT[l].hours);
twiml.redirect(`/menu?lang=${l}`);
return send(res, twiml);
case '0':
twiml.redirect(`/voicemail/record?lang=${l}`);
return send(res, twiml);
default: { // nothing, or a key we do not use
if (tries >= 2) { // third failure: take a message
twiml.redirect(`/voicemail/record?lang=${l}`);
return send(res, twiml);
}
say(twiml, l, choice ? TEXT[l].invalid : TEXT[l].noInput);
twiml.redirect(`/menu?lang=${l}&tries=${tries + 1}`);
return send(res, twiml);
}
}
});
// ---- 6. Sequential fallback: ring support[i], and on failure try support[i+1] ----
app.post('/support', fromTwilio, (req, res) => {
const l = lang(req);
const i = Number(req.query.i || 0);
const twiml = new VoiceResponse();
if (i >= CONFIG.support.length) {
twiml.redirect(`/voicemail/record?lang=${l}`);
return send(res, twiml);
}
const d = twiml.dial({
timeout: CONFIG.ringSeconds, callerId: CONFIG.callerId,
action: `/support/next?lang=${l}&i=${i}`,
});
d.number({ url: `/screen?lang=${l}` }, CONFIG.support[i]);
send(res, twiml);
});
app.post('/support/next', fromTwilio, (req, res) => {
const l = lang(req);
const i = Number(req.query.i || 0);
const twiml = new VoiceResponse();
if (req.body.DialBridged === 'true') twiml.hangup(); // they talked; we are done
else twiml.redirect(`/support?lang=${l}&i=${i + 1}`);
send(res, twiml);
});
app.post('/dial/done', fromTwilio, (req, res) => {
const l = lang(req);
const twiml = new VoiceResponse();
if (req.body.DialBridged === 'true') twiml.hangup();
else twiml.redirect(`/voicemail/record?lang=${l}`);
send(res, twiml);
});
// ---- 7. Call screening: runs on the answering phone before the caller is connected.
// Stops a mobile's own voicemail from "answering" and swallowing the call.
app.post('/screen', fromTwilio, (req, res) => {
const twiml = new VoiceResponse();
const g = twiml.gather({ numDigits: 1, timeout: 5, action: '/screen/accept' });
g.say(VOICE.en, `Business line call${req.query.lang === 'fr' ? ', in French' : ''}. Press 1 to accept.`);
twiml.hangup(); // no key pressed: release this leg
send(res, twiml);
});
app.post('/screen/accept', fromTwilio, (req, res) => {
const twiml = new VoiceResponse();
if (req.body.Digits !== '1') twiml.hangup(); // empty response = connect the caller
send(res, twiml);
});
// ---- 8. Voicemail ----
app.post('/voicemail/record', fromTwilio, (req, res) => {
const l = lang(req);
const twiml = new VoiceResponse();
say(twiml, l, TEXT[l].voicemail);
recordVoicemail(twiml, l);
send(res, twiml);
});
function recordVoicemail(twiml, l) {
twiml.record({
maxLength: 120, timeout: 5, finishOnKey: '#', playBeep: true,
action: `/voicemail/done?lang=${l}`, // runs when recording ends
recordingStatusCallback: `/voicemail/ready?lang=${l}`, // runs when the file exists
recordingStatusCallbackEvent: 'completed',
// Twilio's built-in transcription is American English only.
...(l === 'en' ? { transcribe: true, transcribeCallback: '/voicemail/text' } : {}),
});
}
app.post('/voicemail/done', fromTwilio, (req, res) => {
const l = lang(req);
const twiml = new VoiceResponse();
say(twiml, l, TEXT[l].thanks);
twiml.hangup();
send(res, twiml);
});
// ---- 9. Voicemail to email: Twilio stores the audio, your code sends the mail ----
let transport; // created on first use
const mailer = () => (transport ??= nodemailer.createTransport(process.env.SMTP_URL));
app.post('/voicemail/ready', fromTwilio, (req, res) => {
res.status(204).end(); // answer Twilio first, then do the slow part
const { RecordingUrl, RecordingSid, RecordingDuration, CallSid } = req.body;
if (req.body.RecordingStatus !== 'completed') return;
emailVoicemail({ RecordingUrl, RecordingSid, RecordingDuration, CallSid, lang: lang(req) })
.catch((err) => console.error('voicemail email failed', RecordingSid, err));
});
async function emailVoicemail({ RecordingUrl, RecordingSid, RecordingDuration, CallSid, lang: l }) {
// Media URLs need HTTP Basic auth: an API key SID and secret.
const auth = Buffer.from(`${process.env.TWILIO_API_KEY}:${process.env.TWILIO_API_SECRET}`).toString('base64');
const audio = await fetch(`${RecordingUrl}.mp3`, { headers: { Authorization: `Basic ${auth}` } });
if (!audio.ok) throw new Error(`recording fetch ${audio.status}`);
const call = await twilioClient().calls(CallSid).fetch(); // who called
await mailer().sendMail({
from: process.env.VOICEMAIL_FROM,
to: process.env.VOICEMAIL_TO,
subject: `Voicemail (${l.toUpperCase()}) from ${call.from}, ${RecordingDuration}s`,
text: `New voicemail from ${call.from}.\nRecording SID: ${RecordingSid}\n` +
(l === 'en' ? 'A transcript follows in a separate email if Twilio produces one.\n' : ''),
attachments: [{ filename: `${RecordingSid}.mp3`, content: Buffer.from(await audio.arrayBuffer()) }],
});
}
app.post('/voicemail/text', fromTwilio, (req, res) => {
res.status(204).end();
if (req.body.TranscriptionStatus !== 'completed') return;
mailer().sendMail({
from: process.env.VOICEMAIL_FROM, to: process.env.VOICEMAIL_TO,
subject: 'Voicemail transcript',
text: `${req.body.TranscriptionText}\n\nRecording: ${req.body.RecordingUrl}`,
}).catch((err) => console.error('transcript email failed', err));
});
let client;
function twilioClient() {
client ??= twilio(process.env.TWILIO_API_KEY, process.env.TWILIO_API_SECRET,
{ accountSid: process.env.TWILIO_ACCOUNT_SID });
return client;
}
if (require.main === module) app.listen(process.env.PORT || 3000);
module.exports = { app, isOpen };
What it prints for a French caller who presses 1, as captured in our test:
<Response>
<Say voice="Polly.Gabrielle-Neural" language="fr-CA">Nous vous mettons en communication.</Say>
<Dial timeout="18" action="/dial/done?lang=fr" answerOnBridge="true">
<Number url="/screen?lang=fr">+15145550101</Number>
<Number url="/screen?lang=fr">+15145550102</Number>
</Dial>
</Response>
Relative URLs such as /screen?lang=fr are resolved against the URL of the current request; <Redirect>’s documentation shows a relative example,11 and <Gather> and <Dial> document their action as relative or absolute.78 The 18-second ring time is our choice: it is long enough for someone to reach a phone, and the ideal is a ring time shorter than the delay before your team’s mobiles divert to their own voicemail, so screening rarely has to reject a mailbox. Measure that delay on the actual handsets; it varies by carrier and by phone settings.
Emergency calling (this menu only dials the numbers you configure, never 911, but read our emergency calling page before you put phones behind it), storing call logs, retries when the mail server is down, French transcription, alerts when the fallback URL fires, and numbers and registration in the United States and Canada. Each of those is a production concern rather than a menu concern.
What it costs to run
Twilio’s US and Canada voice price pages both say “Pricing current as of August 2026”, and they list the same figures for the items a phone menu uses.2526 The Canada page shows prices with a dollar sign but, as we read it, does not name the currency.
| Item | US page25 | Canada page26 |
|---|---|---|
| Local number | $1.15 a month | $1.15 a month |
| Toll-free number | $2.15 a month | $2.15 a month |
| Receive a call, local number | $0.0085 a minute | $0.0085 a minute |
| Receive a call, toll-free | $0.0220 a minute | $0.0220 a minute |
| Make a call (the forwarded leg) | $0.0140 a minute | $0.0140 a minute to Canada (Yukon listed separately) |
| Recording / storage | $0.0025 a minute / $0.0005 a minute per month | same |
| Transcription | $0.0500 a minute | same |
| Text-to-speech, Standard / Neural / Generative | $0.0008 / $0.0032 / $0.0130 per 100 characters | same |
Scroll the table sideways to see every column.
Text-to-speech is priced “in blocks of 100 characters” with a 100-character minimum, and Basic voices are free.5 A worked example, using those list prices and nothing else: a caller on a local number listens to a 250-character neural greeting and a 200-character menu, then talks to sales for four minutes. Allow about five minutes of inbound time (menu plus conversation), four minutes of forwarded leg, and five 100-character blocks of neural speech: roughly 5 × $0.0085 + 4 × $0.0140 + 5 × $0.0032 ≈ $0.11. A forwarded call costs two legs, which is the line item people forget. Taxes, regulatory fees and anything not on the price page are not included, and prices change: check the pages on the day you build.
Our view: when this is worth building
Building a phone menu on Twilio is a good use of a developer’s time when the menu has to do something specific to your business: look up the caller in your own system, follow an on-call rota, or sit inside an existing product. The platform is well documented, the primitives in this guide are stable, and you own every decision.
It is a poor trade when the requirement is the ordinary one in this guide: a greeting, a few options, the right phones ringing and messages reaching email. Then you are signing up to operate a small telephony service for as long as the number is published: the fallback URL, the holiday list each year, the recording retention, the bill, the registration paperwork for your numbers, and a test call after every change. A hosted auto attendant or your existing phone provider’s menu feature is usually the better choice there; our own service is one such option. If you do build, build the failure paths first. A menu that handles silence, wrong keys and an unanswered phone gracefully is worth more than one with a fourth option.
For the menu wording itself, our phone menu scripts and the call flow templates are written to drop into the configuration block above. For more Twilio guides, see the Twilio section.
Further reading
Longer research pieces on this topic, each with its own source list and a downloadable PDF.
Articles on this topic
Topics Auto attendant Call routing VoIP All topics
Sources
Every regulatory and pricing claim on this page links to the primary source it came from. Figures were checked on the date shown at the top of the page; rules and prices change, so verify before relying on one.
- Twilio Docs, TwiML for Programmable Voice: “Twilio’s request to your application” (request parameters, POST vs GET, top-to-bottom execution, case-sensitive verbs and attributes). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml - Twilio Docs, IncomingPhoneNumber resource (voice_url, voice_fallback_url, voice_caller_id_lookup at $0.01 per look up). Retrieved 26 Oct 2026
https://www.twilio.com/docs/phone-numbers/api/incomingphonenumber-resource - Twilio Docs, Webhooks connection overrides (default timeouts; hard 15-second upper timeout on call-related HTTP requests). Retrieved 26 Oct 2026
https://www.twilio.com/docs/usage/webhooks/webhooks-connection-overrides - Twilio Docs, TwiML Voice: <Say> (voice, language and loop attributes and defaults; provider voice naming; voice and language compatibility). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/say - Twilio Docs, Text-to-speech (voice table including fr-CA and en-US voices, Generative voices in Public Beta, SSML limited to en-US, character limits, 100-character billing blocks). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/say/text-speech - Twilio Docs, TwiML Voice: <Play> (loop and digits, supported MIME types, caching, transcoding). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/play - Twilio Docs, TwiML Voice: <Gather> (attribute table and defaults, actionOnEmptyResult, no-input behaviour, dtmf speech precedence, speechTimeout and speechModel, phone_call model languages, Digits/SpeechResult/Confidence). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/gather - Twilio Docs, TwiML Voice: <Dial> (timeout 30 s default, 5-600 s; action behaviour; callerId; answerOnBridge; timeLimit; sequential; DialCallStatus, DialBridged, DialCallSid, DialCallDuration). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/dial - Twilio Docs, TwiML Voice: <Number> (up to ten simultaneous numbers, first to answer wins, the url attribute for screening). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/number - Twilio Docs, TwiML Voice: <Record> (attribute table and defaults, looping without action, recording not yet available at action time, transcription limits and callback parameters). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/record - Twilio Docs, TwiML Voice: <Redirect> (method default POST, verbs after it unreachable, relative URLs). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/redirect - Twilio Docs, TwiML Voice: <Hangup> (no attributes; answering before hanging up is still billed, unlike <Reject>). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/twiml/hangup - Twilio Docs, Recording resource (media via .mp3/.wav, HTTP Basic Authentication on media URLs, formats, status callback parameters, metadata kept 40 days after deletion). Retrieved 26 Oct 2026
https://www.twilio.com/docs/voice/api/recording - Twilio Docs, Webhooks security (X-Twilio-Signature, HMAC-SHA1 with the auth token, use the SDK validator, HTTPS). Retrieved 26 Oct 2026
https://www.twilio.com/docs/usage/webhooks/webhooks-security - Twilio Docs, Secure your Express app by validating incoming Twilio requests (twilio.webhook(), TWILIO_AUTH_TOKEN, ngrok and proxy URL mismatch). Retrieved 26 Oct 2026
https://www.twilio.com/docs/usage/tutorials/how-to-secure-your-express-app-by-validating-incoming-twilio-requests - twilio-node source, src/webhooks/webhooks.ts (webhook options url, host, protocol; how the URL is rebuilt from the request). main branch, retrieved 26 Oct 2026
https://github.com/twilio/twilio-node/blob/main/src/webhooks/webhooks.ts - Twilio Docs, Functions invocation (handler signature, context and event, callback behaviour, execution ends after callback). Retrieved 26 Oct 2026
https://www.twilio.com/docs/serverless/functions-assets/functions/invocation - Twilio Docs, Functions and Assets FAQ (10-second execution limit, 30 concurrent invocations, CommonJS only). Retrieved 26 Oct 2026
https://www.twilio.com/docs/serverless/functions-assets/faq - Twilio Docs, Functions and Assets visibility (public, protected, private). Retrieved 26 Oct 2026
https://www.twilio.com/docs/serverless/functions-assets/visibility - Twilio, Serverless pricing (Studio and Functions; “Pricing current as of August 2026”). Retrieved 26 Oct 2026
https://www.twilio.com/en-us/serverless/pricing - Twilio Docs, Studio overview (what Studio is; underlying service fees not included). Retrieved 26 Oct 2026
https://www.twilio.com/docs/studio - Twilio Docs, Studio widget library (voice widget names). Retrieved 26 Oct 2026
https://www.twilio.com/docs/studio/widget-library - Twilio Docs, Studio REST API v2 Flow resource (JSON definition, revision, create and update from JSON). Retrieved 26 Oct 2026
https://www.twilio.com/docs/studio/rest-api/v2/flow - Twilio Docs, Twilio CLI: work with webhooks (phone-numbers:update --voice-url, no localhost URLs, ngrok, inspector, exposure warning). Retrieved 26 Oct 2026
https://www.twilio.com/docs/twilio-cli/general-usage/work-with-webhooks - Twilio, Programmable Voice pricing, United States (“Pricing current as of August 2026”). Retrieved 26 Oct 2026
https://www.twilio.com/en-us/voice/pricing/us - Twilio, Programmable Voice pricing, Canada (“Pricing current as of August 2026”). Retrieved 26 Oct 2026
https://www.twilio.com/en-us/voice/pricing/ca - MDN Web Docs, Intl.DateTimeFormat() constructor (timeZone and hourCycle options). Retrieved 26 Oct 2026
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat - Canada Revenue Agency, Public holidays (2026 list; provincial and territorial holidays differ). Date modified 2026-01-06; retrieved 26 Oct 2026
https://www.canada.ca/en/revenue-agency/services/tax/public-holidays.html - U.S. Office of Personnel Management, Federal holidays (2026 list under 5 U.S.C. 6103, for federal employees). Retrieved 26 Oct 2026
https://www.opm.gov/policy-data-oversight/pay-leave/federal-holidays/ - Nodemailer, Attachments (content as string, Buffer or stream; href and httpHeaders). Retrieved 26 Oct 2026
https://nodemailer.com/message/attachments - twilio-node, package.json (version 6.2.0; engines node >=20.0.0). main branch, retrieved 26 Oct 2026
https://github.com/twilio/twilio-node/blob/main/package.json
