Skip to content

Diagnostic Twilio

a suspended campaign returns 30033 and the sends keep coming

The suspension email went to the account owner's address, which forwards to a distribution list nobody reads. The send worker knows nothing about it: it dequeues, it calls the API, it gets a Message back with status undelivered and error_code 30033, and it retries. Four days later somebody notices, and by then the only question that matters — when did this start, and what has the code been doing since — is answerable only from the Messages list.

Read-only key Python and Node.js Tests included
Red tag with "sale 50%" text on black background
Photo by Sasun Bughdaryan on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and keep the rows where error_code is 30033. The oldest of those dates the suspension. Everything after it is the interesting part: sends that were refused, and any sender that started carrying this traffic only after the onset.

Confirm the state with GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p and read campaign_status. But the campaign resource cannot tell you when the suspension began or whether your producer is still pushing into it, and those are the two facts an incident needs.

The problem in plain words

30033 is a per-message error, and messages are the only place it is recorded. The campaign resource shows campaign_status as SUSPENDED, which is a present-tense fact with no timestamp on it. So the first questions asked in the incident channel — when did we start failing, how many customers did we miss, is it still happening — have no answer on the compliance resources at all.

The second problem is the response. Because the failure is per-message and looks like a sender problem, the instinct is to move the traffic: point the worker at a different Messaging Service, or a toll-free number, or a second campaign that was registered for something else. That instinct is the reason this note exists. Rerouting suspended traffic is the specific behaviour that turns a campaign suspension into an account termination, and it is visible in the Messages list as a sender that appears for the first time after the onset.

Carriersuspendspolicy review, noAPI eventNotice emailedto an unread aliasWorker sendsanyway30033 on everymessageRetriesmultiply itone customer,three rowsTrafficreroutedthe escalatingmoveretry
The suspension notice goes to the account owner by email. The worker learns about it as an error code and treats it like any other.

Why it happens

The error arrives at the worker, not at a human. An undelivered Message with an error code is a normal outcome for a send loop. It is logged at whatever level undelivered messages are logged at, which on most systems is the level nobody alerts on, and 30033 looks exactly like 30007 or 30003 from inside that loop.

Nothing in the API timestamps the suspension. campaign_status has no date_updated you can trust for this, and the campaign carries no history. The first 30033 in the Messages list is the closest thing to an onset time that exists, and it is only as good as the window you paged.

Retries make the number look worse than the outage. A worker that retries three times turns one blocked customer into three 30033 rows. Counting rows overstates the impact; counting distinct to values gives the number you actually need for the customer comms.

The dangerous fix looks like the obvious fix. Sending the same traffic from a different sender is one config change and it appears to work for a few hours. It is also the thing Twilio and the carriers explicitly watch for, and the escalation from it is not another suspension.

The fix, as a flow

The classifier splits the window at the first 30033 and reads the half after it, because the useful question is not whether the campaign is suspended but what the code did once it was.

First 30033 in the windowthe only onset time there isNo 30033 at allnothing suspended hereRefusals stoppedsending halted, ticket openStill pushingevery send refused and billedNew sender after onsetundo it before Support
A window that opens on a 30033 cannot tell a new sender from an old one, so the reroute check is skipped rather than guessed at.

How to fix it

Page the Messages list over a window wider than the outage

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. There is no ErrorCode filter on this resource, so the filtering happens in your code. Make the window wide enough that it opens on healthy traffic: if the oldest row in the window is already a 30033, the onset is outside it and the report cannot date anything.

Sort by date_sent, which is RFC 2822

date_sent comes back as Mon, 24 Aug 2026 12:00:00 +0000, not ISO 8601. A parser that raises on it throws away the window, and a parser that raises on one malformed row throws away the rest. Parse leniently and keep the rows you could not date rather than dropping them silently.

Take the first 30033 as the onset and split there

Everything before it is what normal looked like: which Messaging Services and which from numbers were carrying traffic. Everything after it is the response. That split is the only structure in this data, and both halves are needed — the reroute check is meaningless without knowing what was there before.

Count refusals after the onset, and count distinct recipients

Rows after the onset that still return 30033 are sends the producer made knowing nothing had changed. Report both the row count and the distinct to count: the first is how much the send loop wasted, the second is how many people did not get their message.

Flag any sender that appears only after the onset

A messaging_service_sid or a from that carried nothing before the first 30033 and carries traffic after it is a reroute. Report it loudly and separately. This is the one finding here where the correct action is to undo something rather than to wait.

Confirm the status, then take it to Support and stop the producer

GET /v1/Services/{ServiceSid}/Compliance/Usa2p to confirm campaign_status is SUSPENDED, and check the brand above it before assuming the campaign is where the decision was made. There is no API repair for either. Remediate the traffic that caused it, reply to the suspension notice with evidence, and in the meantime stop sending rather than moving the sends.

How to check it worked

Re-run the script over the same window. The report should read clean, and no sender should be flagged as having appeared after an onset.

python3 twilio_a2p_campaign_suspension_report.py --days 14
# clean  0 x 30033 across 8412 message(s) since 2026-08-16

The full code

One paginated read of the Messages list, then one confirming read per affected Messaging Service. Everything that matters happens in verdict(), which takes a list of message dicts and returns a state: the onset split, the refusal count after it and the reroute check are all decided without a network, because those are the parts worth pinning with tests.

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_campaign_suspension_report.py
"""Date a 10DLC campaign suspension from the Messages list and say what happened next.

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_campaign_suspension_report")

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MSG = "https://messaging.twilio.com/v1"

SUSPENDED = "30033"


def parse_when(value):
    """date_sent is RFC 2822, not ISO 8601. Returns epoch seconds, or None.

    Lenient on purpose. One malformed row should cost one row, not the window,
    and the rows that matter most here are the oldest ones in it.
    """
    if not value:
        return None
    try:
        return parsedate_to_datetime(value).timestamp()
    except (TypeError, ValueError):
        return None


def sender_key(message):
    """What carried this message.

    The Messaging Service wins when both are set, because the campaign is
    attached to the service rather than to the number.
    """
    for k in ("messaging_service_sid", "from"):
        value = message.get(k)
        if value:
            return str(value)
    return "unknown"


def ordered(messages):
    """Oldest first. Rows with no usable date_sent keep their original order at
    the end rather than being dropped, so a bad timestamp cannot hide a 30033."""
    keyed = [(parse_when(m.get("date_sent")), i, m) for i, m in enumerate(messages)]
    dated = sorted(k for k in keyed if k[0] is not None)
    undated = [k for k in keyed if k[0] is None]
    return [m for _w, _i, m in dated] + [m for _w, _i, m in undated]


def is_suspended(message):
    return str(message.get("error_code") or "") == SUSPENDED


def recipients(messages):
    """Distinct to values. Retries turn one blocked customer into three rows, so
    this is the number that belongs in the customer comms."""
    return len({str(m.get("to") or "") for m in messages if m.get("to")})


def verdict(messages):
    """Classify a window by what the sends did after the first 30033. Pure.

    Returns (state, detail). States: clean, rerouted, still-pushing, stopped.
    """
    rows = ordered(messages)
    blocked = [m for m in rows if is_suspended(m)]
    if not blocked:
        return ("clean", "no 30033 in this window.")

    first = next(i for i, m in enumerate(rows) if is_suspended(m))
    after = rows[first + 1:]
    later = [m for m in after if is_suspended(m)]

    partial = ""
    seen_before = None
    if first == 0:
        partial = (" The window opens on a 30033, so the suspension started "
                   "before it: widen --days before reading anything into which "
                   "senders look new.")
    else:
        seen_before = {sender_key(m) for m in rows[:first]}

    if seen_before is not None:
        fresh = []
        for m in after:
            key = sender_key(m)
            if key not in seen_before and not is_suspended(m) and key not in fresh:
                fresh.append(key)
        if fresh:
            return ("rerouted",
                    "%d x 30033 over %d recipient(s), and then %s started "
                    "carrying traffic that had never used it before. Moving "
                    "suspended traffic to another sender is the response that "
                    "escalates to account termination."
                    % (len(blocked), recipients(blocked), ", ".join(fresh)))

    if later:
        return ("still-pushing",
                "%d x 30033 over %d recipient(s), %d of them after the first. "
                "The producer has not been told to stop and every one of those "
                "is a send that was refused.%s"
                % (len(blocked), recipients(blocked), len(later), partial))

    return ("stopped",
            "%d x 30033 over %d recipient(s), and nothing refused since. The "
            "sending stopped; the suspension is open until Support clears it.%s"
            % (len(blocked), recipients(blocked), partial))


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. There is no Status or ErrorCode filter on this
    resource, so the window and the page cap are the only ways to bound it."""
    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=14,
                    help="how far back to read the Messages list")
    ap.add_argument("--max-messages", type=int, default=20000,
                    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)

    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

    state, detail = verdict(messages)
    line = "%-14s %s  %d message(s) since %s" % (state, detail, len(messages), since)
    if state == "clean":
        log.info(line)
        return 0
    log.warning(line)

    blocked = [m for m in messages if is_suspended(m)]
    services = set()
    for sender in sorted({sender_key(m) for m in blocked}):
        count = len([m for m in blocked if sender_key(m) == sender])
        log.warning("  %s  %d x 30033", sender, count)
        if sender.startswith("MG"):
            services.add(sender)

    for service in sorted(services):
        campaigns = list_v1(session, "%s/Services/%s/Compliance/Usa2p" % (MSG, service),
                            "compliance")
        status = (campaigns[0].get("campaign_status") if campaigns else None)
        log.warning("  %s  campaign_status=%s", service, status or "no campaign")

    log.warning("  repair: none by API. Stop the producer, remediate the traffic "
                "named in the suspension notice and reply to Twilio Support with "
                "evidence. Check the brand above the campaign before assuming the "
                "decision was made at the campaign.")
    if state == "rerouted":
        log.warning("  repair: undo the reroute first. Sending the same traffic "
                    "from another sender escalates a campaign suspension to the "
                    "account.")
    return 1


if __name__ == "__main__":
    sys.exit(main())
twilio-a2p-campaign-suspension-report.mjs
/**
 * Date a 10DLC campaign suspension from the Messages list and say what happened next.
 *
 * 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 SUSPENDED = '30033';

/**
 * date_sent is RFC 2822, not ISO 8601. Returns epoch seconds, or null. Lenient
 * on purpose: one malformed row should cost one row, not the window.
 */
export function parseWhen(value) {
  if (!value) return null;
  const ms = Date.parse(value);
  return Number.isNaN(ms) ? null : ms / 1000;
}

/**
 * What carried this message. The Messaging Service wins when both are set,
 * because the campaign is attached to the service rather than to the number.
 */
export function senderKey(message) {
  for (const k of ['messaging_service_sid', 'from']) {
    if (message[k]) return String(message[k]);
  }
  return 'unknown';
}

/** Oldest first. Rows with no usable date_sent keep their order at the end. */
export function ordered(messages) {
  const keyed = messages.map((m, i) => [parseWhen(m.date_sent), i, m]);
  const dated = keyed.filter(([w]) => w !== null)
    .sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
  const undated = keyed.filter(([w]) => w === null);
  return [...dated, ...undated].map(([, , m]) => m);
}

export function isSuspended(message) {
  return String(message.error_code ?? '') === SUSPENDED;
}

/** Distinct to values: retries turn one blocked customer into three rows. */
export function recipients(messages) {
  return new Set(messages.filter((m) => m.to).map((m) => String(m.to))).size;
}

/**
 * Classify a window by what the sends did after the first 30033. Pure. Returns
 * [state, detail] with state clean, rerouted, still-pushing or stopped.
 */
export function verdict(messages) {
  const rows = ordered(messages);
  const blocked = rows.filter(isSuspended);
  if (blocked.length === 0) return ['clean', 'no 30033 in this window.'];

  const first = rows.findIndex(isSuspended);
  const after = rows.slice(first + 1);
  const later = after.filter(isSuspended);

  let partial = '';
  let seenBefore = null;
  if (first === 0) {
    partial = ' The window opens on a 30033, so the suspension started before ' +
      'it: widen --days before reading anything into which senders look new.';
  } else {
    seenBefore = new Set(rows.slice(0, first).map(senderKey));
  }

  if (seenBefore !== null) {
    const fresh = [];
    for (const m of after) {
      const key = senderKey(m);
      if (!seenBefore.has(key) && !isSuspended(m) && !fresh.includes(key)) {
        fresh.push(key);
      }
    }
    if (fresh.length) {
      return ['rerouted',
        `${blocked.length} x 30033 over ${recipients(blocked)} recipient(s), ` +
        `and then ${fresh.join(', ')} started carrying traffic that had never ` +
        'used it before. Moving suspended traffic to another sender is the ' +
        'response that escalates to account termination.'];
    }
  }

  if (later.length) {
    return ['still-pushing',
      `${blocked.length} x 30033 over ${recipients(blocked)} recipient(s), ` +
      `${later.length} of them after the first. The producer has not been told ` +
      `to stop and every one of those is a send that was refused.${partial}`];
  }

  return ['stopped',
    `${blocked.length} x 30033 over ${recipients(blocked)} recipient(s), and ` +
    'nothing refused since. The sending stopped; the suspension is open until ' +
    `Support clears it.${partial}`];
}

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 = 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 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 days = Number(process.argv.includes('--days')
    ? process.argv[process.argv.indexOf('--days') + 1] : 14) || 14;
  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;
  }

  const [state, detail] = verdict(messages);
  const line = `${state.padEnd(14)} ${detail}  ${messages.length} message(s) ` +
               `since ${since}`;
  if (state === 'clean') { console.log(line); return; }
  console.warn(line);

  const blocked = messages.filter(isSuspended);
  const services = new Set();
  for (const sender of [...new Set(blocked.map(senderKey))].sort()) {
    const count = blocked.filter((m) => senderKey(m) === sender).length;
    console.warn(`  ${sender}  ${count} x 30033`);
    if (sender.startsWith('MG')) services.add(sender);
  }

  for (const service of [...services].sort()) {
    const campaigns = await listV1(auth,
      `${MSG}/Services/${service}/Compliance/Usa2p`, 'compliance');
    const status = campaigns[0]?.campaign_status ?? null;
    console.warn(`  ${service}  campaign_status=${status ?? 'no campaign'}`);
  }

  console.warn('  repair: none by API. Stop the producer, remediate the traffic ' +
               'named in the suspension notice and reply to Twilio Support with ' +
               'evidence. Check the brand above the campaign before assuming the ' +
               'decision was made at the campaign.');
  if (state === 'rerouted') {
    console.warn('  repair: undo the reroute first. Sending the same traffic ' +
                 'from another sender escalates a campaign suspension to the ' +
                 'account.');
  }
  process.exitCode = 1;
}

// 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 states and one edge that decides which of them is even reachable. The reroute case is the one worth reading twice: it only fires when there was healthy traffic before the onset, so a window that opens on a 30033 deliberately refuses to guess. The rest pin the RFC 2822 parsing, since a strict parser here silently reports every account as clean.

test_twilio_a2p_campaign_suspension_report.py
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime

from twilio_a2p_campaign_suspension_report import (ordered, recipients,
                                                   sender_key, verdict)

T0 = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)


def at(seconds, **kw):
    """One Message row, dated the way Twilio dates them."""
    row = {"date_sent": format_datetime(T0 + timedelta(seconds=seconds)),
           "messaging_service_sid": "MG1", "to": "+15550000001"}
    row.update(kw)
    return row


def test_a_window_with_no_30033_is_clean():
    state, _ = verdict([at(0), at(60, error_code=30007)])
    assert state == "clean"


def test_sends_continuing_after_the_onset_are_counted_separately():
    state, detail = verdict([at(0), at(60, error_code=30033),
                             at(120, error_code=30033),
                             at(180, error_code=30033)])
    assert state == "still-pushing"
    assert "2 of them after the first" in detail


def test_traffic_that_stopped_after_the_onset_is_its_own_state():
    state, detail = verdict([at(0), at(60, error_code=30033), at(120)])
    assert state == "stopped"
    assert "open until Support" in detail


def test_a_sender_that_appears_only_after_the_onset_is_a_reroute():
    # The dangerous one. MG2 carried nothing before the suspension and is
    # carrying the same traffic afterwards.
    state, detail = verdict([at(0), at(60, error_code=30033),
                             at(120, messaging_service_sid="MG2")])
    assert state == "rerouted"
    assert "MG2" in detail
    assert "termination" in detail


def test_a_window_opening_on_a_30033_refuses_to_guess_at_reroutes():
    # With nothing before the onset every sender looks new, so the check is
    # skipped and the report says to widen the window instead.
    state, detail = verdict([at(0, error_code=30033),
                             at(60, messaging_service_sid="MG2"),
                             at(120, error_code=30033)])
    assert state == "still-pushing"
    assert "widen --days" in detail


def test_retries_are_counted_as_rows_and_recipients_separately():
    rows = [at(0), at(60, error_code=30033), at(70, error_code=30033),
            at(80, error_code=30033)]
    assert recipients(rows[1:]) == 1
    assert "3 x 30033 over 1 recipient(s)" in verdict(rows)[1]


def test_the_messaging_service_wins_over_the_from_number():
    assert sender_key({"messaging_service_sid": "MG1", "from": "+15550001"}) == "MG1"
    assert sender_key({"from": "+15550001"}) == "+15550001"


def test_an_unparseable_date_keeps_its_row_instead_of_dropping_it():
    rows = ordered([{"date_sent": "not a date", "error_code": 30033}, at(0)])
    assert len(rows) == 2
    assert verdict(rows)[0] != "clean"
twilio-a2p-campaign-suspension-report.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ordered, recipients, senderKey, verdict }
  from './twilio-a2p-campaign-suspension-report.mjs';

const T0 = Date.UTC(2026, 7, 24, 12, 0, 0);

/** One Message row, dated the way Twilio dates them. */
const at = (seconds, extra = {}) => ({
  date_sent: new Date(T0 + seconds * 1000).toUTCString(),
  messaging_service_sid: 'MG1',
  to: '+15550000001',
  ...extra,
});

test('a window with no 30033 is clean', () => {
  assert.equal(verdict([at(0), at(60, { error_code: 30007 })])[0], 'clean');
});

test('sends continuing after the onset are counted separately', () => {
  const [state, detail] = verdict([at(0), at(60, { error_code: 30033 }),
    at(120, { error_code: 30033 }), at(180, { error_code: 30033 })]);
  assert.equal(state, 'still-pushing');
  assert.match(detail, /2 of them after the first/);
});

test('traffic that stopped after the onset is its own state', () => {
  const [state, detail] = verdict([at(0), at(60, { error_code: 30033 }), at(120)]);
  assert.equal(state, 'stopped');
  assert.match(detail, /open until Support/);
});

test('a sender that appears only after the onset is a reroute', () => {
  const [state, detail] = verdict([at(0), at(60, { error_code: 30033 }),
    at(120, { messaging_service_sid: 'MG2' })]);
  assert.equal(state, 'rerouted');
  assert.match(detail, /MG2/);
  assert.match(detail, /termination/);
});

test('a window opening on a 30033 refuses to guess at reroutes', () => {
  const [state, detail] = verdict([at(0, { error_code: 30033 }),
    at(60, { messaging_service_sid: 'MG2' }), at(120, { error_code: 30033 })]);
  assert.equal(state, 'still-pushing');
  assert.match(detail, /widen --days/);
});

test('retries are counted as rows and recipients separately', () => {
  const rows = [at(0), at(60, { error_code: 30033 }), at(70, { error_code: 30033 }),
    at(80, { error_code: 30033 })];
  assert.equal(recipients(rows.slice(1)), 1);
  assert.match(verdict(rows)[1], /3 x 30033 over 1 recipient\(s\)/);
});

test('the messaging service wins over the from number', () => {
  assert.equal(senderKey({ messaging_service_sid: 'MG1', from: '+15550001' }), 'MG1');
  assert.equal(senderKey({ from: '+15550001' }), '+15550001');
});

test('an unparseable date keeps its row instead of dropping it', () => {
  const rows = ordered([{ date_sent: 'not a date', error_code: 30033 }, at(0)]);
  assert.equal(rows.length, 2);
  assert.notEqual(verdict(rows)[0], 'clean');
});

FAQ

Can I get the exact time the campaign was suspended?

Not from the compliance resources. campaign_status is a present-tense field with no history, and the campaign object carries no suspension timestamp. The oldest 30033 in the Messages list is the closest thing that exists, and it is only as accurate as the window you paged, which is why the script says so when the window opens on a 30033.

Is 30033 always the campaign rather than the brand?

No. A suspended brand suspends every campaign under it, and the send-side symptom is identical. The layer question is answered by joining BrandRegistrations to the campaigns on brand_registration_sid, which is a separate check with a separate note.

Why does the script care which sender carried traffic after the onset?

Because rerouting suspended traffic through a different Messaging Service, number or campaign is the specific response that escalates a campaign suspension to account termination. It is a config change that looks like a fix, and the Messages list is the only place it is visible.

Should I keep sending while I wait for Support?

No. Every send into a suspended campaign is refused and still consumes your worker's retries. Stop the producer for the affected sender, and if the traffic is genuinely urgent use a channel that was already provisioned for it rather than one you switch to today.

Why not just filter the Messages list by error code?

There is no ErrorCode filter and no Status filter on that resource. The documented parameters are To, From, DateSent, DateSent<, DateSent> and paging. Every 30033 report pages the window and filters client-side, which is the reason so few accounts have one.

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.