Diagnostic Twilio
extra numbers on a Sole Prop campaign never leave UNREGISTERED
Roughly a third of the messages get through. The rest come back 30034. Retrying works, sometimes, and which send succeeds changes every time, so the first theory is a flaky carrier and the second is a Twilio incident. It is neither. The Messaging Service is picking a sender per message from a pool of three, and on a Sole Proprietor brand only one of those three was ever registered.
For each Messaging Service read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p and take brand_registration_sid. Read that brand at GET https://messaging.twilio.com/v1/a2p/BrandRegistrations/{BrandSid}. If brand_type is SOLE_PROPRIETOR, then GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers and flag any pool holding more than one number.
A Sole Proprietor brand permits exactly one campaign, and that campaign exactly one 10DLC number. Adding a second to the pool returns success; it simply never registers.
The problem in plain words
The add succeeded. That is the whole trap. Attaching a number to a Messaging Service is an ordinary call that returns an ordinary created resource, and nothing in the response mentions that the brand behind this service is capped at one sender. So the pool has three numbers in it, the console lists three numbers, and by every visible measure the service is configured the way somebody intended.
Then the sends start failing at a rate nobody can pin down. A Messaging Service selects the from per message, so the same recipient succeeds on one attempt and 30034s on the next. That intermittency is what sends teams chasing carrier filtering, rate limits and retry logic, none of which is involved. Two of the three numbers are sitting at A2P status UNREGISTERED, permanently, and no amount of waiting will move them.
Why it happens
Intermittent is the signature. A number missing from any pool fails consistently for one from: send from that number, it always fails. This fails randomly across the same recipients, because the failure is decided at selection time. If your 30034s correlate with a single sender you have the other problem; if they scatter, you have this one.
The limit is a property of the brand, not of the service. Nothing on the Messaging Service says one number. You have to follow the campaign to brand_registration_sid, read brand_type there, and only then does the pool size mean anything. A script that stops at the campaign sees a perfectly healthy VERIFIED record.
Sole Proprietor cannot be widened. There is no upgrade path that keeps this brand and adds capacity. More senders means a Standard or Low-Volume Standard brand, with a customer profile and a different registration. Teams lose weeks assuming the cap is a quota to be raised rather than a shape of registration to be replaced.
Which number is the registered one is not obvious. Removing the wrong two takes the working sender out of the pool and turns an intermittent failure into a total one. The order numbers were added is not a reliable guide, so the repair has to be checked against the A2P registration status of each number before anything is detached.
The fix, as a flow
The script follows brand_registration_sid from the campaign to the brand before it counts anything, because the one number limit is a property of the brand and nothing on the Messaging Service mentions it.
How to fix it
Read the campaign to find the brand behind the service
GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p returns the campaign under compliance. Take brand_registration_sid from it. A service with no campaign at all is a different finding and is covered separately.
Read brand_type on that brand
GET https://messaging.twilio.com/v1/a2p/BrandRegistrations/{BrandSid} and look at brand_type. Only SOLE_PROPRIETOR carries the one number limit. Cache this per brand: several services can share one, and there is no reason to fetch it twice.
Count the pool
GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers, following the pages. On a Sole Proprietor brand a length greater than one is the finding, and the count of extras is the number of senders that will never register.
Separate an empty pool from an overfilled one
Zero numbers on a Sole Proprietor service fails every send consistently rather than intermittently, and the repair is to add one rather than remove several. Same brand, same limit, opposite mistake, so it belongs in the report as its own state.
Print the removals, and say which one to keep
The repair is to detach every number except the intended sender, leaving one. Do not guess which to keep from the order of the list: confirm the registered sender before detaching anything, or the fix converts intermittent failures into total ones. If the account genuinely needs more senders, the repair is a Standard or Low-Volume Standard brand, because Sole Proprietor cannot be widened.
How to check it worked
Re-run the script. Every Sole Proprietor service should report one number in the pool.
python3 twilio_sole_prop_pool_audit.py
# 6 service(s), 2 on a sole proprietor brand, 0 overfilled
The full code
Three reads per service, and the brand cached across them. The classifier takes the brand, the pool size and the campaign status as plain values rather than fetching anything, so every combination that matters — overfilled, empty, exactly one but not yet verified, and the case where the brand could not be read at all — is a test that runs offline.
"""Find Sole Proprietor Messaging Services holding more than one sender.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. Detaching the wrong number here would take
the one registered sender out of the pool, so the removals are printed and a
person decides which number stays.
"""
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_sole_prop_pool_audit")
MSG = "https://messaging.twilio.com/v1"
SOLE = "SOLE_PROPRIETOR"
# A Sole Proprietor brand permits one campaign, and that campaign one 10DLC
# number. The limit lives on the brand, not on the Messaging Service.
SOLE_PROP_SENDER_LIMIT = 1
def verdict(brand, pool_size, campaign_status=None, limit=SOLE_PROP_SENDER_LIMIT):
"""Classify one Messaging Service against its brand's sender limit.
`brand` is the brand registration dict or None, `pool_size` the number of
phone numbers in the service's pool or None. Nothing is fetched here, so
every state below is testable offline. Returns (state, detail).
"""
if brand is None:
return ("brand-unread",
"the campaign names a brand_registration_sid that could not be "
"read, so the one sender limit cannot be applied to this pool.")
brand_type = str(brand.get("brand_type") or "").upper()
if brand_type != SOLE:
return ("not-sole-prop",
"brand_type is %s: the pool size is not capped by the brand."
% (brand_type or "unset"))
if pool_size is None:
return ("pool-unread",
"sole proprietor brand and the sender pool could not be read.")
status = str(campaign_status or "").upper()
if pool_size == 0:
return ("empty-pool",
"sole proprietor brand with nothing in the sender pool. Every "
"US send fails consistently rather than intermittently, and "
"the repair is to add the one number rather than remove any.")
if pool_size > limit:
extras = pool_size - limit
return ("overfilled",
"%d numbers in the pool on a sole proprietor brand, which "
"permits %d. %d of them will sit at A2P status UNREGISTERED "
"permanently, and the service picks a sender per message, so "
"30034 arrives at random rather than for one from."
% (pool_size, limit, extras))
if status and status != "VERIFIED":
return ("single-not-verified",
"one number, which is the limit, but campaign_status is %s so "
"it is not registered yet. This is the review clock, not the "
"sender limit." % status)
return ("registered",
"one number in the pool, which is what a sole proprietor brand "
"supports.")
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)
if r.status_code == 404:
return None
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)
if page is None:
break
out.extend(page.get(key, []))
url = (page.get("meta") or {}).get("next_page_url")
return out[:limit]
def read_brand(session, cache, brand_sid):
"""Read a brand once per run. Several services can share one brand."""
if not brand_sid:
return None
if brand_sid not in cache:
cache[brand_sid] = get(session, "%s/a2p/BrandRegistrations/%s"
% (MSG, brand_sid))
return cache[brand_sid]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-services", type=int, default=200)
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 = {}
services = list_v1(session, MSG + "/Services", "services", args.max_services)
if not services:
log.info("no Messaging Services on this account")
return 0
sole = 0
bad = 0
for svc in services:
campaigns = list_v1(session, "%s/Services/%s/Compliance/Usa2p"
% (MSG, svc["sid"]), "compliance")
campaign = campaigns[0] if campaigns else None
if campaign is None:
continue
brand = read_brand(session, brands, campaign.get("brand_registration_sid"))
numbers = list_v1(session, "%s/Services/%s/PhoneNumbers" % (MSG, svc["sid"]),
"phone_numbers")
state, detail = verdict(brand, len(numbers),
campaign.get("campaign_status"))
if state == "not-sole-prop":
continue
sole += 1
name = svc.get("friendly_name") or svc["sid"]
line = "%-20s %s %s" % (state, name, detail)
if state == "registered":
log.info(line)
continue
bad += 1
log.warning(line)
if state == "overfilled":
for num in numbers:
log.warning(" in pool: %s %s", num.get("phone_number", "?"),
num.get("sid", ""))
log.warning(" repair: detach every number above except the one that "
"is actually registered, at %s/Services/%s/PhoneNumbers/"
"{PhoneNumberSid}. Confirm which one is registered "
"first: removing the wrong two turns an intermittent "
"failure into a total one", MSG, svc["sid"])
log.warning(" repair: if this account genuinely needs more senders, "
"register a Standard or Low-Volume Standard brand. Sole "
"Proprietor cannot be widened")
elif state == "empty-pool":
log.warning(" repair: attach the intended sender to %s, then wait "
"for its A2P registration to complete", svc["sid"])
log.info("%d service(s), %d on a sole proprietor brand, %d overfilled",
len(services), sole, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find Sole Proprietor Messaging Services holding more than one sender.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The removals are printed, because
* detaching the wrong number takes the one registered sender out of the pool.
*/
const MSG = 'https://messaging.twilio.com/v1';
const SOLE = 'SOLE_PROPRIETOR';
// A Sole Proprietor brand permits one campaign, and that campaign one 10DLC
// number. The limit lives on the brand, not on the Messaging Service.
export const SOLE_PROP_SENDER_LIMIT = 1;
/**
* Classify one Messaging Service against its brand's sender limit. Nothing is
* fetched here. Pure. Returns [state, detail].
*/
export function verdict(brand, poolSize, campaignStatus = null,
limit = SOLE_PROP_SENDER_LIMIT) {
if (brand === null || brand === undefined) {
return ['brand-unread',
'the campaign names a brand_registration_sid that could not be read, so ' +
'the one sender limit cannot be applied to this pool.'];
}
const brandType = String(brand.brand_type ?? '').toUpperCase();
if (brandType !== SOLE) {
return ['not-sole-prop',
`brand_type is ${brandType || 'unset'}: the pool size is not capped by the brand.`];
}
if (poolSize === null || poolSize === undefined) {
return ['pool-unread',
'sole proprietor brand and the sender pool could not be read.'];
}
const status = String(campaignStatus ?? '').toUpperCase();
if (poolSize === 0) {
return ['empty-pool',
'sole proprietor brand with nothing in the sender pool. Every US send ' +
'fails consistently rather than intermittently, and the repair is to add ' +
'the one number rather than remove any.'];
}
if (poolSize > limit) {
const extras = poolSize - limit;
return ['overfilled',
`${poolSize} numbers in the pool on a sole proprietor brand, which ` +
`permits ${limit}. ${extras} of them will sit at A2P status UNREGISTERED ` +
'permanently, and the service picks a sender per message, so 30034 ' +
'arrives at random rather than for one from.'];
}
if (status && status !== 'VERIFIED') {
return ['single-not-verified',
`one number, which is the limit, but campaign_status is ${status} so it ` +
'is not registered yet. This is the review clock, not the sender limit.'];
}
return ['registered',
'one number in the pool, which is what a sole proprietor brand supports.'];
}
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.status === 404) return null;
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 });
if (page === null) break;
out.push(...(page[key] ?? []));
next = page.meta?.next_page_url ?? null;
}
return out.slice(0, limit);
}
async function readBrand(auth, cache, brandSid) {
if (!brandSid) return null;
if (!(brandSid in cache)) {
cache[brandSid] = await get(auth, `${MSG}/a2p/BrandRegistrations/${brandSid}`);
}
return cache[brandSid];
}
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 = {};
const services = await listV1(auth, `${MSG}/Services`, 'services');
if (services.length === 0) {
console.log('no Messaging Services on this account');
return;
}
let sole = 0;
let bad = 0;
for (const svc of services) {
const campaigns = await listV1(auth, `${MSG}/Services/${svc.sid}/Compliance/Usa2p`,
'compliance');
const campaign = campaigns[0] ?? null;
if (campaign === null) continue;
const brand = await readBrand(auth, brands, campaign.brand_registration_sid);
const numbers = await listV1(auth, `${MSG}/Services/${svc.sid}/PhoneNumbers`,
'phone_numbers');
const [state, detail] = verdict(brand, numbers.length, campaign.campaign_status);
if (state === 'not-sole-prop') continue;
sole += 1;
const name = svc.friendly_name ?? svc.sid;
const line = `${state.padEnd(20)} ${name} ${detail}`;
if (state === 'registered') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'overfilled') {
for (const num of numbers) {
console.warn(` in pool: ${num.phone_number ?? '?'} ${num.sid ?? ''}`);
}
console.warn(' repair: detach every number above except the one that is ' +
`actually registered, at ${MSG}/Services/${svc.sid}/` +
'PhoneNumbers/{PhoneNumberSid}. Confirm which one is ' +
'registered first: removing the wrong two turns an ' +
'intermittent failure into a total one');
console.warn(' repair: if this account genuinely needs more senders, ' +
'register a Standard or Low-Volume Standard brand. Sole ' +
'Proprietor cannot be widened');
} else if (state === 'empty-pool') {
console.warn(` repair: attach the intended sender to ${svc.sid}, then ` +
'wait for its A2P registration to complete');
}
}
console.log(`${services.length} service(s), ${sole} on a sole proprietor brand, ` +
`${bad} overfilled`);
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 classifier never fetches, so the pool size is just a number and the brand is a dict. That makes the four states worth being sure about into four short assertions: too many numbers, none at all, exactly one on a campaign that has not been approved yet, and a brand that could not be read, which must not be silently treated as compliant.
from twilio_sole_prop_pool_audit import verdict
SOLE_PROP = {"brand_type": "SOLE_PROPRIETOR", "status": "APPROVED"}
STANDARD = {"brand_type": "STANDARD", "status": "APPROVED"}
def test_three_numbers_leaves_two_permanently_unregistered():
state, detail = verdict(SOLE_PROP, 3, "VERIFIED")
assert state == "overfilled"
assert "2 of them" in detail
assert "at random" in detail
def test_one_number_on_a_verified_campaign_is_the_supported_shape():
assert verdict(SOLE_PROP, 1, "VERIFIED")[0] == "registered"
def test_an_empty_pool_is_the_opposite_mistake():
state, detail = verdict(SOLE_PROP, 0, "VERIFIED")
assert state == "empty-pool"
assert "consistently" in detail
def test_one_number_on_an_unapproved_campaign_is_the_review_clock():
state, detail = verdict(SOLE_PROP, 1, "IN_PROGRESS")
assert state == "single-not-verified"
assert "not the sender limit" in detail
def test_a_standard_brand_is_not_capped_by_pool_size():
state, _ = verdict(STANDARD, 12, "VERIFIED")
assert state == "not-sole-prop"
def test_an_unread_brand_is_never_reported_as_compliant():
# Following brand_registration_sid can fail. Guessing SOLE_PROPRIETOR would
# invent findings; guessing STANDARD would hide them.
assert verdict(None, 4, "VERIFIED")[0] == "brand-unread"
def test_an_unread_pool_is_reported_as_such():
assert verdict(SOLE_PROP, None, "VERIFIED")[0] == "pool-unread"
def test_an_unset_brand_type_is_not_assumed_to_be_sole_prop():
state, detail = verdict({"status": "APPROVED"}, 5)
assert state == "not-sole-prop"
assert "unset" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './twilio-sole-prop-pool-audit.mjs';
const SOLE_PROP = { brand_type: 'SOLE_PROPRIETOR', status: 'APPROVED' };
const STANDARD = { brand_type: 'STANDARD', status: 'APPROVED' };
test('three numbers leaves two permanently unregistered', () => {
const [state, detail] = verdict(SOLE_PROP, 3, 'VERIFIED');
assert.equal(state, 'overfilled');
assert.match(detail, /2 of them/);
assert.match(detail, /at random/);
});
test('one number on a verified campaign is the supported shape', () => {
assert.equal(verdict(SOLE_PROP, 1, 'VERIFIED')[0], 'registered');
});
test('an empty pool is the opposite mistake', () => {
const [state, detail] = verdict(SOLE_PROP, 0, 'VERIFIED');
assert.equal(state, 'empty-pool');
assert.match(detail, /consistently/);
});
test('one number on an unapproved campaign is the review clock', () => {
const [state, detail] = verdict(SOLE_PROP, 1, 'IN_PROGRESS');
assert.equal(state, 'single-not-verified');
assert.match(detail, /not the sender limit/);
});
test('a standard brand is not capped by pool size', () => {
assert.equal(verdict(STANDARD, 12, 'VERIFIED')[0], 'not-sole-prop');
});
test('an unread brand is never reported as compliant', () => {
assert.equal(verdict(null, 4, 'VERIFIED')[0], 'brand-unread');
});
test('an unread pool is reported as such', () => {
assert.equal(verdict(SOLE_PROP, null, 'VERIFIED')[0], 'pool-unread');
});
test('an unset brand type is not assumed to be sole prop', () => {
const [state, detail] = verdict({ status: 'APPROVED' }, 5);
assert.equal(state, 'not-sole-prop');
assert.match(detail, /unset/);
});
FAQ
How do I tell this apart from a number that is not in any pool?
By whether the failures follow a sender. A number outside every pool fails every time you send from it and never fails for the others. This one fails at random across the same recipients, because the Messaging Service chooses the from per message and only one number in the pool is registered.
Can I raise the one number limit on a Sole Proprietor brand?
No. It is not a quota, it is the shape of the registration: one brand, one campaign, one number. More senders means registering a Standard or Low-Volume Standard brand instead, which needs a customer profile and goes through its own review.
Which number in the pool is the registered one?
Check the A2P registration status of each number rather than assuming it is the first or oldest. Detaching the wrong two removes the only working sender, and the failure goes from intermittent to complete while the report still says the pool was fixed.
Does adding the extra numbers cost anything?
They are billed as phone numbers like any others, and they will carry non-US traffic and voice normally. What they will not do is send US A2P messages, so the cost is a monthly line item plus a share of your sends failing.
Why does the API let me add them at all?
The sender pool is a Messaging Service feature and the limit belongs to the brand behind the campaign. The two are separate resources, and the add is validated against the service rather than against the registration, so nothing on that call is in a position to refuse.
Related field notes
- A 10DLC number outside the pool is never registered
- A Sole Prop brand blocked by an unanswered passcode
- A Messaging Service with no A2P campaign
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.
- Troubleshooting A2P phone number registration — Twilio Docs
- Error 30034: message from an unregistered number — Twilio Docs
- Messaging Service PhoneNumber resource — Twilio Docs
- Brand Registration resource — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.