Skip to content

Diagnostic Twilio

error 11206: Twilio cannot parse your webhook's HTTP response

Your access log shows 200 for the request. Your framework's own instrumentation shows the handler completed in nine milliseconds and returned valid TwiML. And Twilio recorded 11206 HTTP protocol violation for that exact request, which is not a complaint about your status code or your XML — it is Twilio saying that the bytes coming back were not a well-formed HTTP response at all.

Read-only key Python and Node.js Tests included
A cardboard box
Photo by Kadarius Seegars on Unsplash
The short answer

Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD for error_code 11206 to find the failing endpoints. That is as far as the list gets you: response_headers and response_body are populated only by the single-alert fetch GET https://monitor.twilio.com/v1/Alerts/{Sid} and are absent from every row of the list.

So fetch a sample individually and read the header block. A Set-Cookie with an empty name, or one carrying raw control characters in its value, is the commonest cause. An alert with no header block at all usually means plain HTTP was served on a port the URL called https, so the first bytes were never a status line.

The problem in plain words

11206 sits at a layer almost nothing else in your stack inspects. Your framework wrote a status line and a header block, your web server framed them, and somewhere between those two the result stopped being parseable. Because the response left your process looking correct, every log you own reports success, and the only party that saw the malformed bytes is the client — which is Twilio, and which wrote it down in a system you have to go and read on purpose.

The commonest source is a cookie, which is why it is so hard to guess. A session library that writes a cookie whose value came from user input, an A/B framework that stores a raw string, a middleware that sets a cookie with an empty name during an error path: all of them produce a header that your own server accepts and emits happily, and that a strict parser refuses. It is also intermittent by nature, because it depends on the value, so it looks like flakiness rather than a defect.

Handler runsreturns validTwiMLCookie is setraw value, noencodingServer emits itno complaint atallClient parsefailslogged 11206Your log says200nothing to grepfor
The handler succeeded and the framework wrote a header block. What left the socket was not a well formed HTTP response.

Why it happens

The evidence is not in the list, and there is no way to filter for it. Every row of GET /v1/Alerts carries the code, the URL and the timestamp, and none of them carries response_headers. Seeing what actually came back costs one GET /v1/Alerts/{Sid} per alert, which is why this script samples rather than fetching everything: a busy account can log thousands of 11206s and you need five.

11206 is not 11200, and the difference is where to look. An 11200 means Twilio read your response and disliked the status. An 11206 means it could not read the response. If you go looking for a bad status code in your handler you will find a good one, conclude Twilio is wrong, and stop.

A cookie value is a header value, and header values have rules. Raw control characters — a stray newline, a tab, a \r from a Windows-authored string — are not permitted in a header value, and a newline in particular is how header injection works, so strict clients refuse rather than repair. A cookie with an empty name is malformed for a simpler reason: there is nothing to name the value.

Everything downstream of the parse is a different error code. If the HTTP parses and the Content-Type is wrong, that is 12300. If the Content-Type is right and the XML is not well-formed, that is 12100. If the TwiML is fine but enormous, that is 11750. Reaching 11206 means the failure happened before any of those checks could run.

The fix, as a flow

The script reads the list to find the endpoints and then fetches a small sample of alerts one at a time, because response_headers is populated only by GET /v1/Alerts/{Sid} and appears on no row of the list.

One alert fetched on its ownGET /v1/Alerts/{Sid}Headers cleanlook at the body framingNever fetchedthe list cannot answer thisSet-Cookie with no nameor control charactersNo header blockplain HTTP on an https port
A row from the list has no response_headers at all. Reading that absence as an empty header block misreports every alert on the account.

How to fix it

Find the failing endpoints in the list

GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url, keeping error_code 11206. Read the code as an integer; the API returns a string. Group by the host and path in request_url so you know which handler to open.

Budget the second fetch before you start it

response_headers exists only on GET /v1/Alerts/{Sid}, one request per alert. Take a small sample per endpoint rather than the whole set: the cause repeats, and a few alerts from one handler show the same malformed header the thousandth would.

Read the Set-Cookie values, character by character

Look for two things. A value with any character below 0x20 or equal to 0x7f in it — a newline, a carriage return, a tab — and a cookie whose name is empty, which is what =value means. Both are emitted happily by most servers and refused by strict clients.

Treat an empty header block as a scheme mismatch

If the fetched alert has a response_headers field and it is empty, the parse failed before a header block existed. The usual cause is a listener answering plain HTTP on a port the configured URL declares as https, so the first bytes back are not a status line at all.

Fix the emitter, not the response

Strip control characters from cookie values at the point they are set and drop cookies with no name; if the value is user-controlled, encode it. Where the header block was empty, make the scheme in the configured URL match what the port actually speaks. Then re-run over a fresh window.

How to check it worked

Re-run over a window starting after the deploy. The endpoint should log no 11206 at all, rather than fewer.

python3 twilio_webhook_protocol_audit.py --days 1 --sample 5
# 0 endpoint(s) returning an unparseable HTTP response

The full code

Two stages, deliberately: one paginated sweep of the alerts list to find the endpoints, then one fetch per sampled alert to see what came back, because response_headers exists nowhere else. The pure parts are the header normaliser, the cookie fault test and the verdict — including the state for a list row, which encodes the trap that this whole note is about.

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_webhook_protocol_audit.py
"""Report webhook endpoints returning HTTP that Twilio cannot parse (11206).

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

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

PROTOCOL_VIOLATION = 11206

# Alerts are retained 30 days.
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, and comparing it raw against
    11206 finds nothing at all.
    """
    raw = alert.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def endpoint(url):
    """Host and path from a request URL, dropping the query string.

    Twilio appends its own parameters to the URL it fetches, so the query string
    differs on every alert and grouping on the whole URL would file each one
    under its own heading.
    """
    if not url:
        return ""
    parts = urlsplit(str(url).strip())
    host = (parts.hostname or "").lower()
    if not host:
        return ""
    return host + (parts.path or "/")


def header_lines(response_headers):
    """Normalise a fetched alert's response_headers into "Name: value" lines.

    The field's shape is not worth betting on: it arrives as a newline-joined
    string on some alerts and as a mapping on others, and a mapping can hold a
    list where a header repeats. Accepting all three costs six lines and saves a
    parser that silently returns nothing.
    """
    h = response_headers
    if not h:
        return []
    if isinstance(h, str):
        return [ln.strip() for ln in h.replace("\r\n", "\n").split("\n") if ln.strip()]
    if isinstance(h, dict):
        out = []
        for name, value in h.items():
            values = value if isinstance(value, (list, tuple)) else [value]
            out.extend("%s: %s" % (name, v) for v in values)
        return out
    if isinstance(h, (list, tuple)):
        return [str(x) for x in h if str(x).strip()]
    return []


def header_values(lines, name):
    """Every value for one header name, matched case-insensitively. Pure."""
    want = name.lower()
    out = []
    for line in lines:
        head, sep, rest = line.partition(":")
        if sep and head.strip().lower() == want:
            out.append(rest.strip())
    return out


def cookie_faults(value):
    """What is wrong with one Set-Cookie value. Pure, returns a sorted list.

    Both faults are emitted happily by most servers and refused by strict
    clients, which is the whole reason this failure reads as flakiness: it
    depends on the value, not on the code path.
    """
    faults = []
    raw = "" if value is None else str(value)
    if any(ord(c) < 0x20 or ord(c) == 0x7f for c in raw):
        faults.append("control-characters")
    pair = raw.split(";", 1)[0]
    if "=" not in pair or not pair.split("=", 1)[0].strip():
        faults.append("nameless")
    return sorted(faults)


def verdict(alert):
    """Classify one alert. Pure, so the two-stage read is testable offline.

    The first branch is the point of the note. A row from the alerts list has no
    response_headers key at all, and treating that absence as an empty header
    block would report every alert in the account as a scheme mismatch.

    Returns (state, detail).
    """
    if code_of(alert) != PROTOCOL_VIOLATION:
        return ("not-11206", "this alert is not an HTTP protocol violation")

    if "response_headers" not in alert:
        return ("unfetched",
                "this is a row from the alerts list. response_headers is "
                "populated only by GET /v1/Alerts/{Sid}, so nothing can be "
                "concluded until the alert is fetched on its own.")

    lines = header_lines(alert.get("response_headers"))
    cookies = header_values(lines, "set-cookie")
    broken = [(c, cookie_faults(c)) for c in cookies]
    broken = [(c, f) for c, f in broken if f]
    if broken:
        names = sorted({f for _c, faults in broken for f in faults})
        return ("malformed-cookie",
                "%d Set-Cookie value(s) a strict parser will refuse (%s). Most "
                "servers emit these without complaint, which is why your own "
                "logs show a clean 200." % (len(broken), ", ".join(names)))

    if not lines:
        return ("no-header-block",
                "the fetched alert carries no response headers, so the parse "
                "failed before a header block existed. The usual cause is a "
                "listener answering plain HTTP on a port the configured URL "
                "calls https.")

    return ("headers-parse",
            "%d header(s) read cleanly, so the violation is in the framing of "
            "the response itself: a truncated body, a Content-Length that does "
            "not match, or a chunked encoding that ended early." % len(lines))


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 fetch_alert(session, sid):
    """One alert on its own. The only place response_headers exists."""
    return get(session, "%s/Alerts/%s" % (MONITOR, sid))


def group(alerts):
    """Bucket the 11206s by endpoint, keeping the sids to sample from. Pure."""
    out = {}
    for a in alerts:
        if code_of(a) != PROTOCOL_VIOLATION:
            continue
        key = endpoint(a.get("request_url"))
        row = out.setdefault(key, {"alerts": 0, "sids": [], "url": ""})
        row["alerts"] += 1
        row["sids"].append(a.get("sid"))
        row["url"] = row["url"] or (a.get("request_url") or "")
    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")
    ap.add_argument("--sample", type=int, default=3,
                    help="alerts to fetch individually per endpoint; each one is "
                         "a request, and response_headers exists nowhere else")
    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 = group(alerts)
    if not rows:
        log.info("no 11206 since %s across %d alert(s)", since, len(alerts))
        return 0

    log.info("%d endpoint(s) with 11206; fetching up to %d alert(s) each",
             len(rows), args.sample)

    bad = 0
    for key, row in sorted(rows.items()):
        bad += 1
        log.warning("%-17s %s  %d x 11206", "protocol-violation", key,
                    row["alerts"])
        log.warning("  sample %s", row["url"] or "(none)")
        for sid in row["sids"][:args.sample]:
            detailed = fetch_alert(session, sid)
            state, detail = verdict(detailed)
            log.warning("  %s %s  %s", sid, state, detail)
        log.warning("  repair: strip characters below 0x20 from cookie values "
                    "where they are set, drop cookies with an empty name, and "
                    "make the scheme in the configured URL match what the port "
                    "actually speaks.")

    log.info("%d endpoint(s) returning an unparseable HTTP response", bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-webhook-protocol-audit.mjs
/**
 * Report webhook endpoints returning HTTP that Twilio cannot parse (11206).
 *
 * 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 PROTOCOL_VIOLATION = 11206;

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

/** 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;
}

/**
 * Host and path from a request URL, dropping the query string. Twilio appends
 * its own parameters, so grouping on the whole URL files every alert separately.
 */
export function endpoint(url) {
  if (!url) return '';
  let u;
  try {
    u = new URL(String(url).trim());
  } catch {
    return '';
  }
  const host = u.hostname.toLowerCase();
  if (!host) return '';
  return host + (u.pathname || '/');
}

/**
 * Normalise a fetched alert's response_headers into "Name: value" lines. The
 * field arrives as a string on some alerts and as a mapping on others, and a
 * mapping can hold a list where a header repeats.
 */
export function headerLines(responseHeaders) {
  const h = responseHeaders;
  if (!h) return [];
  if (typeof h === 'string') {
    return h.replace(/\r\n/g, '\n').split('\n')
      .map((ln) => ln.trim()).filter(Boolean);
  }
  if (Array.isArray(h)) return h.map(String).filter((x) => x.trim());
  if (typeof h === 'object') {
    const out = [];
    for (const [name, value] of Object.entries(h)) {
      for (const v of (Array.isArray(value) ? value : [value])) out.push(`${name}: ${v}`);
    }
    return out;
  }
  return [];
}

/** Every value for one header name, matched case-insensitively. Pure. */
export function headerValues(lines, name) {
  const want = name.toLowerCase();
  const out = [];
  for (const line of lines) {
    const i = line.indexOf(':');
    if (i > -1 && line.slice(0, i).trim().toLowerCase() === want) {
      out.push(line.slice(i + 1).trim());
    }
  }
  return out;
}

/**
 * What is wrong with one Set-Cookie value. Pure, returns a sorted list. Both
 * faults are emitted happily by most servers and refused by strict clients.
 */
export function cookieFaults(value) {
  const faults = [];
  const raw = value === null || value === undefined ? '' : String(value);
  if ([...raw].some((c) => c.charCodeAt(0) < 0x20 || c.charCodeAt(0) === 0x7f)) {
    faults.push('control-characters');
  }
  const pair = raw.split(';')[0];
  if (!pair.includes('=') || !pair.split('=')[0].trim()) faults.push('nameless');
  return faults.sort();
}

/**
 * Classify one alert. Pure, so the two-stage read is testable offline. The
 * first branch is the point: a row from the list has no response_headers key,
 * and treating that absence as an empty header block would misreport everything.
 * Returns [state, detail].
 */
export function verdict(alert) {
  if (codeOf(alert) !== PROTOCOL_VIOLATION) {
    return ['not-11206', 'this alert is not an HTTP protocol violation'];
  }

  if (!Object.prototype.hasOwnProperty.call(alert, 'response_headers')) {
    return ['unfetched',
      'this is a row from the alerts list. response_headers is populated only ' +
      'by GET /v1/Alerts/{Sid}, so nothing can be concluded until the alert ' +
      'is fetched on its own.'];
  }

  const lines = headerLines(alert.response_headers);
  const broken = headerValues(lines, 'set-cookie')
    .map((c) => [c, cookieFaults(c)]).filter(([, f]) => f.length);
  if (broken.length) {
    const names = [...new Set(broken.flatMap(([, f]) => f))].sort();
    return ['malformed-cookie',
      `${broken.length} Set-Cookie value(s) a strict parser will refuse ` +
      `(${names.join(', ')}). Most servers emit these without complaint, ` +
      'which is why your own logs show a clean 200.'];
  }

  if (lines.length === 0) {
    return ['no-header-block',
      'the fetched alert carries no response headers, so the parse failed ' +
      'before a header block existed. The usual cause is a listener answering ' +
      'plain HTTP on a port the configured URL calls https.'];
  }

  return ['headers-parse',
    `${lines.length} header(s) read cleanly, so the violation is in the ` +
    'framing of the response itself: a truncated body, a Content-Length that ' +
    'does not match, or a chunked encoding that ended early.'];
}

/** Bucket the 11206s by endpoint, keeping the sids to sample from. Pure. */
export function group(alerts) {
  const out = new Map();
  for (const a of alerts) {
    if (codeOf(a) !== PROTOCOL_VIOLATION) continue;
    const key = endpoint(a.request_url);
    if (!out.has(key)) out.set(key, { alerts: 0, sids: [], url: '' });
    const row = out.get(key);
    row.alerts += 1;
    row.sids.push(a.sid);
    row.url = row.url || (a.request_url ?? '');
  }
  return out;
}

function authHeader(key, secret) {
  return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}

async function get(auth, url, params = {}) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  const res = await fetch(u, { headers: { Authorization: auth } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
                    'that the API key belongs to that account with read access');
  }
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return res.json();
}

export async function listAlerts(auth, since, limit = 10000, logLevel = 'error') {
  let url = `${MONITOR}/Alerts`;
  let params = { LogLevel: logLevel, StartDate: since, PageSize: 1000 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.alerts ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, limit);
}

async function 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 dayArg = process.argv.indexOf('--days');
  let days = dayArg > -1 ? Number(process.argv[dayArg + 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 sampleArg = process.argv.indexOf('--sample');
  const sample = sampleArg > -1 ? Number(process.argv[sampleArg + 1]) || 3 : 3;

  const since = new Date(Date.now() - days * 86400_000).toISOString().slice(0, 10);

  const alerts = await listAlerts(auth, since);
  const rows = group(alerts);
  if (rows.size === 0) {
    console.log(`no 11206 since ${since} across ${alerts.length} alert(s)`);
    return;
  }

  console.log(`${rows.size} endpoint(s) with 11206; fetching up to ${sample} each`);

  let bad = 0;
  for (const key of [...rows.keys()].sort()) {
    const row = rows.get(key);
    bad += 1;
    console.warn(`${'protocol-violation'.padEnd(17)} ${key}  ${row.alerts} x 11206`);
    console.warn(`  sample ${row.url || '(none)'}`);
    for (const sid of row.sids.slice(0, sample)) {
      const detailed = await get(auth, `${MONITOR}/Alerts/${sid}`);
      const [state, detail] = verdict(detailed);
      console.warn(`  ${sid} ${state}  ${detail}`);
    }
    console.warn('  repair: strip characters below 0x20 from cookie values ' +
      'where they are set, drop cookies with an empty name, and make the ' +
      'scheme in the configured URL match what the port actually speaks.');
  }

  console.log(`${bad} endpoint(s) returning an unparseable HTTP response`);
  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 first case is the one that matters most: an alert straight out of the list has no response_headers key, and a classifier that reads that absence as an empty header block will confidently report every 11206 on the account as a scheme mismatch. The rest pin the two cookie faults, including a value that looks fine until you count the characters, and the case where a clean header block moves the diagnosis into the body framing.

test_twilio_webhook_protocol_audit.py
from twilio_webhook_protocol_audit import (
    cookie_faults, endpoint, group, header_lines, header_values, verdict)


def listed(sid="NO1", url="https://hooks.example.com/voice?AccountSid=AC1"):
    """A row exactly as the alerts list returns it: no response_headers key."""
    return {"sid": sid, "request_url": url, "error_code": "11206",
            "date_generated": "2026-05-05T14:08:00Z"}


def fetched(headers):
    d = listed()
    d["response_headers"] = headers
    return d


def test_a_list_row_is_reported_as_unfetched_not_as_an_empty_header_block():
    # The whole trap of this note. response_headers exists only on the
    # single-alert fetch, and absence is not emptiness.
    state, detail = verdict(listed())
    assert state == "unfetched"
    assert "/v1/Alerts/" in detail


def test_endpoint_drops_the_query_twilio_appends():
    assert endpoint("https://Hooks.Example.com/voice?AccountSid=AC1") == \
        "hooks.example.com/voice"
    assert endpoint("https://hooks.example.com") == "hooks.example.com/"
    assert endpoint("") == ""


def test_header_lines_accepts_a_string_a_mapping_and_a_repeat():
    assert header_lines("Content-Type: text/xml\r\nServer: nginx") == \
        ["Content-Type: text/xml", "Server: nginx"]
    assert header_lines({"Set-Cookie": ["a=1", "b=2"]}) == \
        ["Set-Cookie: a=1", "Set-Cookie: b=2"]
    assert header_lines(None) == []


def test_header_values_matches_case_insensitively():
    lines = ["set-cookie: a=1", "Set-Cookie: b=2", "Server: nginx"]
    assert header_values(lines, "Set-Cookie") == ["a=1", "b=2"]


def test_cookie_faults_finds_a_control_character_and_an_empty_name():
    assert cookie_faults("sid=abc123; Path=/") == []
    assert cookie_faults("sid=ab\ncd; Path=/") == ["control-characters"]
    assert cookie_faults("=abc123; Path=/") == ["nameless"]
    assert cookie_faults("=ab\tcd") == ["control-characters", "nameless"]


def test_a_malformed_cookie_is_named_in_the_verdict():
    state, detail = verdict(fetched({"Set-Cookie": ["ok=1", "=orphan"]}))
    assert state == "malformed-cookie"
    assert "nameless" in detail


def test_an_empty_header_block_on_a_fetched_alert_is_a_scheme_mismatch():
    state, detail = verdict(fetched(""))
    assert state == "no-header-block"
    assert "plain HTTP" in detail


def test_clean_headers_move_the_diagnosis_into_the_body_framing():
    state, detail = verdict(fetched("Content-Type: text/xml\nSet-Cookie: sid=1"))
    assert state == "headers-parse"
    assert "Content-Length" in detail


def test_another_error_code_is_not_this_failure():
    other = fetched("Content-Type: text/xml")
    other["error_code"] = "11200"
    assert verdict(other)[0] == "not-11206"


def test_group_buckets_by_endpoint_and_keeps_the_sids():
    rows = group([listed("A1"), listed("A2"),
                  listed("A3", "https://hooks.example.com/sms?x=1")])
    assert sorted(rows) == ["hooks.example.com/sms", "hooks.example.com/voice"]
    assert rows["hooks.example.com/voice"]["sids"] == ["A1", "A2"]
twilio-webhook-protocol-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  cookieFaults, endpoint, group, headerLines, headerValues, verdict,
} from './twilio-webhook-protocol-audit.mjs';

// A row exactly as the alerts list returns it: no response_headers key.
const listed = (sid = 'NO1', url = 'https://hooks.example.com/voice?AccountSid=AC1') => ({
  sid, request_url: url, error_code: '11206', date_generated: '2026-05-05T14:08:00Z',
});

const fetched = (headers) => ({ ...listed(), response_headers: headers });

test('a list row is reported as unfetched not as an empty header block', () => {
  const [state, detail] = verdict(listed());
  assert.equal(state, 'unfetched');
  assert.match(detail, /\/v1\/Alerts\//);
});

test('endpoint drops the query Twilio appends', () => {
  assert.equal(endpoint('https://Hooks.Example.com/voice?AccountSid=AC1'),
    'hooks.example.com/voice');
  assert.equal(endpoint('https://hooks.example.com'), 'hooks.example.com/');
  assert.equal(endpoint(''), '');
});

test('headerLines accepts a string, a mapping and a repeat', () => {
  assert.deepEqual(headerLines('Content-Type: text/xml\r\nServer: nginx'),
    ['Content-Type: text/xml', 'Server: nginx']);
  assert.deepEqual(headerLines({ 'Set-Cookie': ['a=1', 'b=2'] }),
    ['Set-Cookie: a=1', 'Set-Cookie: b=2']);
  assert.deepEqual(headerLines(null), []);
});

test('headerValues matches case insensitively', () => {
  const lines = ['set-cookie: a=1', 'Set-Cookie: b=2', 'Server: nginx'];
  assert.deepEqual(headerValues(lines, 'Set-Cookie'), ['a=1', 'b=2']);
});

test('cookieFaults finds a control character and an empty name', () => {
  assert.deepEqual(cookieFaults('sid=abc123; Path=/'), []);
  assert.deepEqual(cookieFaults('sid=ab\ncd; Path=/'), ['control-characters']);
  assert.deepEqual(cookieFaults('=abc123; Path=/'), ['nameless']);
  assert.deepEqual(cookieFaults('=ab\tcd'), ['control-characters', 'nameless']);
});

test('a malformed cookie is named in the verdict', () => {
  const [state, detail] = verdict(fetched({ 'Set-Cookie': ['ok=1', '=orphan'] }));
  assert.equal(state, 'malformed-cookie');
  assert.match(detail, /nameless/);
});

test('an empty header block on a fetched alert is a scheme mismatch', () => {
  const [state, detail] = verdict(fetched(''));
  assert.equal(state, 'no-header-block');
  assert.match(detail, /plain HTTP/);
});

test('clean headers move the diagnosis into the body framing', () => {
  const [state, detail] = verdict(fetched('Content-Type: text/xml\nSet-Cookie: sid=1'));
  assert.equal(state, 'headers-parse');
  assert.match(detail, /Content-Length/);
});

test('another error code is not this failure', () => {
  const other = fetched('Content-Type: text/xml');
  other.error_code = '11200';
  assert.equal(verdict(other)[0], 'not-11206');
});

test('group buckets by endpoint and keeps the sids', () => {
  const rows = group([listed('A1'), listed('A2'),
    listed('A3', 'https://hooks.example.com/sms?x=1')]);
  assert.deepEqual([...rows.keys()].sort(),
    ['hooks.example.com/sms', 'hooks.example.com/voice']);
  assert.deepEqual(rows.get('hooks.example.com/voice').sids, ['A1', 'A2']);
});

FAQ

My server logged a 200. How can this be a protocol violation?

Because 11206 is not about the status code. Your framework wrote a status line and headers, your web server framed them, and the result was not parseable by a strict HTTP client. Everything inside your process succeeded, which is exactly why no log of yours records the failure.

Why does the script fetch alerts one at a time?

Because response_headers and response_body appear only on GET /v1/Alerts/{Sid}. Every row of the list carries the code, the URL and the timestamp and nothing about what came back, so seeing the malformed header costs one request per alert. That is why it samples instead of fetching thousands.

What is actually wrong with the cookie?

One of two things. Its value contains a raw control character - a newline, a carriage return, a tab - which is not permitted in a header value and is how header injection works, so strict clients refuse rather than repair it. Or the cookie has no name at all, which is what a Set-Cookie beginning with an equals sign means.

How is this different from 12300 or 12100?

Those are later checks. 12300 means the HTTP parsed and the Content-Type was wrong for TwiML; 12100 means the Content-Type was right and the XML was not well-formed. An 11206 means the failure happened before either check could run, at the HTTP layer itself.

The alert has no response headers at all. What does that mean?

That Twilio abandoned the parse before a header block existed, which usually means the first bytes back were not a status line. The classic cause is a listener speaking plain HTTP on a port the configured URL declares as https, so make the scheme match what the port actually serves.

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.