Skip to content

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.

Read-only key Python and Node.js Tests included
Woman standing and holding smartphones
Photo by Clay Banks on Unsplash
The short answer

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.

Batch job fireswhole queue atonceCombined MPSspikesshared across thepoolCarrier refuses30022 on theoverflowRetry atlunchtimedelivers, looksrandomMore numbersboughtsame campaign,same limit
Off peak everything delivers, so the failure is filed as intermittent and the ceiling that caused it is never looked up.

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.

Peak minute over sixtyagainst the lowest carrier MPSNo 30022 in the windowthe rate fitsOver the ceilingthrottle, then vet the brandUnder it, still failingsmooth the send loopPiled on one handsetper destination, deduplicate
A minute average cannot see a one second burst, which is why under the ceiling and still failing is a real answer rather than a rounding error.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_a2p_throughput_report.py
"""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())
twilio-a2p-throughput-report.mjs
/**
 * 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.

test_twilio_a2p_throughput_report.py
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
twilio-a2p-throughput-report.test.mjs
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

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.