Diagnostic Twilio
a TwiML response over 64 kB drops the call with 11750
11750 TwiML response body too large reads like a capacity problem, so people go looking for the enormous document they must have generated. Usually there isn't one. The handler threw, the framework returned its debug page, and a stack trace with syntax highlighting and every local variable inlined sails past 64 kB without difficulty.
Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD for error_code == 11750, group by endpoint, then fetch a sample with GET https://monitor.twilio.com/v1/Alerts/{Sid} and look at response_body. The question that decides the repair is whether the body is TwiML at all.
An HTML page or a stack trace means the application is failing and the size is a symptom — turn off debug pages in production so a failure returns a short 500. Genuine TwiML over the limit means the document needs splitting across <Redirect> hops. Note that response_body is itself stored with a size limit, so its length is a floor, not a measurement.
The problem in plain words
The error code sends you the wrong way. It names a size, so the search starts with the biggest document the app can produce — the conference with three hundred participants, the <Say> loop over a long list — and often that document turns out to be 6 kB and entirely innocent. Meanwhile the real cause is an exception, and it is only visible if you look at what came back rather than at how much of it there was.
The reason a debug page gets so big is that it is designed to. Source context around every frame, all the locals, the request environment, the loaded modules, and CSS to render it: that is comfortably tens of kilobytes on a framework's default settings. So a handler that throws in production with debug output still on does not return a small error, it returns an essay, and Twilio refuses the essay for its size rather than its content.
Either way the caller is gone. 11750 fires after the webhook, and the call drops immediately.
Why it happens
64 kB is the cap, in bytes. Not characters. A document full of non-ASCII text in <Say> is meaningfully larger on the wire than its length suggests, and any check that measures with a character count will read a failing document as comfortably under the limit.
A debug page is enormous by design. Framework error pages inline source context, local variables and styling. Leaving that enabled in production converts every unhandled exception into a response that is both wrong and oversized, which is why the honest repair is two changes: fix the exception, and stop returning debug pages.
The stored body is truncated, so its length proves nothing. response_body on an alert is kept with a size limit. Measuring it and finding 20 kB does not mean the response was 20 kB. That is why the classification keys on what the body is — HTML, a traceback, real TwiML — and treats the byte count as a floor.
Status callbacks do not need a document at all. A handler that returns a full page to a status callback is doing needless work at every event. An empty <Response/> is the correct answer there, and it is one of the easiest ways to remove a whole class of size failures.
The fix, as a flow
The script judges what the body is rather than how long it is, because the stored copy of response_body is truncated and its length is a floor rather than a measurement.
How to fix it
Sweep for 11750 and group by endpoint
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url, keeping error_code == 11750. Read it as an integer; the Monitor API returns error codes as strings. One failing route is one line in the report regardless of how many calls hit it.
Fetch a sample and read what came back
GET https://monitor.twilio.com/v1/Alerts/{Sid} is the only place response_body exists. One alert per endpoint answers the question that matters: is this a document, or is it an accident? A <!DOCTYPE html> or a traceback is an accident.
Measure in bytes and treat the number as a floor
The limit is 64 kB of bytes, so encode before you count — a character count under-reports any document with accented or non-Latin text in it. And because the stored body is truncated, a measurement below the limit is not evidence the response was: it only ever proves the response was at least that big.
Split the verdict by cause, because the repairs differ
An HTML page or a stack trace is an application failure: fix the exception and disable debug output in production. Real TwiML over the cap is a design problem: split it across <Redirect> hops so each response is small. A status callback returning anything substantial should return an empty <Response/> instead.
Re-run over a window that starts after the deploy
Alerts are retained 30 days, so a window inherited from the previous run will keep showing failures that are already fixed. Start the window after the deploy. A count that drops without reaching zero means one branch of the handler still returns the old page.
How to check it worked
Re-run over a window beginning after the deploy. Zero, not fewer — a smaller number usually means one code path was missed.
python3 twilio_twiml_size_audit.py --days 1
# 0 endpoint(s) exceeding the 64 kB TwiML limit
The full code
Two pure functions and one honest limitation. byte_length() encodes before counting, because the cap is bytes. classify_body() decides what the response was rather than how long it was, and reports the length as a floor, because the copy the Alerts API stores is truncated. Everything else is the usual sweep plus a capped number of single-alert fetches, since that fetch is the only place the body lives.
"""Report Twilio webhooks whose response exceeds the 64 kB TwiML limit (11750).
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 send messages 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_twiml_size_audit")
MONITOR = "https://monitor.twilio.com/v1"
BODY_TOO_LARGE = 11750
# The cap Twilio applies to a TwiML response, in bytes.
LIMIT = 64 * 1024
# Alerts are retained 30 days. A longer window is not more history, it is the
# same history under a label that makes the report look more thorough.
MAX_DAYS = 30
# Markers of a framework error page: the far commoner cause of 11750 than a
# genuinely large document.
TRACE_MARKERS = (
"traceback (most recent call last)",
"stack trace",
"stacktrace",
"whoops! there was an error",
"werkzeug debugger",
"actiondispatch",
)
def code_of(alert):
"""Read error_code off an alert as an integer, or None.
The Monitor API hands this back as a string while the Messages list hands
back a number for the same concept, and a check written for one and pointed
at the other 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 endpoint_of(url):
"""Lowercase host plus path, for grouping. Query string dropped."""
if not url:
return ""
parts = urlsplit(str(url).strip())
host = (parts.hostname or "").lower()
if not host:
return str(url).strip().lower().rstrip("/")
return host + (parts.path or "").rstrip("/")
def byte_length(text):
"""Size in bytes, which is the unit the limit is expressed in.
len() on a string counts characters. A document with accented or non-Latin
text in <Say> is larger on the wire than its length, so a character count
reads a failing response as comfortably inside the cap.
"""
return len(str(text or "").encode("utf-8"))
def classify_body(body):
"""Say what the oversized response actually was. Pure.
The verdict keys on what the body is rather than on how long it is, because
response_body is stored with a size limit of its own: measuring it gives a
floor, never the size of the response Twilio refused.
Returns (state, detail).
"""
raw = "" if body is None else str(body)
size = byte_length(raw)
if not raw.strip():
return ("no-body",
"the single-alert fetch returned nothing, so the cause cannot "
"be read from here. Reproduce the request against the handler "
"and measure what it writes.")
low = raw.lstrip().lower()
if (low.startswith("<!doctype html") or low.startswith("<html")
or "<html" in low[:2000]):
return ("error-page",
"an HTML page, not TwiML: at least %d bytes of framework debug "
"output. The size is a symptom; the handler threw." % size)
if any(m in low for m in TRACE_MARKERS):
return ("stack-trace",
"a stack trace, at least %d bytes of it. Debug output is still "
"on in production and every unhandled exception returns an "
"essay." % size)
if "<response" in low:
if size >= LIMIT:
return ("oversized-twiml",
"real TwiML, %d bytes, over the %d byte cap. This one needs "
"splitting rather than fixing." % (size, LIMIT))
return ("twiml-truncated",
"real TwiML. The stored copy is %d bytes, under the cap, but "
"response_body is truncated: that is a floor, not the size of "
"the response." % size)
return ("not-twiml",
"at least %d bytes of something that is neither TwiML nor a "
"recognisable error page. Read the first line of it." % size)
def group(alerts, code=BODY_TOO_LARGE):
"""Bucket alerts with one error code by endpoint. Pure."""
out = {}
for a in alerts:
if code_of(a) != code:
continue
key = endpoint_of(a.get("request_url"))
row = out.setdefault(key, {"alerts": 0, "sids": [], "first": None,
"last": None})
row["alerts"] += 1
if len(row["sids"]) < 3:
row["sids"].append(a.get("sid"))
when = a.get("date_generated") or ""
if when:
row["first"] = when if row["first"] is None else min(row["first"], when)
row["last"] = when if row["last"] is None else max(row["last"], when)
return out
REPAIRS = {
"error-page": "fix the exception, and turn debug pages off in production so "
"a failure returns a short 500 rather than a rendered page",
"stack-trace": "disable debug output in production and return a small TwiML "
"document from the error branch",
"oversized-twiml": "split the flow across <Redirect> hops so each response "
"is small, and return an empty <Response/> to status "
"callbacks",
"twiml-truncated": "the stored copy is truncated: generate the same document "
"locally and measure it in bytes",
"not-twiml": "read the first line of the body and find what writes it",
"no-body": "reproduce the request against the handler and measure what it "
"writes",
}
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 by SID: the only place response_body is populated."""
return get(session, "%s/Alerts/%s" % (MONITOR, sid))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=1,
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=1,
help="alerts to fetch individually per endpoint for the "
"response body (each one is a request)")
args = ap.parse_args()
account = os.environ.get("TWILIO_ACCOUNT_SID")
key = os.environ.get("TWILIO_API_KEY")
secret = os.environ.get("TWILIO_API_SECRET")
if not (account and key and secret):
log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
"(an API Key with read access, not the auth token)")
return 2
days = args.days
if days > MAX_DAYS:
log.warning("alerts are retained %d days; reading %d instead of %d",
MAX_DAYS, MAX_DAYS, days)
days = MAX_DAYS
session = requests.Session()
session.auth = (key, secret)
since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
alerts = list_alerts(session, since, args.max_alerts)
rows = group(alerts)
log.info("%d alert(s) since %s, %d endpoint(s) with 11750", len(alerts),
since, len(rows))
bad = 0
for key, row in sorted(rows.items()):
bad += 1
state, detail = ("no-body", "not sampled")
for sid in row["sids"][:max(1, args.sample)]:
state, detail = classify_body(fetch_alert(session, sid)
.get("response_body"))
if state != "no-body":
break
log.warning("%-16s %s %d x 11750 %s", state, key, row["alerts"], detail)
log.warning(" first %s, last %s", row["first"], row["last"])
log.warning(" repair: %s", REPAIRS.get(state, "read the body by hand"))
log.info("%d endpoint(s) exceeding the %d byte TwiML limit", bad, LIMIT)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio webhooks whose response exceeds the 64 kB TwiML limit (11750).
*
* 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 BODY_TOO_LARGE = 11750;
// The cap Twilio applies to a TwiML response, in bytes.
const LIMIT = 64 * 1024;
// Alerts are retained 30 days. A longer window is the same history mislabelled.
const MAX_DAYS = 30;
// Markers of a framework error page: the far commoner cause of 11750.
const TRACE_MARKERS = [
'traceback (most recent call last)', 'stack trace', 'stacktrace',
'whoops! there was an error', 'werkzeug debugger', 'actiondispatch',
];
/** 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 host plus path, for grouping. Query string dropped. */
export function endpointOf(url) {
if (!url) return '';
const raw = String(url).trim();
try {
const u = new URL(raw);
let path = u.pathname;
while (path.endsWith('/')) path = path.slice(0, -1);
return u.hostname.toLowerCase() + path;
} catch {
return raw.toLowerCase().replace(/\/+$/, '');
}
}
/**
* Size in bytes, which is the unit the limit is expressed in. A character count
* reads a document with non-Latin text in <Say> as smaller than it is.
*/
export function byteLength(text) {
return Buffer.byteLength(String(text ?? ''), 'utf8');
}
/**
* Say what the oversized response actually was. Pure. Keys on what the body is
* rather than how long it is, because response_body is stored truncated and its
* length is a floor. Returns [state, detail].
*/
export function classifyBody(body) {
const raw = body === null || body === undefined ? '' : String(body);
const size = byteLength(raw);
if (!raw.trim()) {
return ['no-body',
'the single-alert fetch returned nothing, so the cause cannot be read ' +
'from here. Reproduce the request against the handler and measure what ' +
'it writes.'];
}
const low = raw.replace(/^\s+/, '').toLowerCase();
if (low.startsWith('<!doctype html') || low.startsWith('<html')
|| low.slice(0, 2000).includes('<html')) {
return ['error-page',
`an HTML page, not TwiML: at least ${size} bytes of framework debug ` +
'output. The size is a symptom; the handler threw.'];
}
if (TRACE_MARKERS.some((m) => low.includes(m))) {
return ['stack-trace',
`a stack trace, at least ${size} bytes of it. Debug output is still on ` +
'in production and every unhandled exception returns an essay.'];
}
if (low.includes('<response')) {
if (size >= LIMIT) {
return ['oversized-twiml',
`real TwiML, ${size} bytes, over the ${LIMIT} byte cap. This one needs ` +
'splitting rather than fixing.'];
}
return ['twiml-truncated',
`real TwiML. The stored copy is ${size} bytes, under the cap, but ` +
'response_body is truncated: that is a floor, not the size of the response.'];
}
return ['not-twiml',
`at least ${size} bytes of something that is neither TwiML nor a ` +
'recognisable error page. Read the first line of it.'];
}
/** Bucket alerts with one error code by endpoint. Pure. */
export function group(alerts, code = BODY_TOO_LARGE) {
const out = new Map();
for (const a of alerts) {
if (codeOf(a) !== code) continue;
const key = endpointOf(a.request_url);
if (!out.has(key)) out.set(key, { alerts: 0, sids: [], first: null, last: null });
const row = out.get(key);
row.alerts += 1;
if (row.sids.length < 3) row.sids.push(a.sid);
const when = a.date_generated ?? '';
if (when) {
row.first = row.first === null || when < row.first ? when : row.first;
row.last = row.last === null || when > row.last ? when : row.last;
}
}
return out;
}
const REPAIRS = {
'error-page': 'fix the exception, and turn debug pages off in production so a ' +
'failure returns a short 500 rather than a rendered page',
'stack-trace': 'disable debug output in production and return a small TwiML ' +
'document from the error branch',
'oversized-twiml': 'split the flow across <Redirect> hops so each response is ' +
'small, and return an empty <Response/> to status callbacks',
'twiml-truncated': 'the stored copy is truncated: generate the same document ' +
'locally and measure it in bytes',
'not-twiml': 'read the first line of the body and find what writes it',
'no-body': 'reproduce the request against the handler and measure what it writes',
};
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);
}
/** One alert by SID: the only place response_body is populated. */
export async function fetchAlert(auth, sid) {
return get(auth, `${MONITOR}/Alerts/${sid}`);
}
async function main() {
const account = process.env.TWILIO_ACCOUNT_SID;
const key = process.env.TWILIO_API_KEY;
const secret = process.env.TWILIO_API_SECRET;
if (!account || !key || !secret) {
console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
'(an API Key with read access, not the auth token)');
process.exitCode = 2;
return;
}
const auth = authHeader(key, secret);
let days = Number(process.argv.includes('--days')
? process.argv[process.argv.indexOf('--days') + 1] : 1) || 1;
if (days > MAX_DAYS) {
console.warn(`alerts are retained ${MAX_DAYS} days; reading ${MAX_DAYS} instead`);
days = MAX_DAYS;
}
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const alerts = await listAlerts(auth, since);
const rows = group(alerts);
console.log(`${alerts.length} alert(s) since ${since}, ${rows.size} endpoint(s) with 11750`);
let bad = 0;
for (const [key, row] of [...rows.entries()].sort()) {
bad += 1;
let state = 'no-body';
let detail = 'not sampled';
for (const sid of row.sids.slice(0, 1)) {
const full = await fetchAlert(auth, sid);
[state, detail] = classifyBody(full.response_body);
}
console.warn(`${state.padEnd(16)} ${key} ${row.alerts} x 11750 ${detail}`);
console.warn(` first ${row.first}, last ${row.last}`);
console.warn(` repair: ${REPAIRS[state] ?? 'read the body by hand'}`);
}
console.log(`${bad} endpoint(s) exceeding the ${LIMIT} byte TwiML limit`);
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 tests hold the two claims this note makes. The limit is in bytes, so byte_length() is pinned against strings whose character count is smaller than their size — the exact case a naive check gets wrong. And a body under the cap must never be reported as fine, because the stored copy is truncated; the test asserts the verdict says floor rather than declaring the endpoint healthy.
from twilio_twiml_size_audit import (byte_length, classify_body, code_of,
endpoint_of, group, LIMIT)
def alert(sid, url, code="11750", when="2026-04-02T10:00:00Z"):
return {"sid": sid, "request_url": url, "error_code": code,
"date_generated": when}
def test_the_limit_is_measured_in_bytes_not_characters():
# The mistake this exists to prevent: len() on a str reads a failing
# document as comfortably inside the cap.
assert byte_length("caf\u00e9") == 5
assert len("caf\u00e9") == 4
assert byte_length("\U0001f600") == 4
assert byte_length(None) == 0
def test_a_framework_debug_page_is_the_usual_cause():
state, detail = classify_body("<!DOCTYPE html><html><body>Server Error</body></html>")
assert state == "error-page"
assert "symptom" in detail
def test_a_stack_trace_is_named_separately_from_a_rendered_page():
state, _ = classify_body("Traceback (most recent call last):\n File ...")
assert state == "stack-trace"
def test_genuine_twiml_over_the_cap_is_a_splitting_problem():
body = "<Response>" + ("<Say>hello</Say>" * 6000) + "</Response>"
assert byte_length(body) > LIMIT
state, detail = classify_body(body)
assert state == "oversized-twiml"
assert "splitting" in detail
def test_twiml_under_the_cap_is_reported_as_a_floor_not_a_clean_bill():
# response_body is stored truncated, so a small stored copy proves nothing
# about the response Twilio actually refused.
state, detail = classify_body("<Response><Say>Hi</Say></Response>")
assert state == "twiml-truncated"
assert "floor" in detail
def test_an_empty_body_is_reported_rather_than_guessed():
assert classify_body("")[0] == "no-body"
assert classify_body(None)[0] == "no-body"
def test_something_that_is_neither_twiml_nor_an_error_page():
state, detail = classify_body('{"error": "too many participants"}')
assert state == "not-twiml"
assert "bytes" in detail
def test_group_keeps_only_11750_and_records_the_ends():
rows = group([alert("NO1", "https://a.example.com/voice?CallSid=CA1",
when="2026-04-02T10:00:00Z"),
alert("NO2", "https://a.example.com/voice/",
when="2026-04-01T09:00:00Z"),
alert("NO3", "https://a.example.com/voice", code="12100")])
assert set(rows) == {"a.example.com/voice"}
assert rows["a.example.com/voice"]["alerts"] == 2
assert rows["a.example.com/voice"]["last"] == "2026-04-02T10:00:00Z"
def test_code_and_endpoint_helpers():
assert code_of({"error_code": "11750"}) == 11750
assert code_of({}) is None
assert endpoint_of("https://A.example.com/voice?x=1") == "a.example.com/voice"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
byteLength, classifyBody, codeOf, endpointOf, group,
} from './twilio-twiml-size-audit.mjs';
const LIMIT = 64 * 1024;
const alert = (sid, url, code = '11750', when = '2026-04-02T10:00:00Z') => ({
sid, request_url: url, error_code: code, date_generated: when,
});
test('the limit is measured in bytes, not characters', () => {
assert.equal(byteLength('caf\u00e9'), 5);
assert.equal('caf\u00e9'.length, 4);
assert.equal(byteLength('\u{1f600}'), 4);
assert.equal(byteLength(null), 0);
});
test('a framework debug page is the usual cause', () => {
const [state, detail] = classifyBody('<!DOCTYPE html><html><body>Server Error</body></html>');
assert.equal(state, 'error-page');
assert.match(detail, /symptom/);
});
test('a stack trace is named separately from a rendered page', () => {
assert.equal(classifyBody('Traceback (most recent call last):\n File ...')[0],
'stack-trace');
});
test('genuine TwiML over the cap is a splitting problem', () => {
const body = `<Response>${'<Say>hello</Say>'.repeat(6000)}</Response>`;
assert.ok(byteLength(body) > LIMIT);
const [state, detail] = classifyBody(body);
assert.equal(state, 'oversized-twiml');
assert.match(detail, /splitting/);
});
test('TwiML under the cap is reported as a floor, not a clean bill', () => {
const [state, detail] = classifyBody('<Response><Say>Hi</Say></Response>');
assert.equal(state, 'twiml-truncated');
assert.match(detail, /floor/);
});
test('an empty body is reported rather than guessed', () => {
assert.equal(classifyBody('')[0], 'no-body');
assert.equal(classifyBody(null)[0], 'no-body');
});
test('something that is neither TwiML nor an error page', () => {
const [state, detail] = classifyBody('{"error": "too many participants"}');
assert.equal(state, 'not-twiml');
assert.match(detail, /bytes/);
});
test('group keeps only 11750 and records the ends', () => {
const rows = group([
alert('NO1', 'https://a.example.com/voice?CallSid=CA1', '11750', '2026-04-02T10:00:00Z'),
alert('NO2', 'https://a.example.com/voice/', '11750', '2026-04-01T09:00:00Z'),
alert('NO3', 'https://a.example.com/voice', '12100'),
]);
assert.deepEqual([...rows.keys()], ['a.example.com/voice']);
assert.equal(rows.get('a.example.com/voice').alerts, 2);
assert.equal(rows.get('a.example.com/voice').last, '2026-04-02T10:00:00Z');
});
test('code and endpoint helpers', () => {
assert.equal(codeOf({ error_code: '11750' }), 11750);
assert.equal(codeOf({}), null);
assert.equal(endpointOf('https://A.example.com/voice?x=1'), 'a.example.com/voice');
});
FAQ
My TwiML is tiny. Why am I getting 11750?
Because the response Twilio received was not your TwiML. The handler threw and the framework returned its debug page, which inlines source context, local variables and styling and passes 64 kB easily. Fetch the alert by SID and read response_body: if it opens with <!DOCTYPE html>, that is the whole answer.
Is the 64 kB limit in bytes or characters?
Bytes. A document with accented or non-Latin text in <Say> is larger on the wire than its character count suggests, so any check that measures with a string length will pass a response that Twilio refuses. The script encodes before counting.
The stored response body is only 20 kB. Doesn't that disprove the error?
No. response_body on an alert is kept with a size limit of its own, so its length is a floor rather than a measurement. That is why the verdict is based on what the body is, and why a body under the cap is reported as truncated rather than as healthy.
What should a status callback return?
An empty <Response/>. Status callbacks are notifications, not instructions, so there is nothing useful to say back. Handlers that return a full page to every callback do needless work at every event and put themselves one exception away from this error.
How do I get a genuinely large TwiML document under the limit?
Split it across <Redirect> hops. Emit as much of the flow as fits comfortably, end with a <Redirect> to the next segment, and let Twilio fetch it. Each response stays small and the call continues without the caller noticing a boundary.
Related field notes
- TwiML that is not well-formed XML
- The wrong Content-Type on a TwiML response
- 11200 on a status callback leaves delivery state blind
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 11750: TwiML response body too large — Twilio Docs
- Monitor Alert resource — Twilio Docs
- TwiML for Programmable Voice — Twilio Docs
- TwiML Voice: <Redirect> — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.