Diagnostic Twilio
status callback failures with 11200 leave delivery state blind
Support says the customer never got the text. Your dashboard agrees: the row still reads queued, hours later. Then you open the Twilio Console, paste the Message SID, and it says delivered — forty seconds after you sent it. Nothing was lost. Twilio tried to tell you, your endpoint returned something other than a 2xx, and the update went in the bin along with every other one that day.
Sweep GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD, keep alerts where error_code is 11200, and group them by request_url. Then read the configured status_callback off GET https://messaging.twilio.com/v1/Services and GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json, and match the two.
An 11200 on a URL that is one of those is a status callback: delivery state your database never received. An 11200 on any other URL is an inbound handler, which is a different failure with a different repair. Reconcile against Messages.json — that list is the state that is actually true.
The problem in plain words
A status callback is a push copy of something Twilio already knows. That is what makes this failure so patient: nothing is destroyed, the Message resource carries the correct final status the whole time, and every symptom shows up only in your own database. The gap opens quietly and stays open until a human compares two screens.
The shape it takes downstream is worse than a stale row. Retry jobs fire against messages that already arrived. Dunning emails go out to customers whose payment reminder was delivered. A support agent reads queued, resends by hand, and the recipient gets the same one-time passcode twice. Every one of those is caused by trusting a push where a pull was available.
Why it happens
Twilio does not retry status callbacks forever. A callback is a best-effort delivery of an event that has already happened. If your endpoint returns a 500, or takes longer than Twilio's HTTP window, the attempt is logged as 11200 and the event moves on. There is no queue holding it for you and no replay endpoint to ask for it back.
The alert names the URL, not the setting. request_url is the URL Twilio fetched, complete with the query string it appended. The configured value on the Messaging Service or the phone number has no query string, and may differ in scheme or trailing slash. Comparing the two as raw strings matches nothing, which is exactly how a report ends up claiming every alert is on some other webhook.
Two resources own the same setting. status_callback exists on the Messaging Service and on each phone number. Read only one of them and half your alerts get misattributed — and misattribution matters here, because an 11200 on an inbound handler means the message or call itself dropped, while an 11200 on a status callback means only that your bookkeeping is behind.
The response body is not in the list. Every row of GET /v1/Alerts has response_body and response_headers blank. They are populated only when you fetch a single alert by SID. So the cheap sweep tells you which endpoint is failing and how often, and finding out what it returned costs one extra request per alert you care about.
The fix, as a flow
The script matches the logged URL against the configured one on host and path, because Twilio appends its own parameters to the URL it fetches and a raw string comparison would file every alert under some other webhook.
How to fix it
Sweep the alerts for 11200 over a bounded window
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, following meta.next_page_url. Alerts are retained 30 days, so a window longer than that is not a longer window, it is the same one with a misleading label. Read error_code as an integer: the Monitor API returns it as a string, unlike the Messages list.
Normalise the URLs before you compare them
Reduce both the alert's request_url and the configured status_callback to lowercase host plus path, dropping the query string and any trailing slash. Twilio appends parameters to the URL it fetches, so the logged URL never equals the configured one character for character.
Read the configured callbacks from both resources
GET https://messaging.twilio.com/v1/Services gives status_callback per Messaging Service; GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json gives it per number. Build one set from both. An endpoint in that set is a status callback; anything else is an inbound handler and belongs in a different report.
Fetch one failing alert by SID to see the response
GET https://monitor.twilio.com/v1/Alerts/{Sid} returns response_body and response_headers, which the list omits entirely. One fetch per failing endpoint is usually enough: a stack trace, a login redirect, or an empty body with a 502 each point at a different repair. Cap the number of fetches, because this is one request per alert.
Reconcile against the Messages list and backfill
GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD and count how many messages reached a final status while the callbacks were failing. That number is the size of the hole in your database. Fix the handler so it returns an empty 200 immediately and does its work asynchronously, then backfill by polling that list rather than waiting for a replay that is never coming.
How to check it worked
Re-run the script over the same window after the handler change. Configured callbacks should stop appearing in the report entirely.
python3 twilio_status_callback_audit.py --days 3
# 0 status callback endpoint(s) failing, 0 other webhook(s) with 11200
The full code
Four reads joined into one report: the alerts, the two places a status callback can be configured, and the Messages list that says what really happened. The pure functions are the URL normalisation and the classification, because both are where this check quietly fails — a comparison that never matches reports a clean account, and so does one that never ran.
"""Find StatusCallback endpoints failing with 11200 and size the gap they left.
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_status_callback_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
MESSAGING = "https://messaging.twilio.com/v1"
RETRIEVAL_FAILURE = 11200
# Statuses a message never leaves. Anything else is still in flight, and the
# callback that would have told you it moved is the thing that failed.
FINAL = {"delivered", "undelivered", "failed", "received", "read"}
# 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
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. A comparison written against 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(url):
"""Reduce a webhook URL to lowercase host plus path.
Twilio logs the URL it actually fetched, carrying the parameters it appended
and whatever scheme and trailing slash the configuration happened to have.
The configured value has none of that. Comparing the two raw is the mistake
that makes every alert look like it belongs to some other webhook.
"""
if not url:
return ""
parts = urlsplit(str(url).strip())
host = (parts.hostname or "").lower()
if not host:
return str(url).strip().lower().rstrip("/")
path = (parts.path or "").rstrip("/")
return host + path
def callback_endpoints(services, numbers):
"""Every status_callback configured on the account, normalised.
Two resources own one setting: a Messaging Service carries a status_callback
for everything sent through it, and each phone number carries its own for
messages sent from that number outside a service. Reading only one of them
misattributes half the alerts, and the two roles have opposite urgency.
"""
out = {}
for s in services or []:
e = endpoint(s.get("status_callback"))
if e:
out.setdefault(e, []).append("service %s" % (s.get("sid") or "?"))
for n in numbers or []:
e = endpoint(n.get("status_callback"))
if e:
label = n.get("phone_number") or n.get("sid") or "?"
out.setdefault(e, []).append("number %s" % label)
return out
def tally(alerts, callbacks):
"""Group 11200 alerts by the endpoint that failed.
Pure, so the grouping and the role assignment can be tested without a
network. date_generated is ISO 8601 in UTC on every alert, so a string
comparison orders them correctly and no parsing is needed to find the ends.
"""
out = {}
for a in alerts:
if code_of(a) != RETRIEVAL_FAILURE:
continue
e = endpoint(a.get("request_url"))
row = out.setdefault(e, {
"alerts": 0,
"sids": [],
"owners": list(callbacks.get(e, [])),
"role": "status-callback" if e in callbacks else "other-webhook",
"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
def verdict(row, min_alerts=3):
"""Classify one failing endpoint. Pure, so the thresholds are visible.
Returns (state, detail).
"""
n = int(row.get("alerts") or 0)
if not n:
return ("clean", "no 11200 in the window")
if row.get("role") != "status-callback":
return ("other-webhook",
"%d x 11200 on a URL that is not a configured status_callback. "
"This is an inbound handler, so the call or message itself "
"dropped rather than the bookkeeping: a fallback URL is the "
"mitigation there, not a backfill." % n)
if n < min_alerts:
return ("intermittent",
"%d x 11200 on a status callback. A handful is a slow handler "
"under load rather than an outage, but those updates are still "
"gone and only the Messages list has them." % n)
return ("blind",
"%d x 11200 on a status callback. Every one is a delivery update "
"your database never received, and Twilio does not hold them for a "
"replay." % n)
def reconcile(messages):
"""Count what the Messages list says, which is the state that is true.
The callback is a push copy of this resource. When the push fails nothing is
lost, it is simply not in your database, so the number worth printing is how
many messages reached a final status during the window.
"""
out = {"total": 0, "final": 0, "open": 0, "failed": 0}
for m in messages:
status = str(m.get("status") or "").lower()
out["total"] += 1
if status in FINAL:
out["final"] += 1
else:
out["open"] += 1
if status in ("undelivered", "failed"):
out["failed"] += 1
return out
def get(session, url, **params):
r = session.get(url, params=params, timeout=30)
if r.status_code in (401, 403):
raise SystemExit("%d from Twilio: check TWILIO_ACCOUNT_SID and that the "
"API key belongs to that account with read access"
% r.status_code)
r.raise_for_status()
return r.json()
def list_alerts(session, since, limit, log_level="error"):
"""Page the Monitor alerts. next_page_url is absolute on this API."""
url = MONITOR + "/Alerts"
params = {"LogLevel": log_level, "StartDate": since, "PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("alerts", []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def fetch_alert(session, sid):
"""One alert by SID, which is the only place response_body exists.
The list resource blanks response_body and response_headers on every row, so
seeing what the endpoint actually returned costs one request per alert.
"""
return get(session, "%s/Alerts/%s" % (MONITOR, sid))
def list_services(session, limit=1000):
url = MESSAGING + "/Services"
params = {"PageSize": 100}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("services", []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out
def list_numbers(session, account, limit=2000):
url = "%s/Accounts/%s/IncomingPhoneNumbers.json" % (BASE, account)
params = {"PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("incoming_phone_numbers", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out
def list_messages(session, account, since, limit):
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=3,
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("--max-messages", type=int, default=20000,
help="stop paging the Messages list after this many")
ap.add_argument("--min-alerts", type=int, default=3,
help="fewer than this on one endpoint is reported as intermittent")
ap.add_argument("--sample", type=int, default=1,
help="alerts to fetch individually per endpoint for the "
"response body (0 to skip; 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)
callbacks = callback_endpoints(list_services(session),
list_numbers(session, account))
log.info("%d alert(s) since %s, %d configured status_callback endpoint(s)",
len(alerts), since, len(callbacks))
rows = tally(alerts, callbacks)
blind = other = 0
for e, row in sorted(rows.items()):
state, detail = verdict(row, args.min_alerts)
line = "%-14s %s %s" % (state, e, detail)
if state == "clean":
log.info(line)
continue
if state == "other-webhook":
other += 1
log.warning(line)
continue
blind += 1
log.warning(line)
if row["owners"]:
log.warning(" configured on: %s", ", ".join(row["owners"]))
log.warning(" first %s, last %s", row["first"], row["last"])
for sid in row["sids"][:max(0, args.sample)]:
full = fetch_alert(session, sid)
body = (full.get("response_body") or "").strip().replace("\n", " ")
log.warning(" %s returned: %s", sid, body[:200] or "(empty body)")
log.warning(" repair: return an empty 200 from this handler before you "
"do any work, process the payload asynchronously, and "
"allowlist Twilio's egress ranges if a WAF is in front of "
"it. Then backfill from Messages.json.")
counts = reconcile(list_messages(session, account, since, args.max_messages))
log.info("messages since %s: %d total, %d final, %d still open, %d failed",
since, counts["total"], counts["final"], counts["open"],
counts["failed"])
log.info("%d status callback endpoint(s) failing, %d other webhook(s) with "
"11200", blind, other)
return 1 if blind else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find StatusCallback endpoints failing with 11200 and size the gap they left.
*
* 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 MESSAGING = 'https://messaging.twilio.com/v1';
const RETRIEVAL_FAILURE = 11200;
// Statuses a message never leaves. Anything else is still in flight.
const FINAL = new Set(['delivered', 'undelivered', 'failed', 'received', 'read']);
// Alerts are retained 30 days. A longer window is the same history mislabelled.
const MAX_DAYS = 30;
/**
* Read error_code off an alert as a number, or null. The Monitor API returns it
* as a string while the Messages list returns a number, and a check written for
* one and pointed at the other 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;
}
/**
* Reduce a webhook URL to lowercase host plus path. Twilio logs the URL it
* fetched, with the parameters it appended; the configured value has none of
* them, so a raw comparison never matches.
*/
export function endpoint(url) {
if (!url) return '';
const raw = String(url).trim();
let host = '';
let path = '';
try {
const u = new URL(raw);
host = u.hostname.toLowerCase();
path = u.pathname;
} catch {
return raw.toLowerCase().replace(/\/+$/, '');
}
if (!host) return raw.toLowerCase().replace(/\/+$/, '');
while (path.endsWith('/')) path = path.slice(0, -1);
return host + path;
}
/**
* Every status_callback configured on the account, normalised. A Messaging
* Service carries one for the whole service and each phone number carries its
* own; reading only one of them misattributes half the alerts.
*/
export function callbackEndpoints(services, numbers) {
const out = new Map();
for (const s of services ?? []) {
const e = endpoint(s.status_callback);
if (!e) continue;
if (!out.has(e)) out.set(e, []);
out.get(e).push(`service ${s.sid ?? '?'}`);
}
for (const n of numbers ?? []) {
const e = endpoint(n.status_callback);
if (!e) continue;
if (!out.has(e)) out.set(e, []);
out.get(e).push(`number ${n.phone_number ?? n.sid ?? '?'}`);
}
return out;
}
/**
* Group 11200 alerts by the endpoint that failed. Pure. date_generated is ISO
* 8601 in UTC, so a string comparison finds the ends without parsing.
*/
export function tally(alerts, callbacks) {
const out = new Map();
for (const a of alerts) {
if (codeOf(a) !== RETRIEVAL_FAILURE) continue;
const e = endpoint(a.request_url);
if (!out.has(e)) {
out.set(e, {
alerts: 0,
sids: [],
owners: [...(callbacks.get(e) ?? [])],
role: callbacks.has(e) ? 'status-callback' : 'other-webhook',
first: null,
last: null,
});
}
const row = out.get(e);
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;
}
/** Classify one failing endpoint. Pure. Returns [state, detail]. */
export function verdict(row, minAlerts = 3) {
const n = Number(row.alerts ?? 0);
if (!n) return ['clean', 'no 11200 in the window'];
if (row.role !== 'status-callback') {
return ['other-webhook',
`${n} x 11200 on a URL that is not a configured status_callback. This is ` +
'an inbound handler, so the call or message itself dropped rather than ' +
'the bookkeeping: a fallback URL is the mitigation there, not a backfill.'];
}
if (n < minAlerts) {
return ['intermittent',
`${n} x 11200 on a status callback. A handful is a slow handler under ` +
'load rather than an outage, but those updates are still gone and only ' +
'the Messages list has them.'];
}
return ['blind',
`${n} x 11200 on a status callback. Every one is a delivery update your ` +
'database never received, and Twilio does not hold them for a replay.'];
}
/**
* Count what the Messages list says, which is the state that is true. The
* callback is only a push copy of this resource.
*/
export function reconcile(messages) {
const out = { total: 0, final: 0, open: 0, failed: 0 };
for (const m of messages) {
const status = String(m.status ?? '').toLowerCase();
out.total += 1;
if (FINAL.has(status)) out.final += 1;
else out.open += 1;
if (status === 'undelivered' || status === 'failed') out.failed += 1;
}
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);
}
/** 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 listServices(auth) {
let url = `${MESSAGING}/Services`;
let params = { PageSize: 100 };
const out = [];
while (url) {
const page = await get(auth, url, params);
out.push(...(page.services ?? []));
url = page.meta?.next_page_url ?? null;
params = {};
}
return out;
}
async function listNumbers(auth, account) {
let url = `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`;
let params = { PageSize: 1000 };
const out = [];
while (url) {
const page = await get(auth, url, params);
out.push(...(page.incoming_phone_numbers ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out;
}
async function listMessages(auth, account, since, limit = 20000) {
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);
}
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] : 3) || 3;
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 callbacks = callbackEndpoints(await listServices(auth),
await listNumbers(auth, account));
console.log(`${alerts.length} alert(s) since ${since}, ${callbacks.size} ` +
'configured status_callback endpoint(s)');
const rows = tally(alerts, callbacks);
let blind = 0;
let other = 0;
for (const [e, row] of [...rows.entries()].sort()) {
const [state, detail] = verdict(row);
const line = `${state.padEnd(14)} ${e} ${detail}`;
if (state === 'clean') { console.log(line); continue; }
if (state === 'other-webhook') { other += 1; console.warn(line); continue; }
blind += 1;
console.warn(line);
if (row.owners.length) console.warn(` configured on: ${row.owners.join(', ')}`);
console.warn(` first ${row.first}, last ${row.last}`);
if (row.sids.length) {
const full = await fetchAlert(auth, row.sids[0]);
const body = (full.response_body ?? '').trim();
console.warn(` ${row.sids[0]} returned: ${body.slice(0, 200) || '(empty body)'}`);
}
console.warn(' repair: return an empty 200 from this handler before you do ' +
'any work, process the payload asynchronously, and allowlist ' +
"Twilio's egress ranges if a WAF is in front of it. Then " +
'backfill from Messages.json.');
}
const counts = reconcile(await listMessages(auth, account, since));
console.log(`messages since ${since}: ${counts.total} total, ${counts.final} ` +
`final, ${counts.open} still open, ${counts.failed} failed`);
console.log(`${blind} status callback endpoint(s) failing, ${other} other ` +
'webhook(s) with 11200');
process.exitCode = blind ? 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
Four rules carry this report. An error_code that arrives as the string "11200" still has to match. A logged URL with an appended query string still has to equal the configured value it came from. A callback set on a phone number counts as much as one set on a Messaging Service. And an 11200 on something that is not a configured callback has to be reported differently, because that one dropped a call.
from twilio_status_callback_audit import (callback_endpoints, code_of, endpoint,
reconcile, tally, verdict)
def alert(sid, url, code="11200", when="2026-03-02T10:00:00Z"):
return {"sid": sid, "request_url": url, "error_code": code,
"date_generated": when, "log_level": "error"}
def test_code_of_reads_the_string_the_monitor_api_actually_returns():
assert code_of({"error_code": "11200"}) == 11200
assert code_of({"error_code": 11200}) == 11200
assert code_of({"error_code": None}) is None
assert code_of({}) is None
def test_endpoint_ignores_the_query_string_twilio_appends():
logged = "https://hooks.example.com/twilio/status?MessageSid=SM1&AccountSid=AC1"
assert endpoint(logged) == "hooks.example.com/twilio/status"
assert endpoint("https://Hooks.Example.com/twilio/status/") == \
"hooks.example.com/twilio/status"
assert endpoint("http://hooks.example.com:8443/twilio/status") == \
"hooks.example.com/twilio/status"
assert endpoint(None) == ""
def test_callbacks_come_from_services_and_from_numbers():
cbs = callback_endpoints(
[{"sid": "MG1", "status_callback": "https://hooks.example.com/svc"}],
[{"phone_number": "+15550001111",
"status_callback": "https://hooks.example.com/pn/"}],
)
assert set(cbs) == {"hooks.example.com/svc", "hooks.example.com/pn"}
assert cbs["hooks.example.com/pn"] == ["number +15550001111"]
def test_a_number_only_callback_is_still_a_callback():
# Reading services alone is how a real status callback gets filed as some
# other webhook and quietly dropped from the report.
cbs = callback_endpoints([], [{"sid": "PN1",
"status_callback": "https://hooks.example.com/pn"}])
rows = tally([alert("NO1", "https://hooks.example.com/pn?MessageStatus=sent")], cbs)
assert rows["hooks.example.com/pn"]["role"] == "status-callback"
def test_tally_skips_alerts_with_other_error_codes():
cbs = callback_endpoints([], [])
rows = tally([alert("NO1", "https://hooks.example.com/s", code="11205"),
alert("NO2", "https://hooks.example.com/s", code="11200")], cbs)
assert rows["hooks.example.com/s"]["alerts"] == 1
assert rows["hooks.example.com/s"]["sids"] == ["NO2"]
def test_tally_records_the_ends_of_the_window():
cbs = callback_endpoints([], [])
rows = tally([alert("NO1", "https://a.example.com/s", when="2026-03-02T10:00:00Z"),
alert("NO2", "https://a.example.com/s", when="2026-03-01T09:00:00Z"),
alert("NO3", "https://a.example.com/s", when="2026-03-03T11:00:00Z")],
cbs)
row = rows["a.example.com/s"]
assert row["first"] == "2026-03-01T09:00:00Z"
assert row["last"] == "2026-03-03T11:00:00Z"
def test_an_11200_on_something_that_is_not_a_callback_is_a_dropped_call():
state, detail = verdict({"alerts": 40, "role": "other-webhook"})
assert state == "other-webhook"
assert "fallback" in detail
def test_two_failures_on_a_callback_are_a_slow_handler_not_an_outage():
state, detail = verdict({"alerts": 2, "role": "status-callback"})
assert state == "intermittent"
def test_a_run_of_failures_on_a_callback_is_blindness():
state, detail = verdict({"alerts": 900, "role": "status-callback"})
assert state == "blind"
assert "replay" in detail
def test_reconcile_counts_the_state_that_is_actually_true():
counts = reconcile([{"status": "delivered"}, {"status": "queued"},
{"status": "undelivered"}, {"status": "sent"}])
assert counts == {"total": 4, "final": 2, "open": 2, "failed": 1}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
callbackEndpoints, codeOf, endpoint, reconcile, tally, verdict,
} from './twilio-status-callback-audit.mjs';
const alert = (sid, url, code = '11200', when = '2026-03-02T10:00:00Z') => ({
sid, request_url: url, error_code: code, date_generated: when, log_level: 'error',
});
test('codeOf reads the string the Monitor API actually returns', () => {
assert.equal(codeOf({ error_code: '11200' }), 11200);
assert.equal(codeOf({ error_code: 11200 }), 11200);
assert.equal(codeOf({ error_code: null }), null);
assert.equal(codeOf({}), null);
});
test('endpoint ignores the query string Twilio appends', () => {
const logged = 'https://hooks.example.com/twilio/status?MessageSid=SM1&AccountSid=AC1';
assert.equal(endpoint(logged), 'hooks.example.com/twilio/status');
assert.equal(endpoint('https://Hooks.Example.com/twilio/status/'),
'hooks.example.com/twilio/status');
assert.equal(endpoint('http://hooks.example.com:8443/twilio/status'),
'hooks.example.com/twilio/status');
assert.equal(endpoint(null), '');
});
test('callbacks come from services and from numbers', () => {
const cbs = callbackEndpoints(
[{ sid: 'MG1', status_callback: 'https://hooks.example.com/svc' }],
[{ phone_number: '+15550001111', status_callback: 'https://hooks.example.com/pn/' }],
);
assert.deepEqual([...cbs.keys()].sort(),
['hooks.example.com/pn', 'hooks.example.com/svc']);
assert.deepEqual(cbs.get('hooks.example.com/pn'), ['number +15550001111']);
});
test('a number-only callback is still a callback', () => {
const cbs = callbackEndpoints([],
[{ sid: 'PN1', status_callback: 'https://hooks.example.com/pn' }]);
const rows = tally([alert('NO1', 'https://hooks.example.com/pn?MessageStatus=sent')], cbs);
assert.equal(rows.get('hooks.example.com/pn').role, 'status-callback');
});
test('tally skips alerts with other error codes', () => {
const cbs = callbackEndpoints([], []);
const rows = tally([
alert('NO1', 'https://hooks.example.com/s', '11205'),
alert('NO2', 'https://hooks.example.com/s', '11200'),
], cbs);
assert.equal(rows.get('hooks.example.com/s').alerts, 1);
assert.deepEqual(rows.get('hooks.example.com/s').sids, ['NO2']);
});
test('tally records the ends of the window', () => {
const cbs = callbackEndpoints([], []);
const rows = tally([
alert('NO1', 'https://a.example.com/s', '11200', '2026-03-02T10:00:00Z'),
alert('NO2', 'https://a.example.com/s', '11200', '2026-03-01T09:00:00Z'),
alert('NO3', 'https://a.example.com/s', '11200', '2026-03-03T11:00:00Z'),
], cbs);
const row = rows.get('a.example.com/s');
assert.equal(row.first, '2026-03-01T09:00:00Z');
assert.equal(row.last, '2026-03-03T11:00:00Z');
});
test('an 11200 on something that is not a callback is a dropped call', () => {
const [state, detail] = verdict({ alerts: 40, role: 'other-webhook' });
assert.equal(state, 'other-webhook');
assert.match(detail, /fallback/);
});
test('two failures on a callback are a slow handler, not an outage', () => {
const [state] = verdict({ alerts: 2, role: 'status-callback' });
assert.equal(state, 'intermittent');
});
test('a run of failures on a callback is blindness', () => {
const [state, detail] = verdict({ alerts: 900, role: 'status-callback' });
assert.equal(state, 'blind');
assert.match(detail, /replay/);
});
test('reconcile counts the state that is actually true', () => {
const counts = reconcile([{ status: 'delivered' }, { status: 'queued' },
{ status: 'undelivered' }, { status: 'sent' }]);
assert.deepEqual(counts, { total: 4, final: 2, open: 2, failed: 1 });
});
FAQ
Does Twilio retry a status callback that fails?
Not in a way you can rely on. A callback is a best-effort push of an event that already happened; a non-2xx or a slow response is logged as 11200 and the event moves on. There is no replay endpoint, which is why the repair always ends with backfilling from the Messages list.
Why does the script compare host and path instead of the whole URL?
Because Twilio appends its own parameters to the URL it fetches, so request_url on the alert is never character-for-character equal to the status_callback you configured. Scheme and a trailing slash differ too. Matching on lowercase host plus path is the comparison that survives all three.
Why can't I see what my endpoint returned in the alert list?
Because response_body and response_headers are blank on every row of GET /v1/Alerts. They are populated only on the single-alert fetch, GET /v1/Alerts/{Sid}. The list tells you which endpoint is failing and how often; the body costs one request per alert, which is why the script caps how many it pulls.
How far back can this look?
Thirty days. Alerts are retained for that long and no further, so a window longer than 30 days returns the same data under a more confident label. The script clamps the argument and says so rather than quietly returning less than you asked for.
Is an 11200 on a status callback as serious as one on my inbound handler?
No, and that is why they are separate states. A failing status callback loses bookkeeping you can re-read from Messages.json. A failing inbound handler loses the call or the message itself, and the mitigation there is a fallback URL, not a backfill.
Related field notes
- Messages that never reach a final state
- Twilio cannot open a connection to your webhook
- A number with no fallback URL
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 11200: HTTP retrieval failure — Twilio Docs
- Monitor Alert resource — Twilio Docs
- Message resource — Twilio Docs
- Messaging Service resource — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.