Diagnostic Twilio
a webhook hostname with no public DNS record fails with 11210
It worked on the laptop it was written on. It worked in staging. It reached production and every inbound call to that number now produces 11210 HTTP bad host name, because the hostname in the webhook resolves through an /etc/hosts line, an internal zone, or a tunnel that died when someone closed a terminal. Twilio resolves from the public internet and gets nothing back.
Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD, keep error_code 11210, and pull the hostname out of request_url. The shape of the name is usually the diagnosis: a reserved suffix like .internal or .local, a single label with no dot, or an ephemeral tunnel domain.
Then scan the configuration itself. GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json and check every URL field on every number, because a number that has not been called in 30 days produces no alerts at all and is still broken.
The problem in plain words
DNS is the one dependency a webhook has that nobody thinks of as a dependency. The URL is a string in a settings screen; it looks like configuration, not infrastructure. So it gets copied between environments, filled in from a tunnel during a demo, or pointed at a name that only exists inside a VPC, and every one of those passes review because the person reviewing it can resolve the name.
What makes 11210 worse than the other webhook failures is that there is nothing to retry into. There is no connection, no response, no timeout to tune. Twilio asks the public DNS system for a name, gets NXDOMAIN, and the call or the message ends there. Any fallback URL on the same dead hostname fails identically, which is the usual reason a fallback did not help.
Why it happens
Twilio resolves from outside your network. A split-horizon zone, a search domain, a VPC-private zone or an /etc/hosts entry are all invisible from the public internet. The name resolves for you, for CI, and for the load balancer, and not for the one resolver that matters.
Reserved suffixes never resolve publicly, by design. .local, .internal, .test, .invalid, .example, .lan and friends are reserved precisely so they cannot collide with public names. A webhook on one of those is not a DNS outage, it is a URL that could never have worked.
Tunnel hostnames are ephemeral and end up in production anyway. A free ngrok or Cloudflare quick tunnel gets a new hostname on every restart. It is the fastest way to receive a webhook during development and the easiest thing in the world to leave in a settings field, where it works until the tunnel drops and then fails forever.
Silence in the alerts is not evidence of health. An alert only exists if Twilio tried. A number nobody dialled, or a Messaging Service with no inbound traffic this month, generates nothing, and alerts age out after 30 days regardless. That is why the script reads the configuration as well as the alerts: the configuration is the part that is true even when nothing has been attempted.
The fix, as a flow
The script reads the configuration as well as the alerts, because an alert exists only if Twilio tried: a number nobody dialled this month is broken and silent at the same time.
How to fix it
Sweep the alerts for 11210
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url. Read error_code as an integer; the Monitor API returns it as a string. Group by hostname, because the path is irrelevant when the name never resolved.
Classify the name before you blame the resolver
Check the last label against the reserved suffixes, check for a single label with no dot at all, and check for the known ephemeral tunnel domains. Most 11210s are answered by the shape of the name alone, and the ones that are not — an ordinary public-looking hostname — are the ones worth a real DNS investigation.
Scan the configuration, not just the alerts
GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json and check voice_url, sms_url, status_callback and both fallback fields on every number. A number that was never dialled produces no alert and is still broken; this is the only way to find it before a customer does.
Check whether the fallback shares the fate of the primary
A fallback URL on the same unresolvable hostname is not a fallback. If the primary and the fallback both point at the dead tunnel, the number had no second chance at any point, which is worth knowing before you conclude the fallback mechanism is broken.
Publish a record or repoint, then re-check
Either publish a public A, AAAA or CNAME record for that hostname, or repoint the webhook at a host that already has one. There is nothing to fix on the Twilio side; the repair is a DNS record or a settings change, and the script prints which number and which field to change.
How to check it worked
Re-run after the change. Both halves of the report should be empty: no 11210 in the window, and no configured hostname that cannot resolve publicly.
python3 twilio_webhook_dns_audit.py --days 7
# 0 host(s) failing to resolve, 0 configured hostname(s) that never can
The full code
Two reads that answer two different questions: the alerts say what has already failed, the numbers list says what will fail the next time it is used. The pure part is the name classifier, and the case it exists for is hooks.example.com versus hooks.example — one is an ordinary public hostname and the other is a reserved suffix that can never resolve, and only the last label separates them.
"""Report webhook hostnames Twilio cannot resolve (error 11210).
Reads the alerts for names that have already failed, and the phone number
configuration for names that will fail the first time they are used.
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 place calls and
spend money.
"""
import argparse
import datetime as dt
import logging
import os
import sys
from urllib.parse import urlsplit
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_webhook_dns_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
BAD_HOST_NAME = 11210
MAX_DAYS = 30
URL_FIELDS = ("voice_url", "voice_fallback_url", "sms_url", "sms_fallback_url",
"status_callback")
# Reserved and private-use top-level labels. These exist so they cannot collide
# with public names, which means they can never resolve from Twilio's side.
RESERVED = {"local", "localhost", "internal", "intranet", "lan", "home", "corp",
"test", "example", "invalid", "localdomain"}
# Tunnel hostnames are handed out per session and die with the process. They are
# the fastest way to receive a webhook in development and the easiest thing to
# leave behind in a production settings field.
TUNNELS = ("ngrok.io", "ngrok-free.app", "ngrok.app", "ngrok.dev",
"trycloudflare.com", "loca.lt", "localtunnel.me", "serveo.net",
"lhr.life", "pagekite.me", "bore.pub")
def code_of(alert):
"""Read error_code off an alert as an integer, or None.
The Monitor API returns this as a string, and a raw comparison against
11210 quietly matches nothing at all.
"""
raw = alert.get("error_code")
if raw is None or raw == "":
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def hostname(url):
"""Lowercase hostname from a URL, without port or trailing dot.
The path is irrelevant when the name never resolved, so everything after the
host is discarded and ten endpoints on one dead name become one finding.
"""
if not url:
return ""
parts = urlsplit(str(url).strip())
host = (parts.hostname or "").lower()
if not host:
host = str(url).strip().lower()
while host.endswith("."):
host = host[:-1]
return host
def name_class(host):
"""What kind of name this is. Pure, and the whole diagnosis for most 11210s.
The case this function exists for is hooks.example.com against
hooks.example. Only the last label separates an ordinary public hostname
from a reserved suffix that can never resolve, and a check written against
the whole string gets both of them wrong.
"""
h = (host or "").strip().lower()
if not h:
return "empty"
labels = h.split(".")
if ":" in h or (len(labels) == 4
and all(l.isdigit() and len(l) <= 3 for l in labels)):
return "ip-literal"
for suffix in TUNNELS:
if h == suffix or h.endswith("." + suffix):
return "ephemeral-tunnel"
if labels[-1] in RESERVED:
return "reserved-suffix"
if len(labels) == 1:
return "single-label"
return "public"
def tally(alerts):
"""Group name resolution failures by hostname. Pure."""
out = {}
for a in alerts:
if code_of(a) != BAD_HOST_NAME:
continue
h = hostname(a.get("request_url"))
row = out.setdefault(h, {"alerts": 0, "sids": [], "first": None,
"last": None, "url": ""})
row["alerts"] += 1
if len(row["sids"]) < 3:
row["sids"].append(a.get("sid"))
row["url"] = row["url"] or (a.get("request_url") or "")
when = a.get("date_generated") or ""
if when:
row["first"] = when if row["first"] is None else min(row["first"], when)
row["last"] = when if row["last"] is None else max(row["last"], when)
return out
def verdict(host, row):
"""Classify one failing hostname. Pure, so the repair follows from the name.
Returns (state, detail).
"""
n = int(row.get("alerts") or 0)
if not n:
return ("clean", "no 11210 in the window")
kind = name_class(host)
if kind == "ephemeral-tunnel":
return ("dev-tunnel",
"%d x 11210 on a tunnel hostname. Those are handed out per "
"session and die with the process, so this one was wired into "
"production configuration during development and has been dead "
"ever since." % n)
if kind in ("reserved-suffix", "single-label"):
return ("private-name",
"%d x 11210 on a name that resolves only inside your own "
"network. An /etc/hosts line, a search domain or a split "
"horizon zone: this URL could never have worked from outside." % n)
if kind == "ip-literal":
return ("malformed",
"%d x 11210 against something that needs no DNS at all. Twilio "
"could not parse a usable host out of this URL, so the URL "
"itself is the defect." % n)
return ("unpublished",
"%d x 11210 on an ordinary public name. Either the record was never "
"published or the registration lapsed; Twilio asked the public DNS "
"system and got nothing back." % n)
def scan_numbers(numbers):
"""Configured hostnames that can never resolve, whether or not they failed yet.
Pure. An alert only exists if Twilio tried, so a number nobody has dialled
this month produces no alert and is broken all the same. This half of the
report is the one that finds a problem before a customer does.
"""
out = []
for n in numbers or []:
for field in URL_FIELDS:
host = hostname(n.get(field))
if not host:
continue
kind = name_class(host)
if kind in ("public", "empty"):
continue
out.append({"number": n.get("phone_number") or n.get("sid") or "?",
"field": field, "host": host, "class": kind})
return out
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_alerts(session, since, limit, log_level="error"):
"""Page the Monitor alerts. next_page_url is absolute on this API."""
url = MONITOR + "/Alerts"
params = {"LogLevel": log_level, "StartDate": since, "PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("alerts", []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def list_numbers(session, account, limit=2000):
url = "%s/Accounts/%s/IncomingPhoneNumbers.json" % (BASE, account)
params = {"PageSize": 1000}
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
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=7,
help="how far back to read alerts (Twilio keeps 30 days)")
ap.add_argument("--max-alerts", type=int, default=10000,
help="stop paging alerts after this many")
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
days = args.days
if days > MAX_DAYS:
log.warning("alerts are retained %d days; reading %d instead of %d",
MAX_DAYS, MAX_DAYS, days)
days = MAX_DAYS
session = requests.Session()
session.auth = (key, secret)
since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
rows = tally(list_alerts(session, since, args.max_alerts))
numbers = list_numbers(session, account)
failing = 0
for host, row in sorted(rows.items()):
state, detail = verdict(host, row)
line = "%-13s %s %s" % (state, host or "(no host)", detail)
if state == "clean":
log.info(line)
continue
failing += 1
log.warning(line)
log.warning(" first %s, last %s, sample %s", row["first"], row["last"],
row["url"] or "(none)")
log.warning(" alert sids: %s", ", ".join(str(s) for s in row["sids"]))
log.warning(" repair: publish a public A, AAAA or CNAME record for "
"this name, or repoint the webhook at a host that already "
"has one. Nothing on the Twilio side can be changed to make "
"an unresolvable name resolve.")
latent = [f for f in scan_numbers(numbers) if f["host"] not in rows]
for f in latent:
log.warning("latent %s %s = %s (%s). No alert yet only because "
"nothing has used it; it cannot resolve publicly.",
f["number"], f["field"], f["host"], f["class"])
log.info("%d host(s) failing to resolve, %d configured hostname(s) that "
"never can", failing, len(latent))
return 1 if (failing or latent) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report webhook hostnames Twilio cannot resolve (error 11210).
*
* Reads the alerts for names that have already failed, and the phone number
* configuration for names that will fail the first time they are used.
*
* 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 MONITOR = 'https://monitor.twilio.com/v1';
const BAD_HOST_NAME = 11210;
const MAX_DAYS = 30;
const URL_FIELDS = ['voice_url', 'voice_fallback_url', 'sms_url',
'sms_fallback_url', 'status_callback'];
// Reserved and private-use top-level labels: they exist so they cannot collide
// with public names, which means they can never resolve from Twilio's side.
const RESERVED = new Set(['local', 'localhost', 'internal', 'intranet', 'lan',
'home', 'corp', 'test', 'example', 'invalid', 'localdomain']);
// Tunnel hostnames are handed out per session and die with the process.
const TUNNELS = ['ngrok.io', 'ngrok-free.app', 'ngrok.app', 'ngrok.dev',
'trycloudflare.com', 'loca.lt', 'localtunnel.me', 'serveo.net', 'lhr.life',
'pagekite.me', 'bore.pub'];
/**
* Read error_code off an alert as a number, or null. The Monitor API returns it
* as a string, and a raw comparison against 11210 matches nothing.
*/
export function codeOf(alert) {
const raw = alert.error_code;
if (raw === null || raw === undefined || raw === '') return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
/** Lowercase hostname from a URL, without port or trailing dot. */
export function hostname(url) {
if (!url) return '';
const raw = String(url).trim();
let host = '';
try {
host = new URL(raw).hostname.toLowerCase();
} catch {
host = raw.toLowerCase();
}
if (!host) host = raw.toLowerCase();
while (host.endsWith('.')) host = host.slice(0, -1);
return host;
}
/**
* What kind of name this is. Pure, and the whole diagnosis for most 11210s.
*
* The case this exists for is hooks.example.com against hooks.example: only the
* last label separates an ordinary public hostname from a reserved suffix that
* can never resolve.
*/
export function nameClass(host) {
const h = String(host ?? '').trim().toLowerCase();
if (!h) return 'empty';
const labels = h.split('.');
const numeric = labels.length === 4
&& labels.every((l) => l.length > 0 && l.length <= 3
&& [...l].every((c) => c >= '0' && c <= '9'));
if (h.includes(':') || numeric) return 'ip-literal';
for (const suffix of TUNNELS) {
if (h === suffix || h.endsWith(`.${suffix}`)) return 'ephemeral-tunnel';
}
if (RESERVED.has(labels[labels.length - 1])) return 'reserved-suffix';
if (labels.length === 1) return 'single-label';
return 'public';
}
/** Group name resolution failures by hostname. Pure. */
export function tally(alerts) {
const out = new Map();
for (const a of alerts) {
if (codeOf(a) !== BAD_HOST_NAME) continue;
const h = hostname(a.request_url);
if (!out.has(h)) {
out.set(h, { alerts: 0, sids: [], first: null, last: null, url: '' });
}
const row = out.get(h);
row.alerts += 1;
if (row.sids.length < 3) row.sids.push(a.sid);
row.url = row.url || (a.request_url ?? '');
const when = a.date_generated ?? '';
if (when) {
row.first = row.first === null || when < row.first ? when : row.first;
row.last = row.last === null || when > row.last ? when : row.last;
}
}
return out;
}
/** Classify one failing hostname. Pure. Returns [state, detail]. */
export function verdict(host, row) {
const n = Number(row.alerts ?? 0);
if (!n) return ['clean', 'no 11210 in the window'];
const kind = nameClass(host);
if (kind === 'ephemeral-tunnel') {
return ['dev-tunnel',
`${n} x 11210 on a tunnel hostname. Those are handed out per session and ` +
'die with the process, so this one was wired into production ' +
'configuration during development and has been dead ever since.'];
}
if (kind === 'reserved-suffix' || kind === 'single-label') {
return ['private-name',
`${n} x 11210 on a name that resolves only inside your own network. An ` +
'/etc/hosts line, a search domain or a split horizon zone: this URL ' +
'could never have worked from outside.'];
}
if (kind === 'ip-literal') {
return ['malformed',
`${n} x 11210 against something that needs no DNS at all. Twilio could ` +
'not parse a usable host out of this URL, so the URL itself is the defect.'];
}
return ['unpublished',
`${n} x 11210 on an ordinary public name. Either the record was never ` +
'published or the registration lapsed; Twilio asked the public DNS system ' +
'and got nothing back.'];
}
/**
* Configured hostnames that can never resolve, whether or not they have failed
* yet. Pure. An alert exists only if Twilio tried, so a number nobody dialled
* this month is broken and silent at the same time.
*/
export function scanNumbers(numbers) {
const out = [];
for (const n of numbers ?? []) {
for (const field of URL_FIELDS) {
const host = hostname(n[field]);
if (!host) continue;
const kind = nameClass(host);
if (kind === 'public' || kind === 'empty') continue;
out.push({ number: n.phone_number ?? n.sid ?? '?', field, host, class: kind });
}
}
return out;
}
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 listAlerts(auth, since, limit = 10000, logLevel = 'error') {
let url = `${MONITOR}/Alerts`;
let params = { LogLevel: logLevel, StartDate: since, PageSize: 1000 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.alerts ?? []));
url = page.meta?.next_page_url ?? null;
params = {};
}
return out.slice(0, limit);
}
async function listNumbers(auth, account) {
let url = `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`;
let params = { PageSize: 1000 };
const out = [];
while (url) {
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;
}
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);
let days = Number(process.argv.includes('--days')
? process.argv[process.argv.indexOf('--days') + 1] : 7) || 7;
if (days > MAX_DAYS) {
console.warn(`alerts are retained ${MAX_DAYS} days; reading ${MAX_DAYS} instead`);
days = MAX_DAYS;
}
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const rows = tally(await listAlerts(auth, since));
const numbers = await listNumbers(auth, account);
let failing = 0;
for (const [host, row] of [...rows.entries()].sort()) {
const [state, detail] = verdict(host, row);
const line = `${state.padEnd(13)} ${host || '(no host)'} ${detail}`;
if (state === 'clean') { console.log(line); continue; }
failing += 1;
console.warn(line);
console.warn(` first ${row.first}, last ${row.last}, sample ${row.url || '(none)'}`);
console.warn(` alert sids: ${row.sids.join(', ')}`);
console.warn(' repair: publish a public A, AAAA or CNAME record for this ' +
'name, or repoint the webhook at a host that already has one. ' +
'Nothing on the Twilio side can be changed to make an ' +
'unresolvable name resolve.');
}
const latent = scanNumbers(numbers).filter((f) => !rows.has(f.host));
for (const f of latent) {
console.warn(`latent ${f.number} ${f.field} = ${f.host} (${f.class}). ` +
'No alert yet only because nothing has used it; it cannot ' +
'resolve publicly.');
}
console.log(`${failing} host(s) failing to resolve, ${latent.length} configured ` +
'hostname(s) that never can');
process.exitCode = (failing || latent.length) ? 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 last label is the whole game. hooks.example.com is an ordinary public hostname and hooks.example is a reserved suffix that can never resolve, and a classifier that matches on substrings gets both wrong. The rest pins down the half of the report that has no alerts behind it: a number nobody has dialled still has a broken URL, and silence is not the same as health.
from twilio_webhook_dns_audit import (code_of, hostname, name_class,
scan_numbers, tally, verdict)
def alert(sid, url, code="11210", when="2026-06-02T08:00:00Z"):
return {"sid": sid, "request_url": url, "error_code": code,
"date_generated": when, "log_level": "error"}
def test_code_of_reads_the_string_the_monitor_api_returns():
assert code_of({"error_code": "11210"}) == 11210
assert code_of({"error_code": 11210}) == 11210
assert code_of({}) is None
def test_hostname_drops_the_port_the_path_and_a_trailing_dot():
assert hostname("https://Hooks.Example.com:8443/voice?CallSid=CA1") == \
"hooks.example.com"
assert hostname("https://hooks.example.com./voice") == "hooks.example.com"
assert hostname(None) == ""
def test_only_the_last_label_decides_a_reserved_suffix():
# hooks.example.com is a perfectly ordinary public name; hooks.example is a
# reserved suffix that cannot resolve. A substring match gets both wrong.
assert name_class("hooks.example.com") == "public"
assert name_class("hooks.example") == "reserved-suffix"
assert name_class("api.internal") == "reserved-suffix"
assert name_class("printer.local") == "reserved-suffix"
assert name_class("localhost") == "reserved-suffix"
def test_the_other_shapes_a_name_can_take():
assert name_class("webhooks") == "single-label"
assert name_class("10.0.0.5") == "ip-literal"
assert name_class("a1b2c3d4.ngrok.io") == "ephemeral-tunnel"
assert name_class("wandering-cat.trycloudflare.com") == "ephemeral-tunnel"
assert name_class("") == "empty"
def test_tally_groups_by_name_and_ignores_other_codes():
rows = tally([alert("NO1", "https://api.internal/voice"),
alert("NO2", "https://api.internal/sms"),
alert("NO3", "https://api.internal/sms", code="11205")])
assert list(rows) == ["api.internal"]
assert rows["api.internal"]["alerts"] == 2
assert rows["api.internal"]["sids"] == ["NO1", "NO2"]
def test_a_dead_tunnel_is_reported_as_a_development_leftover():
state, detail = verdict("a1b2c3d4.ngrok.io", {"alerts": 60})
assert state == "dev-tunnel"
assert "per session" in detail
def test_an_internal_name_is_reported_as_never_having_worked():
state, detail = verdict("api.internal", {"alerts": 9})
assert state == "private-name"
assert "outside" in detail
def test_a_public_looking_name_is_the_one_worth_investigating():
state, detail = verdict("hooks.example.com", {"alerts": 9})
assert state == "unpublished"
assert "registration lapsed" in detail
def test_the_config_scan_finds_numbers_that_have_produced_no_alerts():
findings = scan_numbers([
{"phone_number": "+15550001111",
"voice_url": "https://a1b2c3d4.ngrok.io/voice",
"voice_fallback_url": "https://a1b2c3d4.ngrok.io/fallback",
"sms_url": "https://hooks.example.com/sms"},
{"phone_number": "+15550002222",
"voice_url": "https://hooks.example.com/voice"},
])
assert [(f["number"], f["field"]) for f in findings] == [
("+15550001111", "voice_url"), ("+15550001111", "voice_fallback_url")]
assert all(f["class"] == "ephemeral-tunnel" for f in findings)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
codeOf, hostname, nameClass, scanNumbers, tally, verdict,
} from './twilio-webhook-dns-audit.mjs';
const alert = (sid, url, code = '11210', when = '2026-06-02T08:00:00Z') => ({
sid, request_url: url, error_code: code, date_generated: when, log_level: 'error',
});
test('codeOf reads the string the Monitor API returns', () => {
assert.equal(codeOf({ error_code: '11210' }), 11210);
assert.equal(codeOf({ error_code: 11210 }), 11210);
assert.equal(codeOf({}), null);
});
test('hostname drops the port, the path and a trailing dot', () => {
assert.equal(hostname('https://Hooks.Example.com:8443/voice?CallSid=CA1'),
'hooks.example.com');
assert.equal(hostname('https://hooks.example.com./voice'), 'hooks.example.com');
assert.equal(hostname(null), '');
});
test('only the last label decides a reserved suffix', () => {
assert.equal(nameClass('hooks.example.com'), 'public');
assert.equal(nameClass('hooks.example'), 'reserved-suffix');
assert.equal(nameClass('api.internal'), 'reserved-suffix');
assert.equal(nameClass('printer.local'), 'reserved-suffix');
assert.equal(nameClass('localhost'), 'reserved-suffix');
});
test('the other shapes a name can take', () => {
assert.equal(nameClass('webhooks'), 'single-label');
assert.equal(nameClass('10.0.0.5'), 'ip-literal');
assert.equal(nameClass('a1b2c3d4.ngrok.io'), 'ephemeral-tunnel');
assert.equal(nameClass('wandering-cat.trycloudflare.com'), 'ephemeral-tunnel');
assert.equal(nameClass(''), 'empty');
});
test('tally groups by name and ignores other codes', () => {
const rows = tally([
alert('NO1', 'https://api.internal/voice'),
alert('NO2', 'https://api.internal/sms'),
alert('NO3', 'https://api.internal/sms', '11205'),
]);
assert.deepEqual([...rows.keys()], ['api.internal']);
assert.equal(rows.get('api.internal').alerts, 2);
assert.deepEqual(rows.get('api.internal').sids, ['NO1', 'NO2']);
});
test('a dead tunnel is reported as a development leftover', () => {
const [state, detail] = verdict('a1b2c3d4.ngrok.io', { alerts: 60 });
assert.equal(state, 'dev-tunnel');
assert.match(detail, /per session/);
});
test('an internal name is reported as never having worked', () => {
const [state, detail] = verdict('api.internal', { alerts: 9 });
assert.equal(state, 'private-name');
assert.match(detail, /outside/);
});
test('a public-looking name is the one worth investigating', () => {
const [state, detail] = verdict('hooks.example.com', { alerts: 9 });
assert.equal(state, 'unpublished');
assert.match(detail, /registration lapsed/);
});
test('the config scan finds numbers that have produced no alerts', () => {
const findings = scanNumbers([
{ phone_number: '+15550001111',
voice_url: 'https://a1b2c3d4.ngrok.io/voice',
voice_fallback_url: 'https://a1b2c3d4.ngrok.io/fallback',
sms_url: 'https://hooks.example.com/sms' },
{ phone_number: '+15550002222', voice_url: 'https://hooks.example.com/voice' },
]);
assert.deepEqual(findings.map((f) => [f.number, f.field]),
[['+15550001111', 'voice_url'], ['+15550001111', 'voice_fallback_url']]);
assert.ok(findings.every((f) => f.class === 'ephemeral-tunnel'));
});
FAQ
The URL works in my browser. Why does Twilio say bad host name?
Because your resolver is not Twilio's. A split-horizon zone, a VPC-private zone, a search domain or an /etc/hosts entry all make a name resolve for you and for nobody on the public internet. Twilio asks public DNS and gets NXDOMAIN, so no connection is ever attempted.
Why does the script look at phone number configuration as well as alerts?
Because an alert only exists if Twilio tried. A number nobody has dialled in a month produces no alerts and is broken all the same, and alerts age out after 30 days regardless. The configuration is true whether or not anything has been attempted, so both halves are needed.
What is wrong with using an ngrok URL in a real account?
Nothing, until the tunnel restarts. Free tunnel hostnames are handed out per session, so the URL is correct for hours and dead forever after. It is the single most common source of 11210 in accounts that used to work, which is why those domains get their own verdict.
Did my fallback URL not save the call?
Not if it is on the same hostname. A fallback that shares an unresolvable name fails in exactly the same way at exactly the same moment. Fallbacks are only worth having on infrastructure that can fail independently of the primary.
Is there anything to change on the Twilio side?
No. The repair is a DNS record or a different URL, both of which live with you. Everything the script does is a GET, so it names the number and the field and stops there; a script holding a credential that can place calls has no business editing routing.
Related field notes
- Twilio cannot open a connection to your webhook
- An expired webhook certificate
- A number still answering with demo TwiML
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.
- Error 11210: HTTP bad host name — Twilio Docs
- Monitor Alert resource — Twilio Docs
- IncomingPhoneNumber resource — 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.