Diagnostic Twilio
a SUSPENDED brand suspends every campaign underneath it
Nothing was deployed. Nothing was edited. Yesterday's traffic delivered and today every US message comes back 30033 with campaign_status reading SUSPENDED. Editing the campaign returns 21729, editing the brand returns 21731, and the reason both refuse is that the suspension is not on the campaign at all.
Read GET https://messaging.twilio.com/v1/a2p/BrandRegistrations and look for status == "SUSPENDED". Then, for each Messaging Service, read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p and join on brand_registration_sid. A campaign whose campaign_status is SUSPENDED under a brand that is also SUSPENDED is a cascade, and the campaign is not the thing to fix.
The join is the whole check. Reading either resource alone gives you a suspended object with no way to tell whether it is the cause or the consequence, and those two readings lead to completely different weeks of work.
The problem in plain words
The symptom arrives at the campaign level and the cause lives at the brand level, so the natural first move is the wrong one. A team sees SUSPENDED on the campaign, reads the campaign's errors[], finds nothing new, and starts rewriting the description and the message samples. Every one of those edits is refused with 21729, which is a permissions-shaped error rather than a diagnostic one, and none of them would have helped anyway.
Meanwhile the suspension is doing something the campaign view cannot show: it applies to every campaign attached to that brand. An account running one brand with four Messaging Services loses all four at once, and each one presents as its own outage. Without the join, four separate investigations start in parallel on four separate services for one cause.
Why it happens
The error code is emitted per message, at the campaign. 30033 is documented as campaign suspended, so the code itself points down rather than up. Nothing in the message record mentions a brand, and the brand SID is not in the send path at all.
Both write paths refuse, with different codes. 21731 on the brand and 21729 on the campaign. Two different numbers for one underlying condition reads like two unrelated problems, and neither says the word suspension.
The cascade is not always visible in the same instant. The brand can read SUSPENDED while campaigns beneath it still read VERIFIED, because the campaign resource has not caught up. Sends fail regardless. A script that only looks for suspended campaigns misses the window where the brand is the only field telling the truth.
Suspension is a compliance decision, not a fault. Campaign-to-traffic mismatch, complaint rate, prohibited content. There is no field to set and no resubmit that clears it, so the only useful output is an accurate statement of which layer was suspended and which campaigns it took with it.
The fix, as a flow
The script reads two resources and joins them on brand_registration_sid, because a suspended object on its own cannot tell you whether it is the cause or the consequence.
How to fix it
List the brands and note every SUSPENDED sid
GET https://messaging.twilio.com/v1/a2p/BrandRegistrations, items under data. Keep the BN… SIDs of anything at SUSPENDED; those are the keys the campaigns will be joined on.
Read every service's campaign, not only the ones that are failing
GET /v1/Services/{ServiceSid}/Compliance/Usa2p per Messaging Service. A brand suspension reaches services that nobody has reported yet, and finding them in the same pass is the difference between one incident and four.
Join on brand_registration_sid
Each campaign object names the brand it was registered under. That field is the only link between the code your sends return and the object that actually changed state, and it is why this check reads both resources rather than one.
Report the direction, not just the status
A suspended campaign under a healthy brand is a campaign-level suspension and belongs with the campaign notes. A suspended campaign under a suspended brand is a cascade. A suspended brand with campaigns still reading VERIFIED is the same cascade, seen before the campaign resource updated. Three findings, three different next steps.
Take the brand to Support, and leave the campaigns alone
There is no API repair. Resolve the brand suspension with Twilio Support first; campaigns stay suspended until the brand clears, so editing or recreating them achieves nothing. Moving the same traffic onto a new brand or a new campaign is the response that risks the account itself.
How to check it worked
Re-run the script. No brand should be SUSPENDED, and no campaign should be suspended under one.
python3 twilio_a2p_brand_suspension_audit.py
# 2 brand(s), 6 campaign(s), 0 suspended
The full code
Two reads and a join: the brand list once, then the campaign subresource per Messaging Service. The classifier is pure and takes a brand plus the campaigns already attributed to it, so the interesting part — which layer was suspended, and therefore which way the causation runs — is decided in a function with no network in it.
"""Report A2P brand suspensions and the campaigns they take down with them.
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
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_a2p_brand_suspension_audit")
MSG = "https://messaging.twilio.com/v1"
SUSPENDED = "SUSPENDED"
def attached(campaigns, brand_sid):
"""The campaigns registered under one brand. Pure.
brand_registration_sid on the campaign is the only link between the 30033
your sends return and the object that actually changed state.
"""
want = str(brand_sid or "").strip()
if not want:
return []
return [c for c in campaigns
if str(c.get("brand_registration_sid") or "").strip() == want]
def campaign_statuses(campaigns):
"""Upper-cased campaign_status for each campaign, in order. Pure."""
return [str(c.get("campaign_status") or "").upper() for c in campaigns]
def verdict(brand, campaigns):
"""Classify one brand together with the campaigns attributed to it. Pure.
The states differ by the direction of causation, not by which fields say
SUSPENDED, because that direction is the only thing that changes what
anybody should do next.
Returns (state, detail).
"""
status = str(brand.get("status") or "").upper()
statuses = campaign_statuses(campaigns)
hit = sum(1 for s in statuses if s == SUSPENDED)
if status == SUSPENDED:
if not campaigns:
return ("brand-suspended-no-campaign",
"brand is SUSPENDED with no campaign attached. Nothing is "
"sending, and nothing can be registered under it.")
if hit == len(statuses):
return ("cascade",
"brand is SUSPENDED and all %d campaign(s) under it are "
"SUSPENDED too. Every US send on them returns 30033, and "
"the campaign is not the thing that changed." % len(statuses))
if hit:
return ("cascade-partial",
"brand is SUSPENDED; %d of %d campaign(s) already read "
"SUSPENDED. The rest are on the same brand and will follow."
% (hit, len(statuses)))
return ("cascade-not-yet-visible",
"brand is SUSPENDED while all %d campaign(s) still read %s. "
"Sends fail regardless: the brand is the field telling the "
"truth here." % (len(statuses), ", ".join(sorted(set(statuses)))))
if hit:
return ("campaign-suspended-only",
"%d campaign(s) SUSPENDED under a brand that is %s. This one is "
"campaign level, so the campaign's errors[] is where the reason "
"is." % (hit, status or "unset"))
if status == "APPROVED":
return ("clean",
"brand is APPROVED and none of its %d campaign(s) are suspended."
% len(statuses))
return ("brand-not-usable",
"brand status is %s, which is not a suspension. Nothing here is "
"being taken down; it never came up." % (status or "unset"))
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_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("--max-services", type=int, default=200)
ap.add_argument("--max-brands", type=int, default=500)
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)
brands = list_v1(session, MSG + "/a2p/BrandRegistrations", "data",
args.max_brands)
if not brands:
log.info("no A2P brand registrations on this account")
return 0
# Every campaign on the account, tagged with the service it came from, so a
# brand suspension is reported against the services it actually reaches.
services = list_v1(session, MSG + "/Services", "services", args.max_services)
campaigns = []
for svc in services:
for c in list_v1(session,
"%s/Services/%s/Compliance/Usa2p" % (MSG, svc["sid"]),
"compliance"):
c = dict(c)
c["_service"] = svc.get("friendly_name") or svc["sid"]
campaigns.append(c)
bad = 0
for brand in brands:
sid = brand.get("sid", "?")
mine = attached(campaigns, sid)
state, detail = verdict(brand, mine)
line = "%-24s %s %s" % (state, sid, detail)
if state in ("clean", "brand-not-usable"):
log.info(line)
continue
bad += 1
log.warning(line)
for c in mine:
log.warning(" %s on %s (%s)", c.get("campaign_status", "?"),
c.get("_service", "?"), c.get("sid", "QE..."))
if state.startswith("cascade") or state == "brand-suspended-no-campaign":
log.warning(" repair: none by API. Take brand %s to Twilio Support; "
"campaigns stay suspended until the brand clears. Do not "
"move the traffic to a new brand or campaign", sid)
elif state == "campaign-suspended-only":
log.warning(" repair: read errors[] on the campaign; the brand above "
"it is not the cause")
log.info("%d brand(s), %d campaign(s), %d suspended",
len(brands), len(campaigns), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report A2P brand suspensions and the campaigns they take down with them.
*
* 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 MSG = 'https://messaging.twilio.com/v1';
const SUSPENDED = 'SUSPENDED';
/**
* The campaigns registered under one brand. Pure. brand_registration_sid is the
* only link between the 30033 your sends return and the object that changed.
*/
export function attached(campaigns, brandSid) {
const want = String(brandSid ?? '').trim();
if (!want) return [];
return campaigns.filter(
(c) => String(c.brand_registration_sid ?? '').trim() === want);
}
/** Upper-cased campaign_status for each campaign, in order. Pure. */
export function campaignStatuses(campaigns) {
return campaigns.map((c) => String(c.campaign_status ?? '').toUpperCase());
}
/**
* Classify one brand together with the campaigns attributed to it. Pure. The
* states differ by the direction of causation, not by which fields say
* SUSPENDED. Returns [state, detail].
*/
export function verdict(brand, campaigns) {
const status = String(brand.status ?? '').toUpperCase();
const statuses = campaignStatuses(campaigns);
const hit = statuses.filter((s) => s === SUSPENDED).length;
if (status === SUSPENDED) {
if (campaigns.length === 0) {
return ['brand-suspended-no-campaign',
'brand is SUSPENDED with no campaign attached. Nothing is sending, and ' +
'nothing can be registered under it.'];
}
if (hit === statuses.length) {
return ['cascade',
`brand is SUSPENDED and all ${statuses.length} campaign(s) under it are ` +
'SUSPENDED too. Every US send on them returns 30033, and the campaign ' +
'is not the thing that changed.'];
}
if (hit) {
return ['cascade-partial',
`brand is SUSPENDED; ${hit} of ${statuses.length} campaign(s) already ` +
'read SUSPENDED. The rest are on the same brand and will follow.'];
}
const seen = [...new Set(statuses)].sort().join(', ');
return ['cascade-not-yet-visible',
`brand is SUSPENDED while all ${statuses.length} campaign(s) still read ` +
`${seen}. Sends fail regardless: the brand is the field telling the ` +
'truth here.'];
}
if (hit) {
return ['campaign-suspended-only',
`${hit} campaign(s) SUSPENDED under a brand that is ${status || 'unset'}. ` +
"This one is campaign level, so the campaign's errors[] is where the " +
'reason is.'];
}
if (status === 'APPROVED') {
return ['clean',
`brand is APPROVED and none of its ${statuses.length} campaign(s) are ` +
'suspended.'];
}
return ['brand-not-usable',
`brand status is ${status || 'unset'}, which is not a suspension. Nothing ` +
'here is being taken down; it never came up.'];
}
function authHeader(key, secret) {
return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}
async function get(auth, url, params = {}) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const res = await fetch(u, { headers: { Authorization: auth } });
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from Twilio: check TWILIO_ACCOUNT_SID and ` +
'that the API key belongs to that account with read access');
}
if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
return res.json();
}
export async function 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 brands = await listV1(auth, `${MSG}/a2p/BrandRegistrations`, 'data');
if (brands.length === 0) {
console.log('no A2P brand registrations on this account');
return;
}
const services = await listV1(auth, `${MSG}/Services`, 'services');
const campaigns = [];
for (const svc of services) {
const found = await listV1(
auth, `${MSG}/Services/${svc.sid}/Compliance/Usa2p`, 'compliance');
for (const c of found) {
campaigns.push({ ...c, _service: svc.friendly_name ?? svc.sid });
}
}
let bad = 0;
for (const brand of brands) {
const sid = brand.sid ?? '?';
const mine = attached(campaigns, sid);
const [state, detail] = verdict(brand, mine);
const line = `${state.padEnd(24)} ${sid} ${detail}`;
if (state === 'clean' || state === 'brand-not-usable') {
console.log(line);
continue;
}
bad += 1;
console.warn(line);
for (const c of mine) {
console.warn(` ${c.campaign_status ?? '?'} on ${c._service ?? '?'} ` +
`(${c.sid ?? 'QE...'})`);
}
if (state.startsWith('cascade') || state === 'brand-suspended-no-campaign') {
console.warn(` repair: none by API. Take brand ${sid} to Twilio Support; ` +
'campaigns stay suspended until the brand clears. Do not move ' +
'the traffic to a new brand or campaign');
} else if (state === 'campaign-suspended-only') {
console.warn(' repair: read errors[] on the campaign; the brand above it ' +
'is not the cause');
}
}
console.log(`${brands.length} brand(s), ${campaigns.length} campaign(s), ` +
`${bad} suspended`);
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
Every case here is about direction. The same word on two resources means a cascade one way round and a campaign problem the other, and the case worth being most careful with is the brand that is already SUSPENDED while its campaigns still read VERIFIED — sends are failing, and a check that only looks for suspended campaigns reports the account as healthy.
from twilio_a2p_brand_suspension_audit import attached, verdict
BRAND = {"sid": "BN0123456789", "status": "SUSPENDED"}
OK_BRAND = {"sid": "BN0123456789", "status": "APPROVED"}
def campaign(status, brand_sid="BN0123456789", sid="QE1"):
return {"sid": sid, "campaign_status": status,
"brand_registration_sid": brand_sid}
def test_suspended_brand_over_suspended_campaigns_is_a_cascade():
state, detail = verdict(BRAND, [campaign("SUSPENDED")])
assert state == "cascade"
assert "30033" in detail
def test_suspended_brand_with_verified_campaigns_is_still_the_brands_fault():
# The campaign resource has not caught up. Sends fail anyway, and a check
# that only looks for suspended campaigns calls this account healthy.
state, detail = verdict(BRAND, [campaign("VERIFIED")])
assert state == "cascade-not-yet-visible"
assert "telling the truth" in detail
def test_a_partly_updated_cascade_says_how_many():
state, detail = verdict(BRAND, [campaign("SUSPENDED", sid="QE1"),
campaign("VERIFIED", sid="QE2")])
assert state == "cascade-partial"
assert "1 of 2" in detail
def test_suspended_campaign_under_a_healthy_brand_is_campaign_level():
state, detail = verdict(OK_BRAND, [campaign("SUSPENDED")])
assert state == "campaign-suspended-only"
assert "errors[]" in detail
def test_a_suspended_brand_with_nothing_attached_is_still_reported():
assert verdict(BRAND, [])[0] == "brand-suspended-no-campaign"
def test_an_approved_brand_with_verified_campaigns_is_clean():
assert verdict(OK_BRAND, [campaign("VERIFIED")])[0] == "clean"
def test_a_failed_brand_is_not_a_suspension():
state, detail = verdict({"sid": "BN1", "status": "FAILED"}, [])
assert state == "brand-not-usable"
assert "never came up" in detail
def test_campaigns_are_attributed_by_brand_registration_sid():
pool = [campaign("SUSPENDED", "BN1", "QE1"), campaign("VERIFIED", "BN2", "QE2")]
assert [c["sid"] for c in attached(pool, "BN1")] == ["QE1"]
def test_a_blank_brand_sid_attributes_nothing():
assert attached([campaign("SUSPENDED", "", "QE1")], "") == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { attached, verdict }
from './twilio-a2p-brand-suspension-audit.mjs';
const BRAND = { sid: 'BN0123456789', status: 'SUSPENDED' };
const OK_BRAND = { sid: 'BN0123456789', status: 'APPROVED' };
function campaign(status, brandSid = 'BN0123456789', sid = 'QE1') {
return { sid, campaign_status: status, brand_registration_sid: brandSid };
}
test('suspended brand over suspended campaigns is a cascade', () => {
const [state, detail] = verdict(BRAND, [campaign('SUSPENDED')]);
assert.equal(state, 'cascade');
assert.match(detail, /30033/);
});
test('suspended brand with verified campaigns is still the brand', () => {
const [state, detail] = verdict(BRAND, [campaign('VERIFIED')]);
assert.equal(state, 'cascade-not-yet-visible');
assert.match(detail, /telling the truth/);
});
test('a partly updated cascade says how many', () => {
const [state, detail] = verdict(BRAND, [campaign('SUSPENDED', 'BN0123456789', 'QE1'),
campaign('VERIFIED', 'BN0123456789', 'QE2')]);
assert.equal(state, 'cascade-partial');
assert.match(detail, /1 of 2/);
});
test('suspended campaign under a healthy brand is campaign level', () => {
const [state, detail] = verdict(OK_BRAND, [campaign('SUSPENDED')]);
assert.equal(state, 'campaign-suspended-only');
assert.match(detail, /errors\[\]/);
});
test('a suspended brand with nothing attached is still reported', () => {
assert.equal(verdict(BRAND, [])[0], 'brand-suspended-no-campaign');
});
test('an approved brand with verified campaigns is clean', () => {
assert.equal(verdict(OK_BRAND, [campaign('VERIFIED')])[0], 'clean');
});
test('a failed brand is not a suspension', () => {
const [state, detail] = verdict({ sid: 'BN1', status: 'FAILED' }, []);
assert.equal(state, 'brand-not-usable');
assert.match(detail, /never came up/);
});
test('campaigns are attributed by brand_registration_sid', () => {
const pool = [campaign('SUSPENDED', 'BN1', 'QE1'),
campaign('VERIFIED', 'BN2', 'QE2')];
assert.deepEqual(attached(pool, 'BN1').map((c) => c.sid), ['QE1']);
});
test('a blank brand sid attributes nothing', () => {
assert.deepEqual(attached([campaign('SUSPENDED', '', 'QE1')], ''), []);
});
FAQ
How do I tell a brand suspension from a campaign suspension?
By joining them. The campaign object carries brand_registration_sid; look up that BN SID in the brand list. Campaign SUSPENDED under brand SUSPENDED is a cascade and the brand is the cause. Campaign SUSPENDED under an APPROVED brand is campaign level, and that campaign's errors[] holds the reason.
Why do my edits fail with 21731 and 21729?
Because a suspended object is frozen. 21731 refuses an update to a suspended brand and 21729 refuses one to a suspended campaign. They are two codes for the same underlying condition, and neither of them says the word suspension, which is why they read like unrelated permission problems.
Can I move the traffic to a new brand or campaign while this is resolved?
No. Re-registering the same traffic under a fresh brand to route around a suspension is treated as evasion and puts the whole account at risk. The suspension is a compliance decision about the traffic, and the traffic has not changed by moving it.
The brand says SUSPENDED but the campaign still says VERIFIED. Which is right?
Both, briefly. The brand transitioned and the campaign resource has not caught up. Sends fail either way, so treat the brand as the truth. This is the exact window in which a check written only against campaign_status reports a healthy account during an outage.
Does a brand suspension affect toll-free or short code traffic?
No. A2P 10DLC brands and campaigns govern US long code traffic, so toll-free and short code senders keep working. That is the usual stopgap while the suspension is being resolved, provided the toll-free number is itself verified.
Related field notes
- A campaign is FAILED and errors[] names the field
- A brand at FAILED blocks every campaign
- An unverified toll-free number blocks US SMS
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.
- BrandRegistration resource — Twilio Docs
- UsAppToPerson resource — Twilio Docs
- Error 30033: US A2P 10DLC campaign suspended — Twilio Docs
- Troubleshooting and rectifying A2P Standard and LVS brands — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.