Diagnostic Twilio
error 11237: your webhook sends a chain Twilio cannot verify
The certificate is valid. It was issued last week, it does not expire for a year, and every browser in the office shows a padlock. Twilio still refuses it with 11237 Certificate Invalid - Could not find path to certificate, because your server is sending one certificate where it should be sending two, and the browsers have been quietly covering for it since the day it was installed.
Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD and keep error_code in (11235, 11237). Group by the hostname in request_url and count the two codes separately: they are different repairs. 11237 means no path to a trusted root — a missing intermediate, or a certificate from a CA that is not in Twilio's trust store. 11235 means the certificate is trusted but does not name the host that was requested.
Then read GET /2010-04-01/Accounts/{AccountSid}/Applications.json and find which TwiML Apps carry that host in any URL field, because an Application's URLs do not appear on the phone numbers that use it and are the ones people forget.
The problem in plain words
A certificate on its own proves nothing. It is signed by an intermediate, which is signed by a root, and the verifying party has to be able to walk that path to a root it already trusts. Your server is supposed to send the leaf and the intermediates so the other end can do the walk. When it sends only the leaf, verification depends entirely on whether the client happens to have the intermediate lying around.
Browsers usually do. They cache intermediates from other sites, and most will fetch a missing one from the URL named in the certificate rather than fail. Twilio's client does neither. So the install looks perfect from every desk in the building, ships, and then one class of traffic — the automated kind, the kind with no human to click through — fails completely and permanently while the padlock stays green.
Why it happens
Twilio validates against a fixed trust store and does not chase what is missing. Only CAs in that store are trusted, and no intermediate is fetched on demand. A certificate signed by an internal CA, a corporate MITM appliance, or a self-signed one has no path at all, and no amount of reissuing will produce one until the certificate comes from a public CA.
11235 and 11237 arrive together and mean different things. 11237 is a trust problem: the chain does not reach a root. 11235 is a naming problem: the chain is fine but no SAN covers the host requested. Counting them as one number produces a report that recommends installing intermediates on a host whose actual fault is a wildcard that does not cover the label it was pointed at.
A URL written as an IP address cannot really be fixed by a certificate. Certificates can carry an IP in a SAN, but very few public CAs issue them and almost no deployment has one. A webhook whose request_url is an address rather than a name is an internal value that escaped into production, and the repair is a hostname, not a reissue.
Alongside an expiry, the chain finding is usually the second symptom of one event. Renewal rewrites the file the chain is read from. A renewal script that installs only the leaf takes a host straight from 11236 to 11237, so a host showing both did not develop two independent faults — it had one bad renewal, and the repair is to install the full bundle in the same step.
The fix, as a flow
The script counts 11237 and 11235 separately, because one is a trust problem and the other is a naming problem, and then reads the TwiML Applications, whose URLs never show up on the numbers that use them.
How to fix it
Sweep the alerts for both certificate-path codes
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url, keeping 11235 and 11237 in separate counters. Read error_code as an integer; the API returns it as a string. Alerts stop at 30 days, so this is a window, not a history.
Group by hostname, and notice when it is an address
A certificate names hosts, so the hostname is the right key here. Flag the case where the host is an IPv4 or IPv6 literal: that is a structural finding rather than a certificate one, and it needs a DNS name before anything else is worth trying.
Keep 11236 in the tally without merging it
An expiry on the same host almost always shares a cause with the chain finding, because a renewal replaces the file both are read from. Report it as one event with two symptoms so the fix installs the full bundle rather than the leaf again.
Check whether anything on that host ever answered
If codes that require a response body — 11200, 11206, 12100, 12300 — also appear against the host, then some requests validated fine. That is a partial chain: one node serving the full bundle and another serving the leaf, which is what a deploy that copied only one file looks like.
Find the TwiML Apps pointing at it, then serve the full bundle
GET /2010-04-01/Accounts/{AccountSid}/Applications.json and list every app with that host in voice_url, sms_url, either fallback, or a status callback. Concatenate leaf plus intermediates in the server's certificate file and reload. For an 11235, reissue with a SAN that covers the exact host. The expiry note covers the number-side blast radius.
How to check it worked
Re-run over a window that starts after the reload. The host should be absent, not merely reduced.
python3 twilio_webhook_chain_audit.py --days 1
# 0 host(s) with a certificate Twilio cannot verify
The full code
One alerts sweep, one applications list, and a classifier that keeps the two certificate-path codes apart. The pure parts are the IP-literal test and the verdict, because the ordering inside that verdict — expiry before chain, address before name — is the whole recommendation, and an ordering is exactly the kind of thing that drifts unless a test is holding it.
"""Report webhook hosts whose certificate chain Twilio cannot verify (11237/11235).
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_chain_audit")
HOST_API = "https://api.twilio.com"
BASE = HOST_API + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
NO_PATH = 11237 # trusted root not reachable from what was presented
NAME_MISMATCH = 11235 # trusted, but no SAN covers the host requested
EXPIRED = 11236 # dated out; shares a cause with NO_PATH after a renewal
# Codes that require a response to have been read, so validation succeeded for
# those requests. Their presence means some nodes serve a complete chain.
REACHED_CODES = (11200, 11206, 11750, 12100, 12300)
# Alerts are retained 30 days.
MAX_DAYS = 30
# Every Application field that can hold a URL. An app's URLs are invisible from
# the phone numbers that use it, which is why they get missed.
APP_URL_FIELDS = ("voice_url", "voice_fallback_url", "sms_url", "sms_fallback_url",
"status_callback", "sms_status_callback")
def code_of(alert):
"""Read error_code off an alert as an integer, or None.
The Monitor API returns this as a string, and comparing it raw against
11237 quietly reports a clean account.
"""
raw = alert.get("error_code")
if raw is None or raw == "":
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def webhook_host(url):
"""Lowercase hostname from a URL, with no port.
Certificates name hosts, not listeners, so unlike a protocol audit the port
is noise here: one certificate covers every port on the name.
"""
if not url:
return ""
parts = urlsplit(str(url).strip())
return (parts.hostname or "").lower()
def is_ip_literal(host):
"""True when the host is an address rather than a name. Pure.
A certificate can carry an IP in a SAN, but few public CAs issue one and
almost no deployment has one, so an address in a webhook URL is an internal
value that escaped rather than a certificate to reissue.
"""
h = str(host or "").strip().lower()
if not h:
return False
if ":" in h:
return True
parts = h.split(".")
return (len(parts) == 4
and all(p.isdigit() and len(p) <= 3 and int(p) < 256 for p in parts))
def sweep(alerts):
"""Tally alerts per hostname, by error code. Pure.
Hosts with neither certificate-path code are dropped at the end: they are
the healthy remainder and they would bury the rows worth reading.
"""
out = {}
for a in alerts:
code = code_of(a)
if code is None:
continue
host = webhook_host(a.get("request_url"))
if not host:
continue
row = out.setdefault(host, {"codes": {}, "sids": [], "url": "",
"ip": is_ip_literal(host)})
row["codes"][code] = row["codes"].get(code, 0) + 1
if code in (NO_PATH, NAME_MISMATCH):
row["url"] = row["url"] or (a.get("request_url") or "")
if len(row["sids"]) < 3:
row["sids"].append(a.get("sid"))
return {h: r for h, r in out.items()
if r["codes"].get(NO_PATH) or r["codes"].get(NAME_MISMATCH)}
def verdict(row):
"""Classify one host from the codes logged against it. Pure.
The order is the recommendation. An expiry is checked first because renewal
rewrites the file the chain is read from, so fixing it fixes both. An
address is checked next because no reissue helps a URL that should have been
a name. Only then does the split between trust and naming matter.
Returns (state, detail).
"""
codes = row.get("codes") or {}
path = int(codes.get(NO_PATH) or 0)
name = int(codes.get(NAME_MISMATCH) or 0)
if not path and not name:
return ("clean", "no 11237 or 11235 on this host")
if codes.get(EXPIRED):
return ("renew-first",
"%d x 11237 and %d x 11235 beside %d x 11236. A renewal rewrites "
"the file the chain is read from, so this is one bad renewal "
"with two symptoms: install the leaf and the intermediates "
"together." % (path, name, codes[EXPIRED]))
if row.get("ip") and name:
return ("address-not-a-name",
"%d x 11235 against an IP address. Almost no public CA issues "
"certificates for addresses, so this URL needs a DNS name "
"before a certificate can cover it at all." % name)
if path and name:
return ("chain-and-name",
"%d x 11237 and %d x 11235: two independent faults. The chain "
"does not reach a trusted root, and the certificate does not "
"name this host either." % (path, name))
if name:
return ("name-mismatch",
"%d x 11235. The chain verifies, but no SAN covers this exact "
"host: usually a wildcard pointed at the apex, or at a label "
"one level deeper than it covers." % name)
if sum(codes.get(c, 0) for c in REACHED_CODES):
return ("partial-chain",
"%d x 11237 alongside requests that were answered. Validation "
"succeeded for those, so some nodes send the intermediates and "
"some send only the leaf." % path)
return ("no-trust-path",
"%d x 11237 and nothing answered. Either the intermediates are "
"missing from the certificate file, or the issuer is a private CA "
"that no public trust store contains." % path)
def apps_on_host(applications, host):
"""Which TwiML Apps carry this host in any URL field. Pure.
Worth its own pass because an Application's URLs never appear on the phone
numbers that route through it, so they survive an audit that only reads
numbers.
"""
out = []
for app in applications or []:
fields = [f for f in APP_URL_FIELDS if webhook_host(app.get(f)) == host]
if fields:
out.append({"sid": app.get("sid") or "?",
"name": app.get("friendly_name") or "(unnamed)",
"fields": fields})
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_applications(session, account, limit=2000):
"""Page the TwiML Applications. next_page_uri here is a path, not a URL."""
url = "%s/Accounts/%s/Applications.json" % (BASE, account)
params = {"PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("applications", []))
nxt = page.get("next_page_uri")
url, params = (HOST_API + 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.datetime.now(dt.timezone.utc)
- dt.timedelta(days=days)).date().isoformat()
alerts = list_alerts(session, since, args.max_alerts)
rows = sweep(alerts)
if not rows:
log.info("no 11237 or 11235 since %s across %d alert(s)",
since, len(alerts))
return 0
applications = list_applications(session, account)
bad = 0
for host, row in sorted(rows.items()):
state, detail = verdict(row)
line = "%-19s %s %s" % (state, host, detail)
if state == "clean":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" sample %s, alert sids: %s", row["url"] or "(none)",
", ".join(str(s) for s in row["sids"]))
for app in apps_on_host(applications, host):
log.warning(" app %s %s uses it on %s", app["sid"], app["name"],
", ".join(app["fields"]))
log.warning(" repair: serve the leaf and its intermediates "
"concatenated in the certificate file and reload the "
"terminating server. For an 11235, reissue with a SAN that "
"covers %s exactly. There is no Twilio-side setting.", host)
log.info("%d host(s) with a certificate Twilio cannot verify", bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report webhook hosts whose certificate chain Twilio cannot verify
* (11237 and 11235).
*
* 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_API = 'https://api.twilio.com';
const BASE = `${HOST_API}/2010-04-01`;
const MONITOR = 'https://monitor.twilio.com/v1';
const NO_PATH = 11237; // trusted root not reachable from what was sent
const NAME_MISMATCH = 11235; // trusted, but no SAN covers the host requested
const EXPIRED = 11236; // shares a cause with NO_PATH after a renewal
// Codes that require a response to have been read, so validation succeeded.
const REACHED_CODES = [11200, 11206, 11750, 12100, 12300];
// Alerts are retained 30 days.
const MAX_DAYS = 30;
// Every Application field that can hold a URL.
const APP_URL_FIELDS = ['voice_url', 'voice_fallback_url', 'sms_url',
'sms_fallback_url', 'status_callback', 'sms_status_callback'];
/** Read error_code off an alert as a number, or null. */
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, with no port. Certificates name hosts, so the
* port is noise here: one certificate covers every port on the name.
*/
export function webhookHost(url) {
if (!url) return '';
try {
return new URL(String(url).trim()).hostname.toLowerCase();
} catch {
return '';
}
}
/**
* True when the host is an address rather than a name. Pure. Few public CAs
* issue certificates for addresses, so this is a URL to replace, not a
* certificate to reissue.
*/
export function isIpLiteral(host) {
const h = String(host ?? '').trim().toLowerCase();
if (!h) return false;
if (h.includes(':')) return true;
const parts = h.split('.');
return parts.length === 4 && parts.every(
(p) => /^[0-9]{1,3}$/.test(p) && Number(p) < 256);
}
/**
* Tally alerts per hostname, by error code. Pure. Hosts with neither
* certificate-path code are dropped.
*/
export function sweep(alerts) {
const out = new Map();
for (const a of alerts) {
const code = codeOf(a);
if (code === null) continue;
const host = webhookHost(a.request_url);
if (!host) continue;
if (!out.has(host)) {
out.set(host, { codes: {}, sids: [], url: '', ip: isIpLiteral(host) });
}
const row = out.get(host);
row.codes[code] = (row.codes[code] ?? 0) + 1;
if (code === NO_PATH || code === NAME_MISMATCH) {
row.url = row.url || (a.request_url ?? '');
if (row.sids.length < 3) row.sids.push(a.sid);
}
}
for (const [host, row] of out) {
if (!row.codes[NO_PATH] && !row.codes[NAME_MISMATCH]) out.delete(host);
}
return out;
}
/**
* Classify one host from the codes logged against it. Pure.
* Returns [state, detail].
*/
export function verdict(row) {
const codes = row.codes ?? {};
const path = Number(codes[NO_PATH] ?? 0);
const name = Number(codes[NAME_MISMATCH] ?? 0);
if (!path && !name) return ['clean', 'no 11237 or 11235 on this host'];
if (codes[EXPIRED]) {
return ['renew-first',
`${path} x 11237 and ${name} x 11235 beside ${codes[EXPIRED]} x 11236. ` +
'A renewal rewrites the file the chain is read from, so this is one bad ' +
'renewal with two symptoms: install the leaf and the intermediates ' +
'together.'];
}
if (row.ip && name) {
return ['address-not-a-name',
`${name} x 11235 against an IP address. Almost no public CA issues ` +
'certificates for addresses, so this URL needs a DNS name before a ' +
'certificate can cover it at all.'];
}
if (path && name) {
return ['chain-and-name',
`${path} x 11237 and ${name} x 11235: two independent faults. The chain ` +
'does not reach a trusted root, and the certificate does not name this ' +
'host either.'];
}
if (name) {
return ['name-mismatch',
`${name} x 11235. The chain verifies, but no SAN covers this exact host: ` +
'usually a wildcard pointed at the apex, or at a label one level deeper ' +
'than it covers.'];
}
if (REACHED_CODES.reduce((t, c) => t + (codes[c] ?? 0), 0)) {
return ['partial-chain',
`${path} x 11237 alongside requests that were answered. Validation ` +
'succeeded for those, so some nodes send the intermediates and some ' +
'send only the leaf.'];
}
return ['no-trust-path',
`${path} x 11237 and nothing answered. Either the intermediates are ` +
'missing from the certificate file, or the issuer is a private CA that no ' +
'public trust store contains.'];
}
/**
* Which TwiML Apps carry this host in any URL field. Pure. An app's URLs never
* appear on the numbers routing through it, so they survive a number-only audit.
*/
export function appsOnHost(applications, host) {
const out = [];
for (const app of applications ?? []) {
const fields = APP_URL_FIELDS.filter((f) => webhookHost(app[f]) === host);
if (fields.length) {
out.push({ sid: app.sid ?? '?', name: app.friendly_name ?? '(unnamed)', fields });
}
}
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);
}
export async function listApplications(auth, account, limit = 2000) {
let url = `${BASE}/Accounts/${account}/Applications.json`;
let params = { PageSize: 1000 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.applications ?? []));
url = page.next_page_uri ? HOST_API + 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);
const arg = process.argv.indexOf('--days');
let days = arg > -1 ? Number(process.argv[arg + 1]) : 7;
if (!Number.isFinite(days) || days < 1) days = 7;
if (days > MAX_DAYS) {
console.warn(`alerts are retained ${MAX_DAYS} days; reading ${MAX_DAYS}`);
days = MAX_DAYS;
}
const since = new Date(Date.now() - days * 86400_000).toISOString().slice(0, 10);
const alerts = await listAlerts(auth, since);
const rows = sweep(alerts);
if (rows.size === 0) {
console.log(`no 11237 or 11235 since ${since} across ${alerts.length} alert(s)`);
return;
}
const applications = await listApplications(auth, account);
let bad = 0;
for (const host of [...rows.keys()].sort()) {
const row = rows.get(host);
const [state, detail] = verdict(row);
const line = `${state.padEnd(19)} ${host} ${detail}`;
if (state === 'clean') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` sample ${row.url || '(none)'}, alert sids: ${row.sids.join(', ')}`);
for (const app of appsOnHost(applications, host)) {
console.warn(` app ${app.sid} ${app.name} uses it on ${app.fields.join(', ')}`);
}
console.warn(' repair: serve the leaf and its intermediates concatenated ' +
'in the certificate file and reload the terminating server. For an ' +
`11235, reissue with a SAN that covers ${host} exactly. There is no ` +
'Twilio-side setting.');
}
console.log(`${bad} host(s) with a certificate Twilio cannot verify`);
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
What the tests hold in place is the order of the verdict. An expiry on the same host outranks the chain finding, because one renewal caused both and one renewal fixes both. An address outranks a naming complaint, because no reissue rescues a URL that should have been a hostname. And a host that answered some requests is a partial chain rather than a missing one, which is a different machine to go and look at.
from twilio_webhook_chain_audit import (
apps_on_host, is_ip_literal, sweep, verdict, webhook_host)
def alert(sid, url, code="11237"):
return {"sid": sid, "request_url": url, "error_code": code,
"date_generated": "2026-05-05T14:08:00Z"}
def test_webhook_host_drops_the_port_because_certificates_name_hosts():
assert webhook_host("https://Hooks.Example.com:8443/voice") == "hooks.example.com"
assert webhook_host("https://hooks.example.com/voice") == "hooks.example.com"
assert webhook_host("nonsense") == ""
assert webhook_host(None) == ""
def test_is_ip_literal_accepts_addresses_and_rejects_names():
assert is_ip_literal("203.0.113.9") is True
assert is_ip_literal("2001:db8::1") is True
assert is_ip_literal("hooks.example.com") is False
assert is_ip_literal("203.0.113.999") is False
assert is_ip_literal("") is False
def test_sweep_keeps_only_hosts_with_a_certificate_path_failure():
rows = sweep([alert("A1", "https://a.example.com/voice"),
alert("A2", "https://b.example.com/voice", code="11200"),
alert("A3", "https://c.example.com/sms", code="11235")])
assert sorted(rows) == ["a.example.com", "c.example.com"]
def test_a_port_does_not_split_a_host_the_way_it_splits_a_listener():
rows = sweep([alert("A1", "https://a.example.com/voice"),
alert("A2", "https://a.example.com:8443/voice")])
assert sorted(rows) == ["a.example.com"]
assert rows["a.example.com"]["codes"][11237] == 2
def test_an_expiry_on_the_same_host_is_reported_as_one_bad_renewal():
state, detail = verdict({"codes": {11237: 900, 11236: 120}})
assert state == "renew-first"
assert "one bad renewal" in detail
def test_a_mismatch_against_an_address_needs_a_name_not_a_reissue():
state, detail = verdict({"codes": {11235: 40}, "ip": True})
assert state == "address-not-a-name"
assert "DNS name" in detail
def test_answered_requests_beside_11237_mean_a_partial_chain():
state, detail = verdict({"codes": {11237: 30, 11200: 200}})
assert state == "partial-chain"
assert "only the leaf" in detail
def test_11237_alone_is_a_missing_intermediate_or_a_private_ca():
state, detail = verdict({"codes": {11237: 2000}})
assert state == "no-trust-path"
assert "private CA" in detail
def test_both_codes_without_an_expiry_are_two_faults():
assert verdict({"codes": {11237: 5, 11235: 5}})[0] == "chain-and-name"
def test_no_path_codes_is_clean():
assert verdict({"codes": {11200: 12}})[0] == "clean"
def test_apps_on_host_finds_urls_that_no_phone_number_shows():
apps = [
{"sid": "AP1", "friendly_name": "voice router",
"voice_url": "https://hooks.example.com/voice",
"sms_url": "https://other.example.net/sms"},
{"sid": "AP2", "voice_url": "https://elsewhere.example.net/voice"},
]
hit = apps_on_host(apps, "hooks.example.com")
assert [h["sid"] for h in hit] == ["AP1"]
assert hit[0]["fields"] == ["voice_url"]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
appsOnHost, isIpLiteral, sweep, verdict, webhookHost,
} from './twilio-webhook-chain-audit.mjs';
const alert = (sid, url, code = '11237') => ({
sid, request_url: url, error_code: code, date_generated: '2026-05-05T14:08:00Z',
});
test('webhookHost drops the port because certificates name hosts', () => {
assert.equal(webhookHost('https://Hooks.Example.com:8443/voice'), 'hooks.example.com');
assert.equal(webhookHost('https://hooks.example.com/voice'), 'hooks.example.com');
assert.equal(webhookHost('nonsense'), '');
assert.equal(webhookHost(null), '');
});
test('isIpLiteral accepts addresses and rejects names', () => {
assert.equal(isIpLiteral('203.0.113.9'), true);
assert.equal(isIpLiteral('2001:db8::1'), true);
assert.equal(isIpLiteral('hooks.example.com'), false);
assert.equal(isIpLiteral('203.0.113.999'), false);
assert.equal(isIpLiteral(''), false);
});
test('sweep keeps only hosts with a certificate path failure', () => {
const rows = sweep([
alert('A1', 'https://a.example.com/voice'),
alert('A2', 'https://b.example.com/voice', '11200'),
alert('A3', 'https://c.example.com/sms', '11235'),
]);
assert.deepEqual([...rows.keys()].sort(), ['a.example.com', 'c.example.com']);
});
test('a port does not split a host the way it splits a listener', () => {
const rows = sweep([
alert('A1', 'https://a.example.com/voice'),
alert('A2', 'https://a.example.com:8443/voice'),
]);
assert.deepEqual([...rows.keys()], ['a.example.com']);
assert.equal(rows.get('a.example.com').codes[11237], 2);
});
test('an expiry on the same host is reported as one bad renewal', () => {
const [state, detail] = verdict({ codes: { 11237: 900, 11236: 120 } });
assert.equal(state, 'renew-first');
assert.match(detail, /one bad renewal/);
});
test('a mismatch against an address needs a name not a reissue', () => {
const [state, detail] = verdict({ codes: { 11235: 40 }, ip: true });
assert.equal(state, 'address-not-a-name');
assert.match(detail, /DNS name/);
});
test('answered requests beside 11237 mean a partial chain', () => {
const [state, detail] = verdict({ codes: { 11237: 30, 11200: 200 } });
assert.equal(state, 'partial-chain');
assert.match(detail, /only the leaf/);
});
test('11237 alone is a missing intermediate or a private CA', () => {
const [state, detail] = verdict({ codes: { 11237: 2000 } });
assert.equal(state, 'no-trust-path');
assert.match(detail, /private CA/);
});
test('both codes without an expiry are two faults', () => {
assert.equal(verdict({ codes: { 11237: 5, 11235: 5 } })[0], 'chain-and-name');
});
test('no path codes is clean', () => {
assert.equal(verdict({ codes: { 11200: 12 } })[0], 'clean');
});
test('appsOnHost finds urls that no phone number shows', () => {
const apps = [
{ sid: 'AP1',
friendly_name: 'voice router',
voice_url: 'https://hooks.example.com/voice',
sms_url: 'https://other.example.net/sms' },
{ sid: 'AP2', voice_url: 'https://elsewhere.example.net/voice' },
];
const hit = appsOnHost(apps, 'hooks.example.com');
assert.deepEqual(hit.map((h) => h.sid), ['AP1']);
assert.deepEqual(hit[0].fields, ['voice_url']);
});
FAQ
Why does my browser accept a certificate that Twilio rejects?
Because browsers fill in what your server left out. They cache intermediates seen on other sites and most will fetch a missing one from the address printed in the certificate. Twilio's client does neither: it validates against a fixed trust store using only what the server actually presented.
What is the difference between 11237 and 11235?
11237 is a trust problem: nothing in what was presented leads to a root Twilio trusts, so the intermediates are missing or the issuer is private. 11235 is a naming problem: the chain is fine and the certificate simply does not cover the host requested. Different files, different repairs.
Can I add my internal CA to Twilio's trust store?
No. There is no per-account trust configuration, which means a certificate from an internal CA, a corporate inspection appliance or a self-signed pair can never validate. A webhook endpoint has to hold a certificate from a public CA.
Why does the script report an expired certificate as the same finding?
Because it usually is. Renewal replaces the file the chain is read from, and a renewal script that writes only the leaf takes a host straight from 11236 to 11237. Two symptoms, one bad renewal, one repair that installs the full bundle.
A few 11237s but most webhooks work. Is the chain broken or not?
On some nodes. Validation is all or nothing for a given certificate, so requests that were answered were answered by a machine sending the complete bundle. Look for the node that got only the leaf copied to it, typically after a deploy that moved one file of two.
Related field notes
- The same host, dated out instead
- A refusal before any certificate is sent
- A hostname with no public DNS record
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 11237: certificate invalid, could not find path to certificate — Twilio Docs
- Error 11235: certificate invalid, domain mismatch — Twilio Docs
- Monitor Alert resource — Twilio Docs
- Application resource — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.