Diagnostic Twilio
30022 when sends outrun the throughput the carrier assigned
It only happens during the morning send. The same message, retried at lunchtime, delivers. So the bug report reads “intermittent SMS failures” and gets triaged as a Twilio problem, when 30022 is Twilio telling you precisely what it is: your combined send rate across the campaign went past a number the carrier assigned you, and that number is published on the campaign resource.
Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, bucket the rows by the minute in date_sent, and find the busiest minute. Then read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p and take the lowest MPS in rate_limits.
Peak minute divided by 60 against that number gives you the answer. Above it, throttle the producer. Below it and still getting 30022, the burst is inside a single second or piled onto one recipient, and more throughput will not fix either.
The problem in plain words
30022 is rate limiting, so it is load-shaped: invisible off-peak, unreproducible in staging, and cleared by a retry. That combination sends teams looking for a network fault. Meanwhile the actual ceiling is a per-carrier number sitting in rate_limits on the campaign, which almost nobody reads, because nothing in the send path ever refers to it.
The other half of the problem is what people do next. The assumed fix is more senders: add numbers to the pool and the throughput goes up. It does not. A 10DLC campaign's throughput is assigned at the campaign, derived from the brand's trust score, and shared across every number in the pool. Ten numbers under one campaign have exactly the throughput of one. Buying nine more numbers to fix 30022 is a monthly bill for nothing.
Why it happens
The ceiling is on a resource the send path never touches. Your code knows a Messaging Service SID and a destination. rate_limits lives two hops away, on the campaign attached to that service, and there is no field on the Message that says what limit it hit.
A minute average hides a one-second burst. The limit is messages per second. A job that fires 900 messages in the first four seconds of a minute is at 15 per second while averaging 15 per minute, which looks fine at every granularity except the one the carrier enforces. That is why “under the ceiling and still failing” is a real state and not a measurement error.
Throughput scales with trust, not with hardware. MPS toward AT&T, T-Mobile and Verizon is a function of the brand's vetting score and the campaign's use case. The lever is secondary vetting on the brand, not senders, not instances, and not a support ticket asking for more.
Per-recipient throttling wears the same code. Too many messages to one handset in quick succession also returns 30022, and it produces a very different fix — deduplicate the producer — from an account-wide overrun. The tell is that the failures pile onto a handful of to values instead of spreading.
The fix, as a flow
The comparison is one division against one number: the busiest minute over sixty, against the lowest MPS in rate_limits. Both halves live on resources the send path never reads.
How to fix it
Read the ceiling off the campaign, per carrier
GET /v1/Services/{ServiceSid}/Compliance/Usa2p and look at rate_limits. It is reported per carrier, and the shape has changed more than once, so walk it rather than indexing into it. Take the lowest MPS you find: your producer meets the tightest carrier first, and that is the number it has to respect.
Page the Messages list over hours, not weeks
This check needs resolution, not history. A day or two at PageSize=1000 is enough to catch a peak, and a wider window mostly costs you paging time. There is no ErrorCode filter on the resource, so read error_code yourself and keep 30022.
Bucket by minute, using date_sent
date_sent is RFC 2822. Parse it, floor to the minute, and count sends and 30022s in each bucket. The busiest bucket divided by 60 is your observed rate. Say plainly in the report that this is an average, because the next state depends on knowing it is.
Check whether the failures concentrate on one recipient first
If most of the 30022s land on a small number of to values, this is per-recipient throttling and the account-wide arithmetic is beside the point. Deduplicate the producer or collapse the messages; the campaign's MPS is not involved.
Compare, then pick the lever that matches
Observed peak above the ceiling: throttle the producer to the published MPS and queue the overflow on your side. Below the ceiling and still failing: your burst is sub-second, so smooth it rather than raise it. Either way, adding numbers to the pool changes nothing, because the limit is on the campaign.
Raise the ceiling only through the brand
More throughput comes from a higher trust score, which comes from secondary vetting on the brand. That is a request against BrandRegistrations/{BrandSid}/Vettings, not a change to the campaign or the pool, and it takes days rather than minutes — so the client-side queue is what carries you until then.
How to check it worked
Re-run over a window that includes a peak. The busiest minute should sit under the published ceiling, with no 30022 in the window.
python3 twilio_a2p_throughput_report.py --days 2
# clean no 30022 in this window. peak 42/min = 0.7/s against a ceiling of 4.5/s
The full code
Two reads: the campaign for the ceiling, the Messages list for the rate. The classifier takes the messages and a ceiling and nothing else, so every branch that matters — over the ceiling, under it and still failing, concentrated on one handset, no ceiling published at all — is decided offline. mps_ceiling() walks rate_limits instead of indexing into it, because that structure is reported per carrier and has not held one shape.
"""Compare an account's peak send rate against the MPS the carrier assigned the campaign.
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 logging
import os
import sys
from datetime import datetime, timedelta, timezone
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_a2p_throughput_report")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MSG = "https://messaging.twilio.com/v1"
THROTTLED = "30022"
def parse_when(value):
"""date_sent is RFC 2822, not ISO 8601. Returns epoch seconds, or None."""
if not value:
return None
try:
return parsedate_to_datetime(value).timestamp()
except (TypeError, ValueError):
return None
def is_throttled(message):
return str(message.get("error_code") or "") == THROTTLED
def mps_ceiling(rate_limits):
"""The lowest per-second ceiling anywhere in rate_limits, or None.
Walked rather than indexed. rate_limits is reported per carrier and the
layout has changed more than once, so this collects any positive number
under a key that mentions mps and takes the smallest: the producer meets
the tightest carrier first, and that is the number it has to respect.
"""
found = []
def walk(node, key=""):
if isinstance(node, dict):
for k, value in node.items():
walk(value, str(k))
elif isinstance(node, list):
for value in node:
walk(value, key)
elif isinstance(node, (int, float)) and not isinstance(node, bool):
if "mps" in key.lower() and node > 0:
found.append(float(node))
walk(rate_limits or {})
return min(found) if found else None
def per_minute(messages):
"""Bucket a window by the minute a message was sent.
Returns {epoch_minute: {"sent": n, "blocked": n}}. Rows with no usable
date_sent cannot be placed on the timeline and are skipped here; they are
still counted in the totals the caller reports.
"""
out = {}
for message in messages:
when = parse_when(message.get("date_sent"))
if when is None:
continue
bucket = out.setdefault(int(when // 60), {"sent": 0, "blocked": 0})
bucket["sent"] += 1
if is_throttled(message):
bucket["blocked"] += 1
return out
def busiest_recipient(messages):
"""(to, share) for the destination carrying the largest share of these rows."""
counts = {}
for message in messages:
to = str(message.get("to") or "")
counts[to] = counts.get(to, 0) + 1
if not counts:
return ("", 0.0)
to, count = max(counts.items(), key=lambda kv: (kv[1], kv[0]))
return (to, count / float(len(messages)))
def peak(buckets):
"""(epoch_minute, sends) for the busiest minute, or (None, 0)."""
if not buckets:
return (None, 0)
minute, counts = max(buckets.items(), key=lambda kv: (kv[1]["sent"], kv[0]))
return (minute, counts["sent"])
def verdict(messages, ceiling):
"""Classify a window against the campaign's published MPS. Pure.
Returns (state, detail). States: clean, per-recipient, no-ceiling-published,
over-the-ceiling, under-the-ceiling.
"""
_minute, sends = peak(per_minute(messages))
observed = sends / 60.0
blocked = [m for m in messages if is_throttled(m)]
if not blocked:
return ("clean",
"no 30022 in this window. Peak %d/min = %.2f/s against a "
"ceiling of %s." % (sends, observed,
"%.2f/s" % ceiling if ceiling else "unpublished"))
to, share = busiest_recipient(blocked)
if len(blocked) >= 4 and share >= 0.5:
return ("per-recipient",
"%d x 30022 and %.0f%% of them went to %s. That is per "
"destination throttling, not the campaign's MPS: collapse or "
"deduplicate the messages to that handset."
% (len(blocked), share * 100, to))
if ceiling is None:
return ("no-ceiling-published",
"%d x 30022, and rate_limits published no MPS to compare "
"against. Peak minute was %d sends = %.2f/s. Check the campaign "
"is VERIFIED before reading anything into that."
% (len(blocked), sends, observed))
if observed > ceiling:
return ("over-the-ceiling",
"%d x 30022. Peak minute averaged %.2f/s against a published "
"ceiling of %.2f/s. Throttle the producer to the ceiling and "
"queue the overflow; more numbers in the pool share the same "
"limit." % (len(blocked), observed, ceiling))
return ("under-the-ceiling",
"%d x 30022, but the peak minute averaged %.2f/s under a ceiling of "
"%.2f/s. The burst is inside a second rather than across the "
"minute, so smooth the send loop; raising the limit will not reach "
"it." % (len(blocked), observed, ceiling))
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_messages(session, account, since, limit):
"""Page Messages.json. No ErrorCode filter exists on this resource, so the
window and the page cap are the only ways to bound the read."""
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 list_v1(session, url, key, limit=1000):
"""Page a messaging.twilio.com list. meta.next_page_url is absolute."""
out = []
while url and len(out) < limit:
page = get(session, url, PageSize=50)
out.extend(page.get(key, []))
url = (page.get("meta") or {}).get("next_page_url")
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=2,
help="how far back to read the Messages list")
ap.add_argument("--max-messages", type=int, default=50000,
help="stop paging after this many messages")
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)
ceilings = {}
for service in list_v1(session, MSG + "/Services", "services"):
campaigns = list_v1(session,
"%s/Services/%s/Compliance/Usa2p" % (MSG, service["sid"]),
"compliance")
if campaigns:
ceilings[service["sid"]] = mps_ceiling(campaigns[0].get("rate_limits"))
since = (datetime.now(timezone.utc)
- timedelta(days=args.days)).strftime("%Y-%m-%d")
messages = list_messages(session, account, since, args.max_messages)
if not messages:
log.info("no messages sent since %s", since)
return 0
bad = 0
for service in sorted(ceilings):
rows = [m for m in messages
if str(m.get("messaging_service_sid") or "") == service]
if not rows:
continue
state, detail = verdict(rows, ceilings[service])
line = "%-21s %s %s" % (state, service, detail)
if state == "clean":
log.info(line)
continue
bad += 1
log.warning(line)
if state == "over-the-ceiling":
log.warning(" repair: throttle the producer to %.2f/s and queue the "
"overflow client side. To lift the ceiling, request "
"secondary vetting on the brand.", ceilings[service])
elif state == "under-the-ceiling":
log.warning(" repair: spread the send loop across the second rather "
"than firing the batch at once. The ceiling is already "
"above your minute average.")
elif state == "per-recipient":
log.warning(" repair: deduplicate the producer. Per destination "
"throttling is not raised by trust score or by senders.")
else:
log.warning(" repair: confirm campaign_status is VERIFIED, then "
"re-read rate_limits before changing the send rate.")
log.info("%d Messaging Service(s) with a campaign, %d over throughput",
len(ceilings), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Compare an account's peak send rate against the MPS the carrier assigned the campaign.
*
* 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 MSG = 'https://messaging.twilio.com/v1';
const THROTTLED = '30022';
/** date_sent is RFC 2822, not ISO 8601. Returns epoch seconds, or null. */
export function parseWhen(value) {
if (!value) return null;
const ms = Date.parse(value);
return Number.isNaN(ms) ? null : ms / 1000;
}
export function isThrottled(message) {
return String(message.error_code ?? '') === THROTTLED;
}
/**
* The lowest per-second ceiling anywhere in rate_limits, or null. Walked rather
* than indexed: rate_limits is reported per carrier and the layout has changed
* more than once, and the tightest carrier is the one the producer meets first.
*/
export function mpsCeiling(rateLimits) {
const found = [];
const walk = (node, key = '') => {
if (Array.isArray(node)) {
for (const value of node) walk(value, key);
} else if (node && typeof node === 'object') {
for (const [k, value] of Object.entries(node)) walk(value, String(k));
} else if (typeof node === 'number' && Number.isFinite(node)) {
if (key.toLowerCase().includes('mps') && node > 0) found.push(node);
}
};
walk(rateLimits ?? {});
return found.length ? Math.min(...found) : null;
}
/**
* Bucket a window by the minute a message was sent. Returns a Map of
* epoch minute to { sent, blocked }. Rows with no usable date_sent cannot be
* placed on the timeline and are skipped here.
*/
export function perMinute(messages) {
const out = new Map();
for (const message of messages) {
const when = parseWhen(message.date_sent);
if (when === null) continue;
const minute = Math.floor(when / 60);
const bucket = out.get(minute) ?? { sent: 0, blocked: 0 };
bucket.sent += 1;
if (isThrottled(message)) bucket.blocked += 1;
out.set(minute, bucket);
}
return out;
}
/** [to, share] for the destination carrying the largest share of these rows. */
export function busiestRecipient(messages) {
const counts = new Map();
for (const message of messages) {
const to = String(message.to ?? '');
counts.set(to, (counts.get(to) ?? 0) + 1);
}
if (counts.size === 0) return ['', 0];
let best = ['', 0];
for (const [to, count] of [...counts.entries()].sort()) {
if (count > best[1]) best = [to, count];
}
return [best[0], best[1] / messages.length];
}
/** [epochMinute, sends] for the busiest minute, or [null, 0]. */
export function peak(buckets) {
let best = [null, 0];
for (const [minute, counts] of [...buckets.entries()].sort((a, b) => a[0] - b[0])) {
if (counts.sent > best[1]) best = [minute, counts.sent];
}
return best;
}
/**
* Classify a window against the campaign's published MPS. Pure. Returns
* [state, detail] with state clean, per-recipient, no-ceiling-published,
* over-the-ceiling or under-the-ceiling.
*/
export function verdict(messages, ceiling) {
const [, sends] = peak(perMinute(messages));
const observed = sends / 60;
const blocked = messages.filter(isThrottled);
const ceilingText = ceiling ? `${ceiling.toFixed(2)}/s` : 'unpublished';
if (blocked.length === 0) {
return ['clean',
`no 30022 in this window. Peak ${sends}/min = ${observed.toFixed(2)}/s ` +
`against a ceiling of ${ceilingText}.`];
}
const [to, share] = busiestRecipient(blocked);
if (blocked.length >= 4 && share >= 0.5) {
return ['per-recipient',
`${blocked.length} x 30022 and ${(share * 100).toFixed(0)}% of them went ` +
`to ${to}. That is per destination throttling, not the campaign's MPS: ` +
'collapse or deduplicate the messages to that handset.'];
}
if (ceiling === null || ceiling === undefined) {
return ['no-ceiling-published',
`${blocked.length} x 30022, and rate_limits published no MPS to compare ` +
`against. Peak minute was ${sends} sends = ${observed.toFixed(2)}/s. ` +
'Check the campaign is VERIFIED before reading anything into that.'];
}
if (observed > ceiling) {
return ['over-the-ceiling',
`${blocked.length} x 30022. Peak minute averaged ${observed.toFixed(2)}/s ` +
`against a published ceiling of ${ceiling.toFixed(2)}/s. Throttle the ` +
'producer to the ceiling and queue the overflow; more numbers in the pool ' +
'share the same limit.'];
}
return ['under-the-ceiling',
`${blocked.length} x 30022, but the peak minute averaged ` +
`${observed.toFixed(2)}/s under a ceiling of ${ceiling.toFixed(2)}/s. The ` +
'burst is inside a second rather than across the minute, so smooth the send ' +
'loop; raising the limit will not reach it.'];
}
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 listMessages(auth, account, since, limit = 50000) {
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 listV1(auth, url, key, limit = 1000) {
const out = [];
let next = url;
while (next && out.length < limit) {
const page = await get(auth, next, { PageSize: 50 });
out.push(...(page[key] ?? []));
next = page.meta?.next_page_url ?? null;
}
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 ceilings = new Map();
for (const service of await listV1(auth, `${MSG}/Services`, 'services')) {
const campaigns = await listV1(auth,
`${MSG}/Services/${service.sid}/Compliance/Usa2p`, 'compliance');
if (campaigns.length) {
ceilings.set(service.sid, mpsCeiling(campaigns[0].rate_limits));
}
}
const days = Number(process.argv.includes('--days')
? process.argv[process.argv.indexOf('--days') + 1] : 2) || 2;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const messages = await listMessages(auth, account, since);
if (messages.length === 0) {
console.log(`no messages sent since ${since}`);
return;
}
let bad = 0;
for (const service of [...ceilings.keys()].sort()) {
const rows = messages.filter(
(m) => String(m.messaging_service_sid ?? '') === service);
if (rows.length === 0) continue;
const ceiling = ceilings.get(service);
const [state, detail] = verdict(rows, ceiling);
const line = `${state.padEnd(21)} ${service} ${detail}`;
if (state === 'clean') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'over-the-ceiling') {
console.warn(` repair: throttle the producer to ${ceiling.toFixed(2)}/s ` +
'and queue the overflow client side. To lift the ceiling, ' +
'request secondary vetting on the brand.');
} else if (state === 'under-the-ceiling') {
console.warn(' repair: spread the send loop across the second rather than ' +
'firing the batch at once. The ceiling is already above your ' +
'minute average.');
} else if (state === 'per-recipient') {
console.warn(' repair: deduplicate the producer. Per destination ' +
'throttling is not raised by trust score or by senders.');
} else {
console.warn(' repair: confirm campaign_status is VERIFIED, then re-read ' +
'rate_limits before changing the send rate.');
}
}
console.log(`${ceilings.size} Messaging Service(s) with a campaign, ${bad} ` +
'over throughput');
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 two states that look identical in a log and mean opposite things are the point: over the ceiling means throttle, under it means the burst is sub-second and the ceiling was never the problem. The mps_ceiling() cases matter just as much — a walker that indexed into rate_limits would return nothing the day the shape changes, and a report comparing against nothing reads as clean.
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime
from twilio_a2p_throughput_report import (mps_ceiling, per_minute, peak, verdict)
T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
RATE_LIMITS = {"carriers": [{"carrier": "att", "mps": 12},
{"carrier": "tmobile", "mps": 4.5},
{"carrier": "verizon", "mps": 30}]}
def at(seconds, **kw):
row = {"date_sent": format_datetime(T0 + timedelta(seconds=seconds)),
"to": "+1555000%04d" % seconds}
row.update(kw)
return row
def burst(count, *, second=0, **kw):
"""count messages inside one minute, so the minute bucket sees all of them."""
return [at(second + (i % 50), **kw) for i in range(count)]
def test_a_window_with_no_30022_is_clean():
state, detail = verdict(burst(120), 4.5)
assert state == "clean"
assert "4.50/s" in detail
def test_a_peak_above_the_ceiling_says_throttle():
rows = burst(500) + burst(6, error_code=30022)
state, detail = verdict(rows, 4.5)
assert state == "over-the-ceiling"
assert "8.43/s" in detail and "4.50/s" in detail
def test_failures_under_the_ceiling_are_a_sub_second_burst():
# 60 sends in the minute is 1/s on average, well under 4.5, and it still
# 30022s: the batch went out inside one second.
rows = burst(60) + burst(5, error_code=30022)
state, detail = verdict(rows, 4.5)
assert state == "under-the-ceiling"
assert "inside a second" in detail
def test_failures_piled_on_one_handset_are_per_recipient_throttling():
rows = burst(60) + [at(i, to="+15550009999", error_code=30022) for i in range(6)]
state, detail = verdict(rows, 4.5)
assert state == "per-recipient"
assert "+15550009999" in detail
def test_no_published_mps_is_reported_rather_than_compared():
state, detail = verdict(burst(60) + burst(5, error_code=30022), None)
assert state == "no-ceiling-published"
assert "VERIFIED" in detail
def test_the_lowest_carrier_mps_is_the_one_that_binds():
assert mps_ceiling(RATE_LIMITS) == 4.5
def test_an_absent_or_shapeless_rate_limits_yields_no_ceiling():
assert mps_ceiling(None) is None
assert mps_ceiling({"carriers": [{"carrier": "att", "daily_cap": 200000}]}) is None
def test_buckets_are_minutes_not_seconds():
buckets = per_minute([at(0), at(30), at(59), at(60)])
assert len(buckets) == 2
assert peak(buckets)[1] == 3
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mpsCeiling, perMinute, peak, verdict }
from './twilio-a2p-throughput-report.mjs';
const T0 = Date.UTC(2026, 7, 24, 12, 0, 0);
const RATE_LIMITS = { carriers: [{ carrier: 'att', mps: 12 },
{ carrier: 'tmobile', mps: 4.5 }, { carrier: 'verizon', mps: 30 }] };
const at = (seconds, extra = {}) => ({
date_sent: new Date(T0 + seconds * 1000).toUTCString(),
to: `+1555000${String(seconds).padStart(4, '0')}`,
...extra,
});
/** count messages inside one minute, so the minute bucket sees all of them. */
const burst = (count, extra = {}) =>
Array.from({ length: count }, (_, i) => at(i % 50, extra));
test('a window with no 30022 is clean', () => {
const [state, detail] = verdict(burst(120), 4.5);
assert.equal(state, 'clean');
assert.match(detail, /4\.50\/s/);
});
test('a peak above the ceiling says throttle', () => {
const [state, detail] = verdict([...burst(500),
...burst(6, { error_code: 30022 })], 4.5);
assert.equal(state, 'over-the-ceiling');
assert.match(detail, /8\.43\/s/);
assert.match(detail, /4\.50\/s/);
});
test('failures under the ceiling are a sub second burst', () => {
const [state, detail] = verdict([...burst(60),
...burst(5, { error_code: 30022 })], 4.5);
assert.equal(state, 'under-the-ceiling');
assert.match(detail, /inside a second/);
});
test('failures piled on one handset are per recipient throttling', () => {
const piled = Array.from({ length: 6 },
(_, i) => at(i, { to: '+15550009999', error_code: 30022 }));
const [state, detail] = verdict([...burst(60), ...piled], 4.5);
assert.equal(state, 'per-recipient');
assert.match(detail, /\+15550009999/);
});
test('no published mps is reported rather than compared', () => {
const [state, detail] = verdict([...burst(60),
...burst(5, { error_code: 30022 })], null);
assert.equal(state, 'no-ceiling-published');
assert.match(detail, /VERIFIED/);
});
test('the lowest carrier mps is the one that binds', () => {
assert.equal(mpsCeiling(RATE_LIMITS), 4.5);
});
test('an absent or shapeless rate_limits yields no ceiling', () => {
assert.equal(mpsCeiling(null), null);
assert.equal(mpsCeiling({ carriers: [{ carrier: 'att', daily_cap: 200000 }] }), null);
});
test('buckets are minutes not seconds', () => {
const buckets = perMinute([at(0), at(30), at(59), at(60)]);
assert.equal(buckets.size, 2);
assert.equal(peak(buckets)[1], 3);
});
FAQ
Will adding more phone numbers raise my throughput?
No. A 10DLC campaign's MPS is assigned at the campaign and shared across every number in its sender pool, so ten numbers under one campaign send at the rate of one. More senders help with sticky sender behaviour and with per-number carrier limits on other channels, not with 30022.
Where is my actual throughput number?
In rate_limits on the campaign, returned by GET /v1/Services/{ServiceSid}/Compliance/Usa2p. It is reported per carrier, so read all of them and plan against the lowest: your producer hits the tightest carrier first and that is where the 30022 comes from.
Why does the script say I am under the ceiling and still failing?
Because the limit is per second and the script can only measure per minute from date_sent. A job that fires its whole batch in the first two seconds of a minute averages far below the ceiling and still exceeds it while it runs. That state is a signal to smooth the send loop rather than ask for more throughput.
How do I actually get a higher ceiling?
Through the brand, not the campaign. Throughput toward AT&T, T-Mobile and Verizon scales with the brand's vetting score, so the lever is secondary vetting on the BrandRegistration. That takes days, which is why the client-side queue is the part you build first.
Is 30022 the same as 30001?
No. 30022 is the carrier refusing the send because the campaign's assigned throughput was exceeded. 30001 is Twilio's own outbound queue overflowing because the producer outran the sender for long enough to fill it. They often appear together under the same load and they have different fixes.
Related field notes
- 30001 when the send loop outruns the sender
- No trust score, so throughput stays floored
- 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 30022: US A2P 10DLC rate limit exceeded — Twilio Docs
- UsAppToPerson resource — Twilio Docs
- Message resource — Twilio Docs
- Scaling, queueing and latency — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.