Diagnostic Twilio
a number with no fallback URL drops the call when yours 500s
Your webhook was down for ninety seconds during a deploy. Twilio requested it, got a 502, logged an 11200 and hung up on whoever was calling. There was a mitigation for exactly this, it costs one field on the number, and it is empty on every number you own.
Read GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000 and flag any number where voice_url is set but voice_fallback_url is empty — and the same for sms_url and sms_fallback_url.
Where voice_application_sid is populated it takes precedence and the number's own URLs are ignored, so the effective fallback lives on the app: read it from GET /2010-04-01/Accounts/{AccountSid}/Applications/{AppSid}.json. A check that skips that resolution reports the wrong answer on exactly the numbers most likely to be misconfigured.
The problem in plain words
A missing fallback URL is invisible until the day it isn't. The number works, the handler answers, calls connect, and the field sits empty because nobody was ever asked to fill it in. Then a deploy takes ninety seconds, or a database connection pool exhausts, or a certificate expires, and Twilio has nowhere to go: it logs 11200 and terminates the interaction.
The cost lands entirely on the customer side of the line. Your monitoring sees an application outage of about a minute and calls it minor. What actually happened is that every caller in that minute heard silence or a fast busy and formed an opinion about your company, and no retry, alert or queue exists to recover them. Inbound calls are not messages: there is nothing to redeliver.
Why it happens
The field is optional and empty by default. Nothing in the purchase flow, the API or the console requires it. A number with a primary handler looks fully configured, and the fallback is the field you only learn about after the first outage.
Fallback is the one mitigation that works while your app is broken. Retries do not exist for inbound voice; the caller is on the line now. A static TwiML Bin that says "we are having trouble, please hold or call back" is a different experience from dead air, and it does not depend on the system that just failed.
Application SIDs move the field somewhere else. When voice_application_sid is set it wins outright and voice_url is ignored, including its fallback. Teams then "fix" the fallback on the number, see no change in behaviour, and conclude fallbacks do not work.
The gap spreads one number at a time. Numbers are bought individually, often in a hurry, and the console does not copy settings from an existing number. So an account ends up with two numbers that have fallbacks and eleven that do not, and no pattern to the difference.
The fix, as a flow
The script resolves the Application SID before judging the number, because when one is set it wins outright and every URL on the number, fallback included, is ignored.
How to fix it
List every number and both channels
GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000, paging on next_page_uri. Voice and SMS are separate pairs of fields; a number can be protected on one and exposed on the other.
Resolve the Application SID before you judge the number
If voice_application_sid or sms_application_sid is set, that resource is the effective handler. Fetch GET /2010-04-01/Accounts/{AccountSid}/Applications/{AppSid}.json once per SID, cache it, and read voice_url and voice_fallback_url from there instead.
Only flag channels that are actually in use
A number with no primary handler on a channel has a different problem, and it belongs in a different report. The finding here is narrow on purpose: a live handler with no fallback behind it.
Point the fallback at something that cannot share your outage
A fallback URL on the same host, behind the same load balancer, served by the same process is not a fallback. A TwiML Bin on handler.twilio.com, or a small static endpoint on separate infrastructure, is what makes the field worth setting.
Set it, then re-run
POST /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{PNSid}.json with VoiceFallbackUrl and VoiceFallbackMethod, or the same on the Application. Then keep the audit on a schedule: the next number somebody buys will arrive without a fallback too.
How to check it worked
Re-run the script. Every number with a live handler should report covered.
python3 twilio_fallback_audit.py
# 12 number(s), 0 with an unprotected handler
The full code
One paginated GET over the numbers, plus one GET per distinct Application SID, cached — an API Key with read access is enough. The precedence rule is in the pure function together with the fallback check, because reading the fallback off the number when an Application SID is set is the exact mistake this note exists to prevent.
"""Report Twilio numbers whose live handlers have no fallback URL.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can spend money.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_fallback_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
CHANNELS = (
("voice", "voice_url", "voice_fallback_url", "voice_application_sid"),
("sms", "sms_url", "sms_fallback_url", "sms_application_sid"),
)
def verdict(number, apps=None):
"""Classify one IncomingPhoneNumber. Pure, so the precedence rule can be
tested without a network.
`apps` maps an Application SID to that Application resource. When a channel
has an application sid, the application is the effective handler and the
number's own url and fallback are ignored entirely.
Returns (state, detail).
"""
apps = apps or {}
exposed, covered, unresolved = [], [], []
for channel, url_field, fb_field, app_field in CHANNELS:
app_sid = str(number.get(app_field) or "").strip()
if app_sid:
source = apps.get(app_sid)
if source is None:
unresolved.append("%s (%s)" % (channel, app_sid))
continue
where = "app %s" % app_sid
else:
source, where = number, "the number"
primary = str(source.get(url_field) or "").strip()
fallback = str(source.get(fb_field) or "").strip()
if not primary:
continue
(covered if fallback else exposed).append("%s on %s" % (channel, where))
if unresolved:
return ("unresolved",
"an application sid is set but the application was not read: %s"
% ", ".join(unresolved))
if exposed:
return ("exposed",
"%s has a live handler and no fallback: one non-2xx and the "
"interaction is dropped." % "; ".join(exposed))
if covered:
return ("covered", "fallback set for " + ", ".join(covered))
return ("idle", "no voice or sms handler configured, so nothing to fall back from")
def get(session, url, **params):
r = session.get(url, params=params, timeout=30)
if r.status_code in (401, 403):
raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
"API key belongs to that account with read access"
% r.status_code)
r.raise_for_status()
return r.json()
def list_numbers(session, account, limit):
url = "%s/Accounts/%s/IncomingPhoneNumbers.json" % (BASE, account)
params = {"PageSize": 100}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("incoming_phone_numbers", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def load_apps(session, account, numbers):
"""Fetch each referenced Application once. A busy account points many numbers
at the same app, so this is a handful of GETs rather than one per number."""
sids = set()
for n in numbers:
for _c, _u, _f, app_field in CHANNELS:
sid = str(n.get(app_field) or "").strip()
if sid:
sids.add(sid)
apps = {}
for sid in sorted(sids):
apps[sid] = get(session, "%s/Accounts/%s/Applications/%s.json"
% (BASE, account, sid))
return apps
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-numbers", type=int, default=1000)
args = ap.parse_args()
account = os.environ.get("TWILIO_ACCOUNT_SID")
key = os.environ.get("TWILIO_API_KEY")
secret = os.environ.get("TWILIO_API_SECRET")
if not (account and key and secret):
log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
"(an API Key with read access, not the auth token)")
return 2
session = requests.Session()
session.auth = (key, secret)
numbers = list_numbers(session, account, args.max_numbers)
if not numbers:
log.info("no phone numbers on this account")
return 0
apps = load_apps(session, account, numbers)
bad = 0
for n in numbers:
state, detail = verdict(n, apps)
line = "%-10s %s %s" % (state, n.get("phone_number", "?"), detail)
if state in ("covered", "idle"):
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: POST %s/Accounts/%s/IncomingPhoneNumbers/%s.json "
"VoiceFallbackUrl=https://handler.twilio.com/twiml/EHxxx "
"VoiceFallbackMethod=POST", BASE, account, n.get("sid"))
log.info("%d number(s), %d with an unprotected handler", len(numbers), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio numbers whose live handlers have no fallback URL.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The repair is printed, never
* performed.
*/
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const CHANNELS = [
['voice', 'voice_url', 'voice_fallback_url', 'voice_application_sid'],
['sms', 'sms_url', 'sms_fallback_url', 'sms_application_sid'],
];
/**
* Classify one IncomingPhoneNumber. Pure, so the precedence rule can be tested
* without a network. `apps` maps an Application SID to that Application: when a
* channel has one, it is the effective handler and the number's own url and
* fallback are ignored entirely. Returns [state, detail].
*/
export function verdict(number, apps = {}) {
const exposed = [];
const covered = [];
const unresolved = [];
for (const [channel, urlField, fbField, appField] of CHANNELS) {
const appSid = String(number[appField] ?? '').trim();
let source = number;
let where = 'the number';
if (appSid) {
source = apps[appSid];
if (source === undefined) { unresolved.push(`${channel} (${appSid})`); continue; }
where = `app ${appSid}`;
}
const primary = String(source[urlField] ?? '').trim();
const fallback = String(source[fbField] ?? '').trim();
if (!primary) continue;
(fallback ? covered : exposed).push(`${channel} on ${where}`);
}
if (unresolved.length) {
return ['unresolved',
`an application sid is set but the application was not read: ${unresolved.join(', ')}`];
}
if (exposed.length) {
return ['exposed',
`${exposed.join('; ')} has a live handler and no fallback: one non-2xx ` +
'and the interaction is dropped.'];
}
if (covered.length) return ['covered', `fallback set for ${covered.join(', ')}`];
return ['idle', 'no voice or sms handler configured, so nothing to fall back from'];
}
function authHeader(key, secret) {
return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}
async function get(auth, url, params = {}) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const res = await fetch(u, { headers: { Authorization: auth } });
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
'that the API key belongs to that account with read access');
}
if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
return res.json();
}
export async function listNumbers(auth, account, limit = 1000) {
let url = `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`;
let params = { PageSize: 100 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.incoming_phone_numbers ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out.slice(0, limit);
}
async function loadApps(auth, account, numbers) {
const sids = new Set();
for (const n of numbers) {
for (const [, , , appField] of CHANNELS) {
const sid = String(n[appField] ?? '').trim();
if (sid) sids.add(sid);
}
}
const apps = {};
for (const sid of [...sids].sort()) {
apps[sid] = await get(auth, `${BASE}/Accounts/${account}/Applications/${sid}.json`);
}
return apps;
}
async function main() {
const account = process.env.TWILIO_ACCOUNT_SID;
const key = process.env.TWILIO_API_KEY;
const secret = process.env.TWILIO_API_SECRET;
if (!account || !key || !secret) {
console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
'(an API Key with read access, not the auth token)');
process.exitCode = 2;
return;
}
const auth = authHeader(key, secret);
const numbers = await listNumbers(auth, account);
if (numbers.length === 0) {
console.log('no phone numbers on this account');
return;
}
const apps = await loadApps(auth, account, numbers);
let bad = 0;
for (const n of numbers) {
const [state, detail] = verdict(n, apps);
const line = `${state.padEnd(10)} ${n.phone_number ?? '?'} ${detail}`;
if (state === 'covered' || state === 'idle') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: POST ${BASE}/Accounts/${account}/IncomingPhoneNumbers/` +
`${n.sid}.json VoiceFallbackUrl=https://handler.twilio.com/twiml/EHxxx ` +
'VoiceFallbackMethod=POST');
}
console.log(`${numbers.length} number(s), ${bad} with an unprotected handler`);
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The case that has to be right is the number with an Application SID: a fallback set on the number itself is ignored, so a classifier that reads the number's fields reports it as protected when it is not. The mirror case matters too — the fallback on the app counts, even though the number's own field is empty.
from twilio_fallback_audit import verdict
APP = "AP0123456789"
def test_live_voice_handler_with_no_fallback_is_exposed():
state, detail = verdict({"voice_url": "https://app.example.com/voice"})
assert state == "exposed"
assert "dropped" in detail
def test_fallback_on_the_number_is_covered():
state, _ = verdict({"voice_url": "https://app.example.com/voice",
"voice_fallback_url": "https://handler.twilio.com/twiml/EH1"})
assert state == "covered"
def test_application_sid_wins_so_a_fallback_on_the_number_does_not_count():
# The mistake this note exists to prevent: the number looks protected and is not.
state, detail = verdict(
{"voice_application_sid": APP,
"voice_url": "https://app.example.com/voice",
"voice_fallback_url": "https://handler.twilio.com/twiml/EH1"},
{APP: {"voice_url": "https://app.example.com/voice"}})
assert state == "exposed"
assert APP in detail
def test_fallback_on_the_application_counts():
state, _ = verdict(
{"voice_application_sid": APP},
{APP: {"voice_url": "https://app.example.com/voice",
"voice_fallback_url": "https://handler.twilio.com/twiml/EH1"}})
assert state == "covered"
def test_sms_is_checked_when_voice_is_fine():
state, detail = verdict({"voice_url": "https://app.example.com/voice",
"voice_fallback_url": "https://handler.twilio.com/twiml/EH1",
"sms_url": "https://app.example.com/sms"})
assert state == "exposed"
assert "sms" in detail
def test_number_with_no_handler_is_idle_not_exposed():
state, _ = verdict({"voice_url": "", "sms_url": None})
assert state == "idle"
def test_unread_application_is_not_guessed_at():
state, _ = verdict({"voice_application_sid": APP}, {})
assert state == "unresolved"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './twilio-fallback-audit.mjs';
const APP = 'AP0123456789';
test('live voice handler with no fallback is exposed', () => {
const [state, detail] = verdict({ voice_url: 'https://app.example.com/voice' });
assert.equal(state, 'exposed');
assert.match(detail, /dropped/);
});
test('fallback on the number is covered', () => {
const [state] = verdict({
voice_url: 'https://app.example.com/voice',
voice_fallback_url: 'https://handler.twilio.com/twiml/EH1',
});
assert.equal(state, 'covered');
});
test('application sid wins, so a fallback on the number does not count', () => {
const [state, detail] = verdict(
{ voice_application_sid: APP,
voice_url: 'https://app.example.com/voice',
voice_fallback_url: 'https://handler.twilio.com/twiml/EH1' },
{ [APP]: { voice_url: 'https://app.example.com/voice' } });
assert.equal(state, 'exposed');
assert.match(detail, new RegExp(APP));
});
test('fallback on the application counts', () => {
const [state] = verdict(
{ voice_application_sid: APP },
{ [APP]: { voice_url: 'https://app.example.com/voice',
voice_fallback_url: 'https://handler.twilio.com/twiml/EH1' } });
assert.equal(state, 'covered');
});
test('sms is checked when voice is fine', () => {
const [state, detail] = verdict({
voice_url: 'https://app.example.com/voice',
voice_fallback_url: 'https://handler.twilio.com/twiml/EH1',
sms_url: 'https://app.example.com/sms',
});
assert.equal(state, 'exposed');
assert.match(detail, /sms/);
});
test('number with no handler is idle, not exposed', () => {
assert.equal(verdict({ voice_url: '', sms_url: null })[0], 'idle');
});
test('unread application is not guessed at', () => {
assert.equal(verdict({ voice_application_sid: APP }, {})[0], 'unresolved');
});
FAQ
When does Twilio actually call the fallback URL?
Only when the primary handler fails: a non-2xx response, a connection or TLS error, a timeout, or TwiML that will not parse. A handler that answers 200 with unhelpful TwiML is a success as far as Twilio is concerned, and the fallback is never reached.
What should the fallback URL point at?
Something that cannot fail for the same reason your app just did. A TwiML Bin on handler.twilio.com is the usual answer: it is static, hosted by Twilio, and says something human while you fix the real handler. A URL on the same host behind the same load balancer is not a fallback.
Why does setting the fallback on the number change nothing?
Because an Application SID is set on that channel. When voice_application_sid is populated it takes precedence outright and every URL on the number, fallback included, is ignored. Set the fallback on the Application, or detach the Application so the number's own fields apply.
Does a missing fallback affect SMS as badly as voice?
It is less severe, because inbound SMS can be reconstructed from the Messages list afterwards and a caller cannot. It still loses the automatic reply and any STOP handling that depended on the webhook, so it belongs in the same audit with a lower priority.
Is a fallback a substitute for fixing the handler?
No. It converts a dropped call into a degraded one, which is worth having and is not the same as working. The 11200 in the Debugger is still the thing to chase; the fallback just means the customer is not the one who pays for it.
Related field notes
- A number still points at the demo TwiML
- Inbound SMS disappears into a blank sms_url
- A Messaging Service with no A2P campaign
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- IncomingPhoneNumber resource — Twilio Docs
- Application resource — Twilio Docs
- Error 11200: HTTP retrieval failure — Twilio Docs
- Webhooks (HTTP callbacks) — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.