Diagnostic Twilio
twilio cannot open a TCP connection to your webhook (11205)
You have the URL open in a browser tab and it works. Curl works. The health check is green. And the Twilio Debugger is filling with 11205 HTTP connection failure for that exact URL, while your access log has no entry for it at all — not a 500, not a 404, nothing. The request never arrived, because the connection was never opened.
Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD and group alerts by the hostname in request_url, keeping the count of 11205 and the count of 11200 per host side by side.
That pairing is the diagnosis. 11205 means the TCP handshake never completed; 11200 means it completed and the response was wrong. A host with only 11205 was never reachable from the public internet — a firewall, a dead host, or a private address. A host with both answered sometimes, which is a capacity problem, not a network one.
The problem in plain words
11205 is the failure that makes people doubt their own tools. Every check available from a laptop passes, because a laptop is not where Twilio dials from. Twilio connects from its own egress ranges over the public internet, and it allows 10 seconds to establish the connection and 15 seconds for the whole exchange. A WAF rule, a security group that lost a CIDR, a host that was replaced, or a URL that quietly points at an internal address all end the same way: nothing to connect to, inside the budget.
Because the request never reaches your application, nothing in your stack records it. There is no request ID, no trace, no error, no log line to grep for. The only party that saw the failure is Twilio, and the only place it wrote it down is the alerts list, which expires after 30 days.
Why it happens
The request never reached your app, so your logs are the wrong place to look. This is the difference between 11205 and 11200 and it is worth internalising: 11200 is your application answering badly, 11205 is nothing answering at all. Searching your access log for evidence of an 11205 will always come back empty, and that emptiness is routinely misread as "Twilio never sent it".
There is a connect budget and it is short. Twilio allows roughly 10 seconds to establish the TCP connection and 15 seconds in total. A backlog queue that is full, an autoscaler mid-scale, or a load balancer with no healthy targets can all blow through that while the host is technically alive, which is why a host that also has 11200 alerts is a different diagnosis from one that has only 11205.
A private address cannot be allowlisted into working. If request_url points at 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12, 127.0.0.1 or 169.254.169.254, no firewall change will help: the packets never leave Twilio's network toward anything you own. The classic case is a staging value copied into production, and the classic near miss is 172.32.x.x, which looks private and is not — the block stops at 172.31.
Alerts are the only record and they expire. Thirty days of retention, and nothing else on the account remembers that a webhook was attempted and failed to connect. Any trend you want beyond that window has to be captured by something of yours, on a schedule, before the evidence ages out.
The fix, as a flow
The script sweeps once and keeps 11200 alongside 11205, because a host carrying both answered some of the time, and that is capacity rather than a firewall.
How to fix it
Confirm the credential is on the account you think it is
GET /2010-04-01/Accounts/{AccountSid}.json first. An API Key created on a different subaccount returns 401 here, and half of "no alerts found" reports are a key pointed at the wrong account rather than a healthy webhook. It also tells you the account is active rather than suspended.
Sweep the alerts once and keep both codes
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url. Do not filter to 11205 in the request — you want the 11200s from the same window, on the same hosts, because the comparison between them is the whole diagnosis.
Group by hostname, not by URL
A connection failure happens before any path is requested, so the path is noise here. Strip it, lowercase the host, drop the port. Ten different endpoints on one dead host are one finding, and reporting them as ten is how a single expired security group looks like an application-wide collapse.
Ask whether the host is reachable from anywhere public
Check the hostname for a private, loopback or link-local literal before you blame the network. If it is one of those the repair is the configured URL, not the firewall. Everything else is a real host, and the question becomes whether it ever answered.
Split firewall from capacity, then repair the right one
Only 11205 on a host means nothing ever completed a handshake: allowlist Twilio's egress ranges at the firewall or WAF and confirm the host answers publicly on that port. Both 11205 and 11200 on a host means it answers some of the time: that is backlog, pool exhaustion or a scaling event, and the repair is capacity plus a handler that returns immediately. Verify the configured URL with GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json.
How to check it worked
Re-run over the same window after the firewall or capacity change. Every host should come back clean.
python3 twilio_webhook_timeout_audit.py --days 2
# 3 host(s) with webhook alerts, 0 unreachable
The full code
One alerts sweep, one account preflight, and a classifier that reads the two error codes against each other. The pure parts are the host extraction and the private-address test, because the second one is where this check is most often wrong in a way that looks right: 172.31 is private, 172.32 is not, and a report that gets that backwards sends someone to argue with a firewall team for a week.
"""Report webhook hosts Twilio cannot open a connection to (error 11205).
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_timeout_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
CONNECT_FAILURE = 11205
RETRIEVAL_FAILURE = 11200
# Alerts are retained 30 days and nothing else on the account remembers a
# webhook that failed to connect.
MAX_DAYS = 30
def code_of(alert):
"""Read error_code off an alert as an integer, or None.
The Monitor API returns this as a string. Comparing the raw value against
11205 is the mistake that makes the whole sweep report nothing.
"""
raw = alert.get("error_code")
if raw is None or raw == "":
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def host_of(url):
"""Lowercase hostname from a webhook URL, without the port.
A connection failure happens before a path is ever requested, so grouping by
full URL splits one dead host into one finding per endpoint and makes a
single expired firewall rule look like an application-wide collapse.
"""
if not url:
return ""
parts = urlsplit(str(url).strip())
if parts.hostname:
return parts.hostname.lower()
return str(url).strip().lower()
def unroutable(host):
"""Why Twilio can never open a connection to this host, or None.
Twilio dials from the public internet. A private, loopback or link-local
address is not a firewall problem and no allowlist will fix it: the packets
never leave Twilio's network toward anything you own.
The 172 range is the one worth writing a test for. RFC 1918 reserves
172.16.0.0/12, which stops at 172.31 -- 172.32.0.0 is ordinary public space,
and a check that treats it as private sends somebody to argue with a network
team about an address that was never the problem.
"""
h = (host or "").strip().lower().strip("[]")
if not h:
return "empty host"
if h in ("localhost", "::1"):
return "loopback"
labels = h.split(".")
if len(labels) == 4 and all(l.isdigit() and len(l) <= 3 for l in labels):
octets = [int(l) for l in labels]
if any(o > 255 for o in octets):
return "malformed IP literal"
a, b = octets[0], octets[1]
if a == 0:
return "unspecified address"
if a == 127:
return "loopback"
if a == 10:
return "private address"
if a == 172 and 16 <= b <= 31:
return "private address"
if a == 192 and b == 168:
return "private address"
if a == 169 and b == 254:
return "link-local address"
if a == 100 and 64 <= b <= 127:
return "carrier-grade NAT address"
return None
def tally(alerts):
"""Group connection and retrieval failures by host.
Pure, so the pairing can be tested without a network. Both codes are kept
per host on purpose: 11205 says the handshake never completed, 11200 says it
completed and the response was wrong, and a host carrying both answered some
of the time.
"""
out = {}
for a in alerts:
code = code_of(a)
if code not in (CONNECT_FAILURE, RETRIEVAL_FAILURE):
continue
h = host_of(a.get("request_url"))
row = out.setdefault(h, {"timeouts": 0, "retrievals": 0, "sids": [],
"first": None, "last": None, "url": ""})
if code == CONNECT_FAILURE:
row["timeouts"] += 1
if len(row["sids"]) < 3:
row["sids"].append(a.get("sid"))
row["url"] = row["url"] or (a.get("request_url") or "")
else:
row["retrievals"] += 1
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, min_alerts=3):
"""Classify one host. Pure, so the thresholds and the order are visible.
Returns (state, detail). The order matters: an unroutable address is
reported even on a single alert, because one is proof, and a host that also
has 11200 alerts is reported as capacity however few connection failures it
has, because it demonstrably answers.
"""
timeouts = int(row.get("timeouts") or 0)
retrievals = int(row.get("retrievals") or 0)
if not timeouts:
return ("clean", "%d retrieval failure(s), no connection failures"
% retrievals)
reason = unroutable(host)
if reason:
return ("misconfigured",
"%d x 11205 against a %s. No firewall change reaches this: the "
"configured URL points somewhere Twilio can never dial, so the "
"repair is the URL." % (timeouts, reason))
if retrievals:
return ("flapping",
"%d x 11205 and %d x 11200 on the same host. It answers some of "
"the time, so this is capacity rather than a firewall: a full "
"backlog queue or an exhausted pool inside the 10 second connect "
"budget." % (timeouts, retrievals))
if timeouts < min_alerts:
return ("isolated",
"%d x 11205 and nothing else. Too few to call an outage; a "
"restart or a scaling event closes the listener for a moment and "
"looks exactly like this." % timeouts)
return ("unreachable",
"%d x 11205 and not one 11200. Nothing ever completed a handshake, "
"so your access log has no record of any of it: a firewall dropping "
"Twilio's egress ranges, or a host that is gone." % timeouts)
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 account_preflight(session, account):
"""Confirm the key really belongs to this account before reporting nothing.
An API Key made on a different subaccount 401s here rather than returning an
empty alerts list, which is the difference between "no problems" and "no
permission".
"""
return get(session, "%s/Accounts/%s.json" % (BASE, account))
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 main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=2,
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")
ap.add_argument("--min-alerts", type=int, default=3,
help="fewer connection failures than this is reported as isolated")
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)
acct = account_preflight(session, account)
log.info("account %s (%s), status %s", account, acct.get("friendly_name"),
acct.get("status"))
since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
alerts = list_alerts(session, since, args.max_alerts)
rows = tally(alerts)
bad = 0
for host, row in sorted(rows.items()):
state, detail = verdict(host, row, args.min_alerts)
line = "%-14s %s %s" % (state, host or "(no host)", detail)
if state == "clean":
log.info(line)
continue
bad += 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"]))
if state == "misconfigured":
log.warning(" repair: repoint the webhook at a publicly resolvable "
"host. Check VoiceUrl and SmsUrl on the number with GET "
"/2010-04-01/Accounts/%s/IncomingPhoneNumbers.json",
account)
elif state == "flapping":
log.warning(" repair: acknowledge with an empty 200 immediately and "
"do the work asynchronously, then give the listener "
"enough backlog and workers to accept a connection "
"within 10 seconds.")
else:
log.warning(" repair: allowlist Twilio's egress ranges at the "
"firewall or WAF and confirm the host answers publicly "
"on that port. Nothing in your own logs will confirm "
"this: the request never arrived.")
log.info("%d host(s) with webhook alerts, %d unreachable", len(rows), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report webhook hosts Twilio cannot open a connection to (error 11205).
*
* 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 CONNECT_FAILURE = 11205;
const RETRIEVAL_FAILURE = 11200;
// Alerts are retained 30 days and nothing else remembers a failed connection.
const MAX_DAYS = 30;
/**
* Read error_code off an alert as a number, or null. The Monitor API returns it
* as a string, and comparing the raw value against 11205 reports 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 webhook URL, without the port. A connection failure
* happens before any path is requested, so grouping by full URL turns one dead
* host into one finding per endpoint.
*/
export function hostOf(url) {
if (!url) return '';
const raw = String(url).trim();
try {
const u = new URL(raw);
if (u.hostname) return u.hostname.toLowerCase();
} catch {
return raw.toLowerCase();
}
return raw.toLowerCase();
}
/**
* Why Twilio can never open a connection to this host, or null. Twilio dials
* from the public internet, so a private or loopback address is not a firewall
* problem and no allowlist will fix it.
*
* RFC 1918 reserves 172.16.0.0/12, which stops at 172.31. Treating 172.32 as
* private sends somebody to argue with a network team about an address that was
* never the problem.
*/
export function unroutable(host) {
let h = String(host ?? '').trim().toLowerCase();
while (h.startsWith('[')) h = h.slice(1);
while (h.endsWith(']')) h = h.slice(0, -1);
if (!h) return 'empty host';
if (h === 'localhost' || h === '::1') return 'loopback';
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 (numeric) {
const o = labels.map((l) => Number(l));
if (o.some((n) => n > 255)) return 'malformed IP literal';
const [a, b] = o;
if (a === 0) return 'unspecified address';
if (a === 127) return 'loopback';
if (a === 10) return 'private address';
if (a === 172 && b >= 16 && b <= 31) return 'private address';
if (a === 192 && b === 168) return 'private address';
if (a === 169 && b === 254) return 'link-local address';
if (a === 100 && b >= 64 && b <= 127) return 'carrier-grade NAT address';
}
return null;
}
/**
* Group connection and retrieval failures by host. Pure. Both codes are kept
* per host because the comparison between them is the diagnosis.
*/
export function tally(alerts) {
const out = new Map();
for (const a of alerts) {
const code = codeOf(a);
if (code !== CONNECT_FAILURE && code !== RETRIEVAL_FAILURE) continue;
const h = hostOf(a.request_url);
if (!out.has(h)) {
out.set(h, { timeouts: 0, retrievals: 0, sids: [], first: null, last: null, url: '' });
}
const row = out.get(h);
if (code === CONNECT_FAILURE) {
row.timeouts += 1;
if (row.sids.length < 3) row.sids.push(a.sid);
row.url = row.url || (a.request_url ?? '');
} else {
row.retrievals += 1;
}
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 host. Pure. The order matters: an unroutable address is reported
* on a single alert because one is proof, and a host that also has 11200 alerts
* is capacity however few connection failures it has, because it answers.
* Returns [state, detail].
*/
export function verdict(host, row, minAlerts = 3) {
const timeouts = Number(row.timeouts ?? 0);
const retrievals = Number(row.retrievals ?? 0);
if (!timeouts) {
return ['clean', `${retrievals} retrieval failure(s), no connection failures`];
}
const reason = unroutable(host);
if (reason) {
return ['misconfigured',
`${timeouts} x 11205 against a ${reason}. No firewall change reaches ` +
'this: the configured URL points somewhere Twilio can never dial, so the ' +
'repair is the URL.'];
}
if (retrievals) {
return ['flapping',
`${timeouts} x 11205 and ${retrievals} x 11200 on the same host. It ` +
'answers some of the time, so this is capacity rather than a firewall: a ' +
'full backlog queue or an exhausted pool inside the 10 second connect budget.'];
}
if (timeouts < minAlerts) {
return ['isolated',
`${timeouts} x 11205 and nothing else. Too few to call an outage; a ` +
'restart or a scaling event closes the listener for a moment and looks ' +
'exactly like this.'];
}
return ['unreachable',
`${timeouts} x 11205 and not one 11200. Nothing ever completed a handshake, ` +
'so your access log has no record of any of it: a firewall dropping ' +
"Twilio's egress ranges, or a host that is gone."];
}
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();
}
/** Confirm the key belongs to this account before reporting an empty result. */
export async function accountPreflight(auth, account) {
return get(auth, `${BASE}/Accounts/${account}.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 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] : 2) || 2;
if (days > MAX_DAYS) {
console.warn(`alerts are retained ${MAX_DAYS} days; reading ${MAX_DAYS} instead`);
days = MAX_DAYS;
}
const acct = await accountPreflight(auth, account);
console.log(`account ${account} (${acct.friendly_name}), status ${acct.status}`);
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const alerts = await listAlerts(auth, since);
const rows = tally(alerts);
let bad = 0;
for (const [host, row] of [...rows.entries()].sort()) {
const [state, detail] = verdict(host, row);
const line = `${state.padEnd(14)} ${host || '(no host)'} ${detail}`;
if (state === 'clean') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` first ${row.first}, last ${row.last}, sample ${row.url || '(none)'}`);
console.warn(` alert sids: ${row.sids.join(', ')}`);
if (state === 'misconfigured') {
console.warn(' repair: repoint the webhook at a publicly resolvable host. ' +
`Check VoiceUrl and SmsUrl with GET /2010-04-01/Accounts/${account}` +
'/IncomingPhoneNumbers.json');
} else if (state === 'flapping') {
console.warn(' repair: acknowledge with an empty 200 immediately and do ' +
'the work asynchronously, then give the listener enough ' +
'backlog and workers to accept a connection within 10 seconds.');
} else {
console.warn(" repair: allowlist Twilio's egress ranges at the firewall " +
'or WAF and confirm the host answers publicly on that port. ' +
'Nothing in your own logs will confirm this: the request ' +
'never arrived.');
}
}
console.log(`${rows.size} host(s) with webhook alerts, ${bad} unreachable`);
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 address test is the one that earns its keep. 172.31.5.4 is private and 172.32.5.4 is not, and a report that confuses them costs a network team a week. The rest pins down the pairing: a host with only 11205 is unreachable, the same host with a single 11200 beside it is a capacity problem, and those two sentences send you to different people.
from twilio_webhook_timeout_audit import code_of, host_of, tally, unroutable, verdict
def alert(sid, url, code="11205", when="2026-04-01T12: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": "11205"}) == 11205
assert code_of({"error_code": 11205}) == 11205
assert code_of({"error_code": ""}) is None
def test_host_of_drops_the_path_and_the_port():
assert host_of("https://Hooks.Example.com:8443/voice?CallSid=CA1") == \
"hooks.example.com"
assert host_of("https://hooks.example.com/sms") == "hooks.example.com"
assert host_of(None) == ""
def test_the_172_block_stops_at_31():
# RFC 1918 reserves 172.16.0.0/12. Getting this wrong sends somebody to
# argue with a network team about a perfectly public address.
assert unroutable("172.16.0.1") == "private address"
assert unroutable("172.31.255.254") == "private address"
assert unroutable("172.32.0.1") is None
assert unroutable("172.15.0.1") is None
def test_the_other_addresses_twilio_can_never_dial():
assert unroutable("127.0.0.1") == "loopback"
assert unroutable("localhost") == "loopback"
assert unroutable("10.4.2.1") == "private address"
assert unroutable("192.168.1.10") == "private address"
assert unroutable("169.254.169.254") == "link-local address"
assert unroutable("100.100.0.1") == "carrier-grade NAT address"
assert unroutable("hooks.example.com") is None
assert unroutable("999.1.1.1") == "malformed IP literal"
def test_tally_keeps_both_codes_on_one_host():
rows = tally([alert("NO1", "https://hooks.example.com/voice"),
alert("NO2", "https://hooks.example.com/sms"),
alert("NO3", "https://hooks.example.com/sms", code="11200"),
alert("NO4", "https://hooks.example.com/sms", code="11236")])
row = rows["hooks.example.com"]
assert row["timeouts"] == 2
assert row["retrievals"] == 1
assert row["sids"] == ["NO1", "NO2"]
def test_a_private_address_is_reported_on_a_single_alert():
state, detail = verdict("10.0.0.7", {"timeouts": 1, "retrievals": 0})
assert state == "misconfigured"
assert "No firewall change" in detail
def test_a_host_with_both_codes_is_capacity_not_a_firewall():
state, detail = verdict("hooks.example.com", {"timeouts": 40, "retrievals": 2})
assert state == "flapping"
assert "10 second" in detail
def test_a_run_of_timeouts_with_no_replies_is_unreachable():
state, detail = verdict("hooks.example.com", {"timeouts": 40, "retrievals": 0})
assert state == "unreachable"
assert "access log" in detail
def test_one_timeout_is_a_restart_not_an_outage():
state, _ = verdict("hooks.example.com", {"timeouts": 1, "retrievals": 0})
assert state == "isolated"
def test_retrieval_failures_alone_are_not_this_report():
state, _ = verdict("hooks.example.com", {"timeouts": 0, "retrievals": 90})
assert state == "clean"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
codeOf, hostOf, tally, unroutable, verdict,
} from './twilio-webhook-timeout-audit.mjs';
const alert = (sid, url, code = '11205', when = '2026-04-01T12: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: '11205' }), 11205);
assert.equal(codeOf({ error_code: 11205 }), 11205);
assert.equal(codeOf({ error_code: '' }), null);
});
test('hostOf drops the path and the port', () => {
assert.equal(hostOf('https://Hooks.Example.com:8443/voice?CallSid=CA1'),
'hooks.example.com');
assert.equal(hostOf('https://hooks.example.com/sms'), 'hooks.example.com');
assert.equal(hostOf(null), '');
});
test('the 172 block stops at 31', () => {
assert.equal(unroutable('172.16.0.1'), 'private address');
assert.equal(unroutable('172.31.255.254'), 'private address');
assert.equal(unroutable('172.32.0.1'), null);
assert.equal(unroutable('172.15.0.1'), null);
});
test('the other addresses Twilio can never dial', () => {
assert.equal(unroutable('127.0.0.1'), 'loopback');
assert.equal(unroutable('localhost'), 'loopback');
assert.equal(unroutable('10.4.2.1'), 'private address');
assert.equal(unroutable('192.168.1.10'), 'private address');
assert.equal(unroutable('169.254.169.254'), 'link-local address');
assert.equal(unroutable('100.100.0.1'), 'carrier-grade NAT address');
assert.equal(unroutable('hooks.example.com'), null);
assert.equal(unroutable('999.1.1.1'), 'malformed IP literal');
});
test('tally keeps both codes on one host', () => {
const rows = tally([
alert('NO1', 'https://hooks.example.com/voice'),
alert('NO2', 'https://hooks.example.com/sms'),
alert('NO3', 'https://hooks.example.com/sms', '11200'),
alert('NO4', 'https://hooks.example.com/sms', '11236'),
]);
const row = rows.get('hooks.example.com');
assert.equal(row.timeouts, 2);
assert.equal(row.retrievals, 1);
assert.deepEqual(row.sids, ['NO1', 'NO2']);
});
test('a private address is reported on a single alert', () => {
const [state, detail] = verdict('10.0.0.7', { timeouts: 1, retrievals: 0 });
assert.equal(state, 'misconfigured');
assert.match(detail, /No firewall change/);
});
test('a host with both codes is capacity, not a firewall', () => {
const [state, detail] = verdict('hooks.example.com', { timeouts: 40, retrievals: 2 });
assert.equal(state, 'flapping');
assert.match(detail, /10 second/);
});
test('a run of timeouts with no replies is unreachable', () => {
const [state, detail] = verdict('hooks.example.com', { timeouts: 40, retrievals: 0 });
assert.equal(state, 'unreachable');
assert.match(detail, /access log/);
});
test('one timeout is a restart, not an outage', () => {
const [state] = verdict('hooks.example.com', { timeouts: 1, retrievals: 0 });
assert.equal(state, 'isolated');
});
test('retrieval failures alone are not this report', () => {
const [state] = verdict('hooks.example.com', { timeouts: 0, retrievals: 90 });
assert.equal(state, 'clean');
});
FAQ
What is the difference between 11205 and 11200?
11205 means Twilio could not open the TCP connection at all, so the request never reached your application and your access log has no trace of it. 11200 means the connection succeeded and the response was unusable - a non-2xx, or nothing back inside the HTTP window. The first is a network fact, the second is your code.
The URL works from my laptop. Why does Twilio time out?
Because Twilio dials from its own egress ranges over the public internet, not from your laptop or your VPN. A WAF rule, a security group that lost a CIDR, or a host that only answers on a private interface all pass every test you can run locally and still fail every webhook.
How long does Twilio wait?
Roughly 10 seconds to establish the connection and 15 seconds for the whole exchange. That budget is why a host under load can produce connection failures while it is still technically alive: the listener backlog is full and the handshake never completes in time.
Why does the script report a private address separately?
Because no firewall change will ever fix it. If the configured URL points at 10.x, 192.168.x, 172.16-31.x, 127.0.0.1 or 169.254.169.254, the packets never leave Twilio's network. That finding is worth reporting on a single alert, where a public host needs a few before it means anything.
Can the script fix the webhook URL for me?
No. Everything here is a GET, including the account preflight, so an API Key with read access is all it can use. It prints the number, the field and the repair, and a human runs it - a script holding a credential that can place calls should not be editing routing at 3am.
Related field notes
- A webhook hostname with no public DNS record
- A number with no fallback URL
- Status callbacks failing with 11200
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 11205: HTTP connection failure — Twilio Docs
- Error 11200: HTTP retrieval failure — Twilio Docs
- Monitor Alert 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.