Skip to content

Diagnostic Twilio

error 11220: the TLS handshake with your webhook never completes

You paste the webhook URL into a browser and it loads, padlock and all. You run curl against it and get your TwiML back. And the Debugger keeps filling with 11220 SSL/TLS Handshake Error for that exact URL. Nothing is wrong with the certificate, because nothing ever got as far as looking at the certificate — the two ends could not agree on how to talk before either of them said who they were.

Read-only key Python and Node.js Tests included
Man sitting on chair wearing gray crew-neck long-sleeved shirt using apple magic keyboard
Photo by Tim van der Kuip on Unsplash
The short answer

Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD, keep error_code 11220, and key the results on host and port from request_url, writing the port out even when it is 443.

Then read every other code logged against that same listener. If 11200, 11206, 12100, 12300 or 11750 also appear, TLS completed for those requests, so the endpoint is not uniformly refusing to negotiate and you are looking at one machine with an old configuration. If nothing but 11220 appears, the listener offers no protocol version or cipher suite Twilio's client accepts — almost always TLS 1.0/1.1 only, or a hand-hardened suite list.

The problem in plain words

The handshake is the part of TLS that happens before identity. Client and server exchange what versions and cipher suites they support and pick an intersection; the certificate is not sent until that intersection exists. So an 11220 tells you something narrower than "TLS is broken": it tells you the intersection was empty. Nothing was validated, nothing was refused on trust grounds, and the certificate on that host may be flawless.

That is why every tool on your desk disagrees with Twilio. A browser carries a deliberately generous compatibility list, and curl offers whatever the local OpenSSL was built with, which on a developer laptop is usually broad. Twilio's HTTP client offers what it offers. Three clients, three different intersections with your server, and only one of them empty — which is experienced as "it works everywhere except Twilio", a sentence that sends people to look at Twilio.

Call arriveshandler is healthyTwilio connectsTCP opens fineClient offersversions andciphersNo intersectionconnection resetLogged 11220certificate neverseen
Nothing is validated and nothing is rejected on trust. The two ends simply found no version or cipher they both support.

Why it happens

A handshake failure is not a certificate failure, and the codes are separate for a reason. 11235, 11236 and 11237 all mean a certificate was presented and rejected. 11220 means the conversation ended earlier than that. Treating them as one bucket called "SSL problems" is how a team spends an afternoon renewing a certificate that had two months left on it.

The listener is the unit, not the hostname. One hostname can front :443 and :8443 with two entirely separate protocol configurations, often on two different pieces of software. Keying on the hostname alone merges them and produces a verdict about an endpoint that does not exist. Writing the port out even when it is the default keeps the report readable as a list of things you can go and reconfigure.

Other codes on the same listener are the strongest evidence you have. An 11200 means Twilio read a response and disliked the status; an 11206 means it read bytes it could not parse; a 12100 means it parsed the HTTP and disliked the XML. Every one of those required a completed handshake. Their presence alongside 11220 is proof that the endpoint negotiates fine with Twilio some of the time, which narrows the problem from "the server" to "one node behind the balancer".

Sweeping only the error level hides that evidence. Several Twilio failures are recorded at LogLevel=warning rather than error. The 11220s themselves are errors, so a single-level sweep still finds the failing listener — it just loses some of the alerts that prove TLS worked, and the verdict flips from "one stale node" to "this endpoint is misconfigured". Read both levels.

The fix, as a flow

The script keys on host and port with the port always written out, and counts every code logged against that listener rather than only the 11220s, because the other codes are what decide which of three things an 11220 means.

Alerts at error and warningkeyed on host and portNo 11220 herehealthy listener11220 plus 1123xclear the certificate first11220 plus answered callsone node, old config11220 and nothing elseno shared cipher suite
A certificate code means the handshake reached the stage where certificates are sent. A code that needed a response means it finished.

How to fix it

Sweep both log levels for the window you care about

GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url, then the same again with LogLevel=warning. Alerts are retained 30 days and the request is capped at 10,000, so pick a window rather than asking for everything and hoping.

Read error_code as an integer

The Monitor API returns error_code as a string. Comparing the raw value against 11220 matches nothing and reports a spotless account, which is the single most convincing wrong answer this API can give you.

Key on host and port, with the port written out

Derive the key from request_url as host:port, filling in 443 for https and 80 for http. The negotiation belongs to a listener. Two on the same name can have two different stories, and the port is the half of the key that tells you which config file to open.

Count every code against that listener, not just 11220

Keep the whole tally. Certificate codes 11235, 11236 and 11237 mean a certificate was presented, so the handshake reached that stage at least sometimes. Codes that require a response body — 11200, 11206, 11750, 12100, 12300 — mean it completed. Both groups change what the 11220s mean.

Fix it on the endpoint, because there is no Twilio-side setting

Enable TLS 1.2 or later with a mainstream cipher suite list on the server or load balancer terminating that port, then re-run over a window that starts after the change. If the verdict was one stale node, find the node: a pool member restored from an old image, or a terminator that was upgraded everywhere except one region.

How to check it worked

Re-run over a window that begins after the reconfiguration. The listener should be gone from the report entirely, not merely quieter.

python3 twilio_tls_handshake_audit.py --days 1
# 0 listener(s) failing the TLS handshake

The full code

Two sweeps of one endpoint — the alerts list at error level and at warning level — and a classifier made entirely of code counts. The pure parts are the listener key and the verdict, because the whole argument of this note is that other codes on the same listener change what an 11220 means, and an argument like that should be somewhere a test can hold it still.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_tls_handshake_audit.py
"""Report webhook listeners whose TLS handshake Twilio cannot complete (11220).

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_tls_handshake_audit")

MONITOR = "https://monitor.twilio.com/v1"

HANDSHAKE = 11220

# Certificate validation failures. Each of these means a certificate was
# presented, which means the handshake got past version and cipher negotiation.
# They are a different fault on a different file from an 11220.
CERT_CODES = (11235, 11236, 11237)

# Codes that cannot be raised until a response has been read back. Every one of
# them required a completed handshake, so their presence beside an 11220 is the
# evidence that separates "this listener offers nothing we accept" from "one
# machine behind the balancer does not".
REACHED_CODES = (11200, 11206, 11750, 12100, 12300)

# Alerts are retained 30 days. Nothing here can see further back than that.
MAX_DAYS = 30

# Several Twilio failures are logged at warning rather than error. The 11220s
# are errors either way, but some of the REACHED_CODES are not, and losing them
# turns one stale node into a report that condemns the whole endpoint.
LEVELS = ("error", "warning")

DEFAULT_PORTS = {"http": 80, "https": 443}


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
    11220 matches nothing and reports a healthy account.
    """
    raw = alert.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def listener(url):
    """Host and port, with the port always written out.

    A handshake is negotiated by whatever is listening on a port, not by a
    domain, and the port is the half of the key that says which config file to
    open. It stays in the key even when it is the default, because a report that
    silently drops 443 reads as though the port did not matter.
    """
    if not url:
        return ""
    parts = urlsplit(str(url).strip())
    host = (parts.hostname or "").lower()
    if not host:
        return ""
    try:
        port = parts.port
    except ValueError:
        port = None
    scheme = (parts.scheme or "").lower()
    return "%s:%d" % (host, port or DEFAULT_PORTS.get(scheme, 443))


def sweep(alerts):
    """Tally every alert per listener, by error code. Pure.

    Listeners with no 11220 are dropped at the end: they are the healthy rest of
    the account and they would bury the four rows that matter.
    """
    out = {}
    for a in alerts:
        code = code_of(a)
        if code is None:
            continue
        key = listener(a.get("request_url"))
        if not key:
            continue
        row = out.setdefault(key, {"codes": {}, "sids": [], "url": ""})
        row["codes"][code] = row["codes"].get(code, 0) + 1
        if code == HANDSHAKE:
            row["url"] = row["url"] or (a.get("request_url") or "")
            if len(row["sids"]) < 3:
                row["sids"].append(a.get("sid"))
    return {k: v for k, v in out.items() if v["codes"].get(HANDSHAKE)}


def verdict(row):
    """Classify one listener from the mix of codes logged against it. Pure.

    The order is the point. A certificate code proves the handshake reached the
    stage where a certificate is sent; a code that needed a response proves it
    finished. Either one contradicts the simple reading of an 11220, and both
    are cheaper to act on than a protocol audit.

    Returns (state, detail).
    """
    codes = row.get("codes") or {}
    n = int(codes.get(HANDSHAKE) or 0)
    if not n:
        return ("clean", "no 11220 on this listener")

    certs = sorted((c, codes[c]) for c in CERT_CODES if codes.get(c))
    if certs:
        named = ", ".join("%d x %d" % (count, code) for code, count in certs)
        return ("certificate-first",
                "%d x 11220, and also %s. A certificate is only sent once "
                "version and cipher are agreed, so this listener is not "
                "refusing every negotiation. Clear the named certificate fault "
                "first and re-run." % (n, named))

    reached = sum(codes.get(c, 0) for c in REACHED_CODES)
    if reached:
        return ("one-node",
                "%d x 11220 beside %d alert(s) that could only be raised after "
                "a response was read. TLS completed for those, so the endpoint "
                "does negotiate with this client: one machine behind the "
                "balancer is still on the old protocol configuration."
                % (n, reached))

    return ("no-shared-parameters",
            "%d x 11220 and not one alert that required a response. Every "
            "attempt ended during negotiation: this listener offers no protocol "
            "version or cipher suite the client will accept." % n)


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):
    """Page the Monitor alerts at one level. next_page_url is absolute here."""
    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=7,
                    help="how far back to read alerts (Twilio keeps 30 days)")
    ap.add_argument("--max-alerts", type=int, default=10000,
                    help="stop paging at this many alerts per log level")
    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 = []
    for level in LEVELS:
        got = list_alerts(session, since, args.max_alerts, level)
        log.info("%d alert(s) at LogLevel=%s since %s", len(got), level, since)
        alerts.extend(got)

    rows = sweep(alerts)
    if not rows:
        log.info("no 11220 since %s across %d alert(s)", since, len(alerts))
        return 0

    bad = 0
    for key, row in sorted(rows.items()):
        state, detail = verdict(row)
        line = "%-21s %s  %s" % (state, key, 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"]))
        log.warning("  codes seen here: %s",
                    ", ".join("%d x %d" % (v, c)
                              for c, v in sorted(row["codes"].items())))
        log.warning("  repair: enable TLS 1.2 or later with a mainstream cipher "
                    "suite list on the server or load balancer terminating %s. "
                    "There is no Twilio-side setting for this; the negotiation "
                    "happens entirely on your endpoint.", key)

    log.info("%d listener(s) failing the TLS handshake", bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-tls-handshake-audit.mjs
/**
 * Report webhook listeners whose TLS handshake Twilio cannot complete (11220).
 *
 * 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 MONITOR = 'https://monitor.twilio.com/v1';

const HANDSHAKE = 11220;

// A certificate is only presented once version and cipher are agreed, so any of
// these means the handshake got past negotiation.
const CERT_CODES = [11235, 11236, 11237];

// Codes that cannot be raised until a response was read back, so each one
// required a completed handshake.
const REACHED_CODES = [11200, 11206, 11750, 12100, 12300];

// Alerts are retained 30 days.
const MAX_DAYS = 30;

// Several Twilio failures are logged at warning rather than error, and some of
// the REACHED_CODES are among them.
const LEVELS = ['error', 'warning'];

const DEFAULT_PORTS = { 'http:': 80, 'https:': 443 };

/**
 * Read error_code off an alert as a number, or null. The Monitor API returns it
 * as a string, and a raw comparison reports a healthy account.
 */
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;
}

/**
 * Host and port, with the port always written out. A handshake belongs to a
 * listener, and the port is the half of the key that names the config file.
 */
export function listener(url) {
  if (!url) return '';
  let u;
  try {
    u = new URL(String(url).trim());
  } catch {
    return '';
  }
  const host = u.hostname.toLowerCase();
  if (!host) return '';
  const port = u.port ? Number(u.port) : (DEFAULT_PORTS[u.protocol] ?? 443);
  return `${host}:${port}`;
}

/**
 * Tally every alert per listener, by error code. Pure. Listeners with no 11220
 * are dropped, because they are the healthy rest of the account.
 */
export function sweep(alerts) {
  const out = new Map();
  for (const a of alerts) {
    const code = codeOf(a);
    if (code === null) continue;
    const key = listener(a.request_url);
    if (!key) continue;
    if (!out.has(key)) out.set(key, { codes: {}, sids: [], url: '' });
    const row = out.get(key);
    row.codes[code] = (row.codes[code] ?? 0) + 1;
    if (code === HANDSHAKE) {
      row.url = row.url || (a.request_url ?? '');
      if (row.sids.length < 3) row.sids.push(a.sid);
    }
  }
  for (const [key, row] of out) if (!row.codes[HANDSHAKE]) out.delete(key);
  return out;
}

/**
 * Classify one listener from the mix of codes logged against it. Pure.
 * Returns [state, detail].
 */
export function verdict(row) {
  const codes = row.codes ?? {};
  const n = Number(codes[HANDSHAKE] ?? 0);
  if (!n) return ['clean', 'no 11220 on this listener'];

  const certs = CERT_CODES.filter((c) => codes[c]).sort((a, b) => a - b);
  if (certs.length) {
    const named = certs.map((c) => `${codes[c]} x ${c}`).join(', ');
    return ['certificate-first',
      `${n} x 11220, and also ${named}. A certificate is only sent once ` +
      'version and cipher are agreed, so this listener is not refusing every ' +
      'negotiation. Clear the named certificate fault first and re-run.'];
  }

  const reached = REACHED_CODES.reduce((t, c) => t + (codes[c] ?? 0), 0);
  if (reached) {
    return ['one-node',
      `${n} x 11220 beside ${reached} alert(s) that could only be raised ` +
      'after a response was read. TLS completed for those, so the endpoint ' +
      'does negotiate with this client: one machine behind the balancer is ' +
      'still on the old protocol configuration.'];
  }

  return ['no-shared-parameters',
    `${n} x 11220 and not one alert that required a response. Every attempt ` +
    'ended during negotiation: this listener offers no protocol version or ' +
    'cipher suite the client will accept.'];
}

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, logLevel) {
  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);

  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 = [];
  for (const level of LEVELS) {
    const got = await listAlerts(auth, since, 10000, level);
    console.log(`${got.length} alert(s) at LogLevel=${level} since ${since}`);
    alerts.push(...got);
  }

  const rows = sweep(alerts);
  if (rows.size === 0) {
    console.log(`no 11220 since ${since} across ${alerts.length} alert(s)`);
    return;
  }

  let bad = 0;
  for (const key of [...rows.keys()].sort()) {
    const row = rows.get(key);
    const [state, detail] = verdict(row);
    const line = `${state.padEnd(21)} ${key}  ${detail}`;
    if (state === 'clean') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  sample ${row.url || '(none)'}, alert sids: ${row.sids.join(', ')}`);
    const seen = Object.entries(row.codes)
      .sort((a, b) => Number(a[0]) - Number(b[0]))
      .map(([c, v]) => `${v} x ${c}`).join(', ');
    console.warn(`  codes seen here: ${seen}`);
    console.warn('  repair: enable TLS 1.2 or later with a mainstream cipher ' +
      `suite list on the server or load balancer terminating ${key}. There is ` +
      'no Twilio-side setting for this; the negotiation happens entirely on ' +
      'your endpoint.');
  }

  console.log(`${bad} listener(s) failing the TLS handshake`);
  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 judgements worth freezing are the three readings of an 11220. A listener with certificate errors beside it reached the stage where certificates are sent, so it is not the protocol. A listener with codes that required a response body negotiated successfully at least sometimes, so it is one machine and not the endpoint. Only the listener with nothing but 11220 against it is the plain case, and it is the least common of the three.

test_twilio_tls_handshake_audit.py
from twilio_tls_handshake_audit import code_of, listener, sweep, verdict


def alert(sid, url, code="11220"):
    return {"sid": sid, "request_url": url, "error_code": code,
            "date_generated": "2026-05-05T14:08:00Z"}


def test_listener_always_writes_the_port_out():
    assert listener("https://hooks.example.com/voice") == "hooks.example.com:443"
    assert listener("https://Hooks.Example.com:8443/voice") == "hooks.example.com:8443"
    assert listener("http://hooks.example.com/voice") == "hooks.example.com:80"
    assert listener("not a url") == ""
    assert listener(None) == ""


def test_code_of_reads_the_string_the_monitor_api_returns():
    assert code_of({"error_code": "11220"}) == 11220
    assert code_of({"error_code": 11220}) == 11220
    assert code_of({"error_code": ""}) is None
    assert code_of({}) is None


def test_sweep_drops_listeners_with_no_handshake_failure():
    rows = sweep([alert("A1", "https://a.example.com/voice"),
                  alert("A2", "https://b.example.com/voice", code="11200"),
                  alert("A3", "https://a.example.com:8443/voice")])
    assert sorted(rows) == ["a.example.com:443", "a.example.com:8443"]


def test_two_ports_on_one_host_are_two_listeners():
    rows = sweep([alert("A1", "https://a.example.com/voice"),
                  alert("A2", "https://a.example.com:8443/voice")])
    assert rows["a.example.com:443"]["codes"][11220] == 1
    assert rows["a.example.com:8443"]["codes"][11220] == 1


def test_a_certificate_code_beside_it_means_the_handshake_got_further():
    state, detail = verdict({"codes": {11220: 40, 11236: 12}})
    assert state == "certificate-first"
    assert "11236" in detail


def test_a_code_that_needed_a_response_means_one_stale_node():
    # 11200 cannot be raised without a completed handshake, so TLS worked for
    # those requests and the endpoint as a whole negotiates fine.
    state, detail = verdict({"codes": {11220: 9, 11200: 300}})
    assert state == "one-node"
    assert "balancer" in detail


def test_only_11220_is_the_plain_protocol_mismatch():
    state, detail = verdict({"codes": {11220: 512}})
    assert state == "no-shared-parameters"
    assert "cipher suite" in detail


def test_no_handshake_failures_is_clean():
    assert verdict({"codes": {11200: 4}})[0] == "clean"
    assert verdict({})[0] == "clean"
twilio-tls-handshake-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { codeOf, listener, sweep, verdict } from './twilio-tls-handshake-audit.mjs';

const alert = (sid, url, code = '11220') => ({
  sid, request_url: url, error_code: code, date_generated: '2026-05-05T14:08:00Z',
});

test('listener always writes the port out', () => {
  assert.equal(listener('https://hooks.example.com/voice'), 'hooks.example.com:443');
  assert.equal(listener('https://Hooks.Example.com:8443/voice'), 'hooks.example.com:8443');
  assert.equal(listener('http://hooks.example.com/voice'), 'hooks.example.com:80');
  assert.equal(listener('not a url'), '');
  assert.equal(listener(null), '');
});

test('codeOf reads the string the Monitor API returns', () => {
  assert.equal(codeOf({ error_code: '11220' }), 11220);
  assert.equal(codeOf({ error_code: 11220 }), 11220);
  assert.equal(codeOf({ error_code: '' }), null);
  assert.equal(codeOf({}), null);
});

test('sweep drops listeners with no handshake failure', () => {
  const rows = sweep([
    alert('A1', 'https://a.example.com/voice'),
    alert('A2', 'https://b.example.com/voice', '11200'),
    alert('A3', 'https://a.example.com:8443/voice'),
  ]);
  assert.deepEqual([...rows.keys()].sort(), ['a.example.com:443', 'a.example.com:8443']);
});

test('two ports on one host are two listeners', () => {
  const rows = sweep([
    alert('A1', 'https://a.example.com/voice'),
    alert('A2', 'https://a.example.com:8443/voice'),
  ]);
  assert.equal(rows.get('a.example.com:443').codes[11220], 1);
  assert.equal(rows.get('a.example.com:8443').codes[11220], 1);
});

test('a certificate code beside it means the handshake got further', () => {
  const [state, detail] = verdict({ codes: { 11220: 40, 11236: 12 } });
  assert.equal(state, 'certificate-first');
  assert.match(detail, /11236/);
});

test('a code that needed a response means one stale node', () => {
  const [state, detail] = verdict({ codes: { 11220: 9, 11200: 300 } });
  assert.equal(state, 'one-node');
  assert.match(detail, /balancer/);
});

test('only 11220 is the plain protocol mismatch', () => {
  const [state, detail] = verdict({ codes: { 11220: 512 } });
  assert.equal(state, 'no-shared-parameters');
  assert.match(detail, /cipher suite/);
});

test('no handshake failures is clean', () => {
  assert.equal(verdict({ codes: { 11200: 4 } })[0], 'clean');
  assert.equal(verdict({})[0], 'clean');
});

FAQ

The URL loads in my browser. How can the handshake be failing?

Because a browser and Twilio's HTTP client offer different sets of protocol versions and cipher suites. The handshake succeeds when the two sides share at least one; a browser carries a deliberately generous compatibility list, so it can find an intersection where a stricter client finds none. Your browser is evidence about your browser.

Is 11220 the same thing as an expired or untrusted certificate?

No, and the distinction saves real time. A certificate is sent only after version and cipher are agreed, so 11235, 11236 and 11237 all imply the handshake got that far. An 11220 means it ended before the server ever identified itself, which is why the certificate on the host is often perfectly fine.

Why key the report on host and port rather than hostname?

Because the protocol configuration belongs to whatever is listening on a port. One hostname can front 443 and 8443 with two different terminators and two different cipher lists, and merging them yields a verdict about an endpoint that does not exist.

I get some 11220s and plenty of successful webhooks on the same host. What does that mean?

That the endpoint negotiates with Twilio perfectly well most of the time, so it is not offering an unacceptable protocol set. Look for one machine: a pool member restored from an older image, a terminator upgraded everywhere except one region, or a canary running different software.

Can I ask Twilio to accept the older TLS version?

No. There is no per-account setting for the protocol versions or cipher suites Twilio's client offers, and Twilio has been retiring TLS 1.0 and 1.1 across its interfaces. The entire repair is on the terminating server or load balancer.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.