Diagnostic Twilio
error 21617: the rendered message body exceeds 1600 chars
The template is fine. It has been fine for a year. Then one customer with a long company name, three line items and a German address renders past sixteen hundred characters, Twilio refuses the request with 21617, and that customer never receives the message. Their Message SID is not in your logs because there is no Message SID: the send was rejected before a resource was created, so it appears nowhere in the Messages list at all.
Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD and keep alerts whose error_code is 21617. Request-time rejections never create a Message row, so the Alerts list is the only read-only place they exist.
Then page GET /2010-04-01/Accounts/{AccountSid}/Messages.json for the near misses: any sender whose longest body is close to the sixteen hundred character ceiling, or whose messages are already at eight or more segments, is one long customer name from being rejected.
The problem in plain words
Two facts combine badly here. The first is that 21617 is a request-time rejection: the API refuses the parameters, no Message resource is created, nothing is billed, and no status callback fires. The second is that the rejection is data-dependent — it happens for the subset of recipients whose interpolated values are long, which is a subset your test fixtures do not contain and your staging data almost certainly does not either.
So the failure lands entirely in your own error handling, and only for some users. If the send happens inside a background job that logs and moves on, the outcome is a customer who silently stops receiving one class of message while everybody else keeps getting it. Nobody reports it, because a message that never arrives leaves no trace to report.
The account-level view is worse than useless: delivery rate is unaffected, because a rejected send never enters the denominator.
Why it happens
Rejected sends are invisible in the Messages list. The row does not exist. You can page Messages.json for a year and never see a single 21617, which is why the Monitor Alerts list, not the message list, is the read path for this whole class of error.
The limit is on the rendered body, not the template. Validation that runs against the template passes forever. The only length that matters is the one produced after every variable has been substituted, for the specific recipient, at the moment of the call.
Sixteen hundred characters is not sixteen hundred bytes. The ceiling counts characters, but the encoding those characters force decides how many segments a body of a given length becomes, which is why long non-Latin bodies feel like they hit the wall sooner. Anything at eight segments or more is close enough to the edge to be worth reading before it goes over.
Alerts are retained thirty days. Whatever this script says about how long the problem has been happening is bounded by that window, and it should say so rather than implying it looked further back than it can.
The fix, as a flow
The script reads two lists, because the failure and its warning signs live apart: Monitor Alerts holds the rejections that never became messages, and the Messages list holds the bodies that are nearly there.
How to fix it
Sweep the Monitor alerts for the window you care about
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=100, following meta.next_page_url — on this API the next page is an absolute URL, not the relative next_page_uri the 2010 API uses. Read error_code as an integer: the Monitor API returns it as a string.
Count the rejections and take the first and last date
date_generated on the earliest and latest 21617 tells you whether this started with last week's template change or has been quietly running all month. Alerts are kept thirty days, so the earliest one you can see may not be the first one that happened.
Fetch a few alerts individually to see what was sent
GET https://monitor.twilio.com/v1/Alerts/{Sid} returns request_variables, request_headers and response_body, none of which appear in the list rows. That is one request per alert, so cap it: two or three examples are enough to identify which template and which variable did it.
Page the Messages list for the near misses
GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000. Group by messaging_service_sid or from, keep the longest body length seen and count the rows at num_segments eight or higher. Those are the sends that will be rejected the next time a longer name comes through.
Truncate at the source, then re-run
Validate the fully rendered body before the call, truncate or split server-side, and aim well below the ceiling — under 320 characters is the range where deliverability and cost both behave. Re-run the sweep over the following week; the alert count should be zero and the longest body should have moved.
How to check it worked
Re-run over a window that covers the sends since the change. The alert count should be zero and every sender should report fine.
python3 twilio_body_length_audit.py --days 14
# 0 rejection(s) with 21617, 3 sender(s), 0 near the limit
The full code
Two read paths, because the failure and its warning signs live in different places: the Monitor Alerts list for the rejections that never became messages, and the Messages list for the bodies that are nearly there. Both are GET. The summary and the verdict are pure functions, so the thresholds — what counts as near the limit, what counts as merely long — are visible rather than buried.
"""Report Twilio sends rejected with 21617 and the bodies that are nearly there.
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 email.utils import parsedate_to_datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_body_length_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
TOO_LONG = 21617
LIMIT = 1600 # the hard ceiling on a concatenated body
NEAR = 1200 # close enough that one long name goes over
COMFORTABLE = 320 # above this, cost and deliverability both start to bite
NEAR_SEGMENTS = 8
def alert_error_code(alert):
"""Read error_code off a Monitor alert as an integer, or None.
The Monitor API returns it as a string, unlike the Messages list. Comparing
it to 21617 without the conversion matches 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 parse_ts(raw):
"""date_generated is ISO 8601 on the Monitor API and RFC 2822 on the 2010
one. Accept both rather than guessing which list is being read."""
s = str(raw or "").strip()
if not s:
return None
try:
return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
except ValueError:
pass
try:
return parsedate_to_datetime(s)
except (TypeError, ValueError):
return None
def alert_summary(alerts, code=TOO_LONG):
"""Reduce a page of alerts to the rejections that matter. Pure.
Returns {"count", "first", "last", "sids"}. The SIDs are capped at three,
because each one costs a separate GET to expand and three examples identify
the template.
"""
out = {"count": 0, "first": None, "last": None, "sids": []}
for a in alerts:
if alert_error_code(a) != code:
continue
out["count"] += 1
if len(out["sids"]) < 3:
out["sids"].append(a.get("sid"))
stamp = parse_ts(a.get("date_generated"))
if stamp is not None:
if out["first"] is None or stamp < out["first"]:
out["first"] = stamp
if out["last"] is None or stamp > out["last"]:
out["last"] = stamp
return out
def tally(messages):
"""Bucket outbound messages by sender, keeping the length evidence. Pure.
Inbound messages are skipped: their length is not yours to control and they
cannot be rejected by an API you did not call.
"""
rows = {}
for m in messages:
if str(m.get("direction") or "").startswith("inbound"):
continue
key = m.get("messaging_service_sid") or m.get("from") or "unknown sender"
row = rows.setdefault(key, {"total": 0, "longest": 0, "near": 0,
"sids": []})
row["total"] += 1
size = len(str(m.get("body") or ""))
try:
segments = int(m.get("num_segments") or 1)
except (TypeError, ValueError):
segments = 1
if size > row["longest"]:
row["longest"] = size
if size >= NEAR or segments >= NEAR_SEGMENTS:
row["near"] += 1
if len(row["sids"]) < 3:
row["sids"].append(m.get("sid"))
return rows
def verdict(stats, limit=LIMIT, near=NEAR, comfortable=COMFORTABLE):
"""Classify one sender by how close its longest body came to the ceiling.
Pure, so the thresholds can be read and argued with. Returns
(state, detail).
"""
total = int(stats.get("total") or 0)
longest = int(stats.get("longest") or 0)
close = int(stats.get("near") or 0)
headroom = limit - longest
if longest >= near:
return ("near-limit",
"longest body %d of %d characters, %d to spare, %d message(s) "
"already past %d. One longer name or one extra line item and "
"that send is rejected with 21617 and never becomes a Message."
% (longest, limit, headroom, close, near))
if longest >= comfortable:
return ("long",
"longest body %d characters over %d message(s). Under the "
"ceiling, but past the point where segments and carrier "
"tolerance both start to cost you." % (longest, total))
return ("fine", "%d message(s), longest body %d characters" % (total, longest))
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, start, limit):
"""Page the Monitor alerts. next_page_url is absolute on this API."""
url = "%s/Alerts" % MONITOR
params = {"LogLevel": "error", "StartDate": start, "PageSize": 100}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("alerts", []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def list_messages(session, account, since, limit):
"""Page Messages.json. No Status or ErrorCode filter exists on this
resource, so the window and the cap are the only bounds."""
url = "%s/Accounts/%s/Messages.json" % (BASE, account)
params = {"PageSize": 1000, "DateSent>=": since}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("messages", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=14,
help="window for both sweeps; alerts are retained 30 days")
ap.add_argument("--max-messages", type=int, default=20000,
help="stop paging the Messages list after this many rows")
ap.add_argument("--detail", type=int, default=2,
help="expand this many alerts individually for the request "
"variables the list omits (one GET each)")
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
session = requests.Session()
session.auth = (key, secret)
days = min(args.days, 30)
if days != args.days:
log.info("alerts are retained 30 days; window shortened to %d", days)
since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
rejected = alert_summary(list_alerts(session, since, 10000))
if rejected["count"]:
log.warning("rejected 21617 x%d, first %s, last %s",
rejected["count"], rejected["first"], rejected["last"])
log.warning(" alert sids: %s", ", ".join(str(s) for s in rejected["sids"]))
for sid in rejected["sids"][:max(0, args.detail)]:
one = get(session, "%s/Alerts/%s" % (MONITOR, sid))
log.warning(" %s request_variables: %.400s", sid,
one.get("request_variables") or "(empty)")
log.warning(" repair: truncate or split the rendered body before the "
"call. The limit is on the substituted text, not the "
"template, so validate the string you are about to send.")
else:
log.info("rejected no 21617 alerts since %s", since)
messages = list_messages(session, account, since, args.max_messages)
senders = tally(messages)
bad = 0
for sender, stats in sorted(senders.items()):
state, detail = verdict(stats)
line = "%-11s %s %s" % (state, sender, detail)
if state == "fine":
log.info(line)
continue
if state == "long":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" message sids: %s", ", ".join(str(s) for s in stats["sids"]))
log.info("%d rejection(s) with 21617, %d sender(s), %d near the limit",
rejected["count"], len(senders), bad)
return 1 if (bad or rejected["count"]) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio sends rejected with 21617 and the bodies that are nearly there.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The repair is printed, never
* performed.
*/
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const MONITOR = 'https://monitor.twilio.com/v1';
const TOO_LONG = 21617;
const LIMIT = 1600; // the hard ceiling on a concatenated body
const NEAR = 1200; // close enough that one long name goes over
const COMFORTABLE = 320; // above this, cost and deliverability both bite
const NEAR_SEGMENTS = 8;
/**
* Read error_code off a Monitor alert as a number, or null. The Monitor API
* returns it as a string, unlike the Messages list.
*/
export function alertErrorCode(alert) {
const raw = alert.error_code;
if (raw === null || raw === undefined || raw === '') return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function parseTs(raw) {
const s = String(raw ?? '').trim();
if (!s) return null;
const t = Date.parse(s);
return Number.isNaN(t) ? null : new Date(t);
}
/**
* Reduce a page of alerts to the rejections that matter. Pure. SIDs are capped
* at three, because each one costs a separate GET to expand.
*/
export function alertSummary(alerts, code = TOO_LONG) {
const out = { count: 0, first: null, last: null, sids: [] };
for (const a of alerts) {
if (alertErrorCode(a) !== code) continue;
out.count += 1;
if (out.sids.length < 3) out.sids.push(a.sid);
const stamp = parseTs(a.date_generated);
if (stamp) {
if (!out.first || stamp < out.first) out.first = stamp;
if (!out.last || stamp > out.last) out.last = stamp;
}
}
return out;
}
/**
* Bucket outbound messages by sender, keeping the length evidence. Pure.
*/
export function tally(messages) {
const rows = new Map();
for (const m of messages) {
if (String(m.direction ?? '').startsWith('inbound')) continue;
const key = m.messaging_service_sid || m.from || 'unknown sender';
if (!rows.has(key)) rows.set(key, { total: 0, longest: 0, near: 0, sids: [] });
const row = rows.get(key);
row.total += 1;
const size = String(m.body ?? '').length;
const segments = Number(m.num_segments ?? 1) || 1;
if (size > row.longest) row.longest = size;
if (size >= NEAR || segments >= NEAR_SEGMENTS) {
row.near += 1;
if (row.sids.length < 3) row.sids.push(m.sid);
}
}
return rows;
}
/**
* Classify one sender by how close its longest body came to the ceiling. Pure.
* Returns [state, detail].
*/
export function verdict(stats, limit = LIMIT, near = NEAR, comfortable = COMFORTABLE) {
const total = Number(stats.total ?? 0);
const longest = Number(stats.longest ?? 0);
const close = Number(stats.near ?? 0);
const headroom = limit - longest;
if (longest >= near) {
return ['near-limit',
`longest body ${longest} of ${limit} characters, ${headroom} to spare, ` +
`${close} message(s) already past ${near}. One longer name or one extra ` +
'line item and that send is rejected with 21617 and never becomes a Message.'];
}
if (longest >= comfortable) {
return ['long',
`longest body ${longest} characters over ${total} message(s). Under the ` +
'ceiling, but past the point where segments and carrier tolerance both ' +
'start to cost you.'];
}
return ['fine', `${total} message(s), longest body ${longest} characters`];
}
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();
}
async function listAlerts(auth, start, limit) {
let url = `${MONITOR}/Alerts`;
let params = { LogLevel: 'error', StartDate: start, PageSize: 100 };
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 listMessages(auth, account, since, limit) {
let url = `${BASE}/Accounts/${account}/Messages.json`;
let params = { PageSize: 1000, 'DateSent>=': since };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.messages ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out.slice(0, limit);
}
function flag(name, fallback) {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : Number(process.argv[i + 1]);
}
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 days = Math.min(flag('--days', 14), 30);
const detail = flag('--detail', 2);
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const rejected = alertSummary(await listAlerts(auth, since, 10000));
if (rejected.count) {
console.warn(`rejected 21617 x${rejected.count}, first ${rejected.first}, ` +
`last ${rejected.last}`);
console.warn(` alert sids: ${rejected.sids.join(', ')}`);
for (const sid of rejected.sids.slice(0, Math.max(0, detail))) {
const one = await get(auth, `${MONITOR}/Alerts/${sid}`);
console.warn(` ${sid} request_variables: ` +
String(one.request_variables ?? '(empty)').slice(0, 400));
}
console.warn(' repair: truncate or split the rendered body before the call. ' +
'The limit is on the substituted text, not the template, so ' +
'validate the string you are about to send.');
} else {
console.log(`rejected no 21617 alerts since ${since}`);
}
const messages = await listMessages(auth, account, since, flag('--max-messages', 20000));
const senders = tally(messages);
let bad = 0;
for (const sender of [...senders.keys()].sort()) {
const stats = senders.get(sender);
const [state, detail2] = verdict(stats);
const line = `${state.padEnd(11)} ${sender} ${detail2}`;
if (state !== 'near-limit') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` message sids: ${stats.sids.join(', ')}`);
}
console.log(`${rejected.count} rejection(s) with 21617, ${senders.size} ` +
`sender(s), ${bad} near the limit`);
process.exitCode = (bad || rejected.count) ? 1 : 0;
}
// Only run when invoked directly, so importing this module in the tests does not
// run main(), fail on the missing credentials and set a non-zero exit code.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The case that matters most is the one that costs nothing to get wrong and everything to miss: the Monitor API hands back error_code as a string, so a summary that compares it to the integer 21617 reports a clean account forever. The rest pin the thresholds — a body at 1250 characters is a warning, a body at 400 is not.
from twilio_body_length_audit import alert_summary, tally, verdict
def alert(sid, code, when="2026-03-02T09:00:00Z"):
return {"sid": sid, "error_code": code, "date_generated": when}
def test_monitor_returns_error_code_as_a_string():
# The whole audit reports nothing if this comparison is done on the raw value.
out = alert_summary([alert("NO1", "21617")])
assert out["count"] == 1
def test_summary_ignores_other_error_codes():
out = alert_summary([alert("NO1", "11200"), alert("NO2", "21617")])
assert out["count"] == 1
assert out["sids"] == ["NO2"]
def test_summary_keeps_the_first_and_last_rejection():
out = alert_summary([
alert("NO1", "21617", "2026-03-02T09:00:00Z"),
alert("NO2", "21617", "2026-02-25T04:30:00Z"),
alert("NO3", "21617", "2026-03-04T18:00:00Z"),
])
assert out["count"] == 3
assert out["first"].day == 25
assert out["last"].day == 4
def test_alert_sids_are_capped_at_three():
out = alert_summary([alert("NO%d" % i, "21617") for i in range(7)])
assert out["sids"] == ["NO0", "NO1", "NO2"]
assert out["count"] == 7
def test_tally_keeps_the_longest_body_per_sender_and_skips_inbound():
rows = tally([
{"sid": "SM1", "from": "+15550001111", "body": "x" * 40},
{"sid": "SM2", "from": "+15550001111", "body": "x" * 1250},
{"sid": "SM3", "from": "+15550001111", "direction": "inbound", "body": "y" * 90},
{"sid": "SM4", "messaging_service_sid": "MG1", "from": "+15550001111",
"body": "z" * 20},
])
assert sorted(rows) == ["+15550001111", "MG1"]
assert rows["+15550001111"]["longest"] == 1250
assert rows["+15550001111"]["near"] == 1
assert rows["+15550001111"]["sids"] == ["SM2"]
def test_eight_segments_counts_as_near_even_on_a_short_body():
# num_segments is the near-miss signal when the body was truncated in transit
# or the encoding inflated it.
rows = tally([{"sid": "SM1", "from": "+1555", "body": "x" * 600,
"num_segments": "9"}])
assert rows["+1555"]["near"] == 1
def test_a_body_past_the_warning_line_is_near_limit():
state, detail = verdict({"total": 900, "longest": 1250, "near": 4})
assert state == "near-limit"
assert "350 to spare" in detail
assert "21617" in detail
def test_a_long_but_safe_body_is_only_long():
state, detail = verdict({"total": 900, "longest": 400})
assert state == "long"
assert "ceiling" in detail
def test_short_bodies_are_fine():
state, detail = verdict({"total": 900, "longest": 120})
assert state == "fine"
assert "120" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { alertSummary, tally, verdict } from './twilio-body-length-audit.mjs';
const alert = (sid, code, when = '2026-03-02T09:00:00Z') => ({
sid, error_code: code, date_generated: when,
});
test('monitor returns error_code as a string', () => {
assert.equal(alertSummary([alert('NO1', '21617')]).count, 1);
});
test('summary ignores other error codes', () => {
const out = alertSummary([alert('NO1', '11200'), alert('NO2', '21617')]);
assert.equal(out.count, 1);
assert.deepEqual(out.sids, ['NO2']);
});
test('summary keeps the first and last rejection', () => {
const out = alertSummary([
alert('NO1', '21617', '2026-03-02T09:00:00Z'),
alert('NO2', '21617', '2026-02-25T04:30:00Z'),
alert('NO3', '21617', '2026-03-04T18:00:00Z'),
]);
assert.equal(out.count, 3);
assert.equal(out.first.getUTCDate(), 25);
assert.equal(out.last.getUTCDate(), 4);
});
test('alert sids are capped at three', () => {
const out = alertSummary([...Array(7).keys()].map((i) => alert(`NO${i}`, '21617')));
assert.deepEqual(out.sids, ['NO0', 'NO1', 'NO2']);
assert.equal(out.count, 7);
});
test('tally keeps the longest body per sender and skips inbound', () => {
const rows = tally([
{ sid: 'SM1', from: '+15550001111', body: 'x'.repeat(40) },
{ sid: 'SM2', from: '+15550001111', body: 'x'.repeat(1250) },
{ sid: 'SM3', from: '+15550001111', direction: 'inbound', body: 'y'.repeat(90) },
{ sid: 'SM4', messaging_service_sid: 'MG1', from: '+15550001111', body: 'z'.repeat(20) },
]);
assert.deepEqual([...rows.keys()].sort(), ['+15550001111', 'MG1']);
assert.equal(rows.get('+15550001111').longest, 1250);
assert.equal(rows.get('+15550001111').near, 1);
assert.deepEqual(rows.get('+15550001111').sids, ['SM2']);
});
test('eight segments counts as near even on a short body', () => {
const rows = tally([{ sid: 'SM1', from: '+1555', body: 'x'.repeat(600),
num_segments: '9' }]);
assert.equal(rows.get('+1555').near, 1);
});
test('a body past the warning line is near-limit', () => {
const [state, detail] = verdict({ total: 900, longest: 1250, near: 4 });
assert.equal(state, 'near-limit');
assert.match(detail, /350 to spare/);
assert.match(detail, /21617/);
});
test('a long but safe body is only long', () => {
const [state, detail] = verdict({ total: 900, longest: 400 });
assert.equal(state, 'long');
assert.match(detail, /ceiling/);
});
test('short bodies are fine', () => {
const [state, detail] = verdict({ total: 900, longest: 120 });
assert.equal(state, 'fine');
assert.match(detail, /120/);
});
FAQ
Why can't I find the failed message in the Messages list?
Because it was never created. 21617 is a request-time rejection: the API refuses the parameters, no Message resource exists, nothing is billed and no status callback fires. The Monitor Alerts list is the only read-only record that the attempt happened at all.
Is the limit 1600 characters or 1600 bytes?
Characters, on the concatenated body. What the encoding changes is the cost of getting there: a body forced into UCS-2 fits 70 characters per segment instead of 160, so a long non-Latin message becomes many more segments well before it reaches the ceiling.
Why does the script flag messages at eight segments?
Because eight segments is the neighbourhood of the wall, and near misses are the only early warning this failure has. A sender whose longest body is already 1250 characters is one longer customer name away from silently dropping a message, and you would rather know now than from a support ticket.
Why does it fetch some alerts individually?
Because request_variables, request_headers and response_body are populated only on GET /v1/Alerts/{Sid} and are absent from every row of the list. That is what tells you which template and which variable did it. It costs one request per alert, so the script caps it at two by default.
How far back can this look?
Thirty days. Alerts are retained for that long, so any statement about when the problem started is bounded by the window and the script says so rather than implying it looked further.
Related field notes
- One smart quote triples the segment count
- Carrier filtering drops SMS with error 30007
- Messages that never leave queued or accepted
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 21617: message body exceeds 1600 characters — Twilio Docs
- Monitor Alert resource — Twilio Docs
- Message resource — Twilio Docs
- Messaging Services — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.