Diagnostic Twilio
a Messaging Service with no A2P campaign fails US sends
Staging was cloned from production last quarter and it has worked ever since — until the new tenant went live and every US message came back 30034. The brand is approved. The campaign is verified. Neither of them is attached to this Messaging Service, because A2P registration is per service and nobody registers the second one.
Read GET https://messaging.twilio.com/v1/Services and flag every service where us_app_to_person_registered is false. That single boolean is the fastest account-wide way to find unregistered services.
Confirm each with GET /v1/Services/{ServiceSid}/Compliance/Usa2p, which returns an empty list when no campaign is attached. Then count the US long codes in GET /v1/Services/{ServiceSid}/PhoneNumbers — an unregistered service with US senders is failing right now; one with none is a ticket for before launch.
The problem in plain words
Nothing about an unregistered Messaging Service looks unregistered. You can create it, name it, add numbers to it, set its callbacks, and call the API against it, and every one of those operations succeeds. The service is a perfectly valid object. It is only at the moment a US message is handed to a carrier that the missing campaign matters, and by then you are looking at a delivery failure rather than a configuration error.
What makes it expensive is the shape of the failure: 30034 comes back per message, at send time, in production. A service created for staging, or for a new tenant, or as part of a migration, carries no warning that it is not the registered one. The team sees the brand approved in the console, concludes registration is done, and ships.
Why it happens
Registration attaches to the service, not to the account. One approved brand can sit behind many Messaging Services, and each one needs its own campaign. "We are registered" is true of the account and false of the service that is actually sending.
Cloning a service does not clone its campaign. Copying the settings that show in the console — pool, opt-out, callbacks — produces something that looks identical and is missing the one attribute you cannot see there.
The failure is per message, not at configuration time. Adding a US long code to an unregistered service returns 201. Twilio will not stop you assembling a service that cannot send; the carrier rejects the traffic later.
A registered flag is not a healthy campaign. us_app_to_person_registered can be true while the campaign underneath is IN_PROGRESS, FAILED or SUSPENDED. Reading only the boolean turns a suspended campaign into a green tick, so the campaign status has to be read as well.
The fix, as a flow
The script reads one boolean per service to find the candidates, then confirms each against the campaign subresource, because a service can be flagged registered while the campaign underneath is suspended.
How to fix it
Sweep every service for the boolean
GET https://messaging.twilio.com/v1/Services and read us_app_to_person_registered on each. This is one paginated GET for the whole account and it is the check to put on a schedule, because the failure arrives with a service somebody created last week.
Confirm with the campaign subresource
GET /v1/Services/{ServiceSid}/Compliance/Usa2p returns the campaign objects under compliance. An empty list confirms the boolean. A non-empty list with us_app_to_person_registered false, or the reverse, is a disagreement worth reporting rather than resolving in favour of whichever you read first.
Read campaign_status, not just presence
A campaign exists in several states that are not VERIFIED. IN_PROGRESS means it is not live yet, FAILED means it never will be without changes, and SUSPENDED usually means the brand above it is suspended. All three send exactly like an unregistered service.
Count the US senders to decide urgency
GET /v1/Services/{ServiceSid}/PhoneNumbers and count +1 numbers that are not toll-free. Unregistered with US senders is a live outage; unregistered with none has not bitten yet and can be fixed before it does.
Register the service, then re-run
POST /v1/Services/{ServiceSid}/Compliance/Usa2p with BrandRegistrationSid, Description, MessageFlow, MessageSamples, UsAppToPersonUsecase, HasEmbeddedLinks and HasEmbeddedPhone. Carrier provisioning of the individual numbers takes up to a day afterwards, so re-run the audit tomorrow rather than treating the POST as the finish line.
How to check it worked
Re-run the script. Every service with US senders should report registered, with a campaign in VERIFIED.
python3 twilio_a2p_registration_audit.py
# 4 service(s), 0 unable to send to US numbers
The full code
One paginated GET for the services and two per service for its campaign and its pool — all reads, with an API Key that has read access and nothing more. The classifier takes the service, the campaign list and the US sender count together, because the same missing campaign is a ticket on an empty service and an outage on one that is sending.
"""Report Messaging Services that cannot send to US numbers under A2P 10DLC.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The registration 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_registration_audit")
MSG = "https://messaging.twilio.com/v1"
TOLL_FREE = ("800", "833", "844", "855", "866", "877", "888")
def us_long_codes(pool):
"""Count the senders 10DLC registration actually governs.
Pure. Toll-free numbers are verified separately and short codes are not
10DLC at all, so counting every +1 in the pool overstates the exposure.
"""
out = []
for n in pool:
number = str(n.get("phone_number") or "")
if not number.startswith("+1") or len(number) != 12:
continue
if number[2:5] in TOLL_FREE:
continue
out.append(number)
return out
def verdict(service, campaigns, us_senders):
"""Classify one Messaging Service's A2P standing. Pure, so the states can be
tested without a network.
`campaigns` is the list from Compliance/Usa2p; `us_senders` is the count of
US long codes in its pool. Returns (state, detail).
"""
registered = bool(service.get("us_app_to_person_registered"))
campaign = campaigns[0] if campaigns else None
if campaign is None:
if registered:
return ("inconsistent",
"us_app_to_person_registered is true but Compliance/Usa2p "
"returned no campaign. Trust the subresource, not the flag.")
if us_senders:
return ("blocked",
"no A2P campaign and %d US long code(s) in the pool: every "
"US send through this service returns 30034." % us_senders)
return ("unregistered",
"no A2P campaign. No US long codes in the pool yet, so nothing "
"is failing; register before one is added.")
status = str(campaign.get("campaign_status") or "").upper()
if status == "VERIFIED":
if not registered:
return ("inconsistent",
"campaign is VERIFIED but us_app_to_person_registered is "
"false. Trust the subresource, not the flag.")
return ("registered", "campaign %s is VERIFIED" % campaign.get("sid", "?"))
return ("campaign-%s" % (status.lower() or "unknown"),
"a campaign exists but its status is %s, which sends exactly like "
"no campaign at all (%d US long code(s) affected)."
% (status or "unset", us_senders))
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)
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)
services = list_v1(session, MSG + "/Services", "services", args.max_services)
if not services:
log.info("no Messaging Services on this account")
return 0
bad = 0
for svc in services:
sid = svc["sid"]
campaigns = list_v1(session, "%s/Services/%s/Compliance/Usa2p" % (MSG, sid),
"compliance")
pool = list_v1(session, "%s/Services/%s/PhoneNumbers" % (MSG, sid),
"phone_numbers")
state, detail = verdict(svc, campaigns, len(us_long_codes(pool)))
line = "%-22s %s %s" % (state, svc.get("friendly_name", sid), detail)
if state == "registered":
log.info(line)
continue
bad += 1
log.warning(line)
if state in ("blocked", "unregistered"):
log.warning(" repair: POST %s/Services/%s/Compliance/Usa2p with "
"BrandRegistrationSid, Description, MessageFlow, "
"MessageSamples, UsAppToPersonUsecase, HasEmbeddedLinks, "
"HasEmbeddedPhone", MSG, sid)
log.info("%d service(s), %d unable to send to US numbers", len(services), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Messaging Services that cannot send to US numbers under A2P 10DLC.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The registration is printed, never
* performed.
*/
const MSG = 'https://messaging.twilio.com/v1';
const TOLL_FREE = ['800', '833', '844', '855', '866', '877', '888'];
/**
* Count the senders 10DLC registration actually governs. Pure: toll-free
* numbers are verified separately and short codes are not 10DLC at all.
*/
export function usLongCodes(pool) {
return pool
.map((n) => String(n.phone_number ?? ''))
.filter((n) => n.startsWith('+1') && n.length === 12 && !TOLL_FREE.includes(n.slice(2, 5)));
}
/**
* Classify one Messaging Service's A2P standing. Pure, so the states can be
* tested without a network. Returns [state, detail].
*/
export function verdict(service, campaigns, usSenders) {
const registered = Boolean(service.us_app_to_person_registered);
const campaign = campaigns && campaigns.length ? campaigns[0] : null;
if (campaign === null) {
if (registered) {
return ['inconsistent',
'us_app_to_person_registered is true but Compliance/Usa2p returned no ' +
'campaign. Trust the subresource, not the flag.'];
}
if (usSenders) {
return ['blocked',
`no A2P campaign and ${usSenders} US long code(s) in the pool: every US ` +
'send through this service returns 30034.'];
}
return ['unregistered',
'no A2P campaign. No US long codes in the pool yet, so nothing is ' +
'failing; register before one is added.'];
}
const status = String(campaign.campaign_status ?? '').toUpperCase();
if (status === 'VERIFIED') {
if (!registered) {
return ['inconsistent',
'campaign is VERIFIED but us_app_to_person_registered is false. Trust ' +
'the subresource, not the flag.'];
}
return ['registered', `campaign ${campaign.sid ?? '?'} is VERIFIED`];
}
return [`campaign-${status.toLowerCase() || 'unknown'}`,
`a campaign exists but its status is ${status || 'unset'}, which sends ` +
`exactly like no campaign at all (${usSenders} US long code(s) affected).`];
}
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 services = await listV1(auth, `${MSG}/Services`, 'services');
if (services.length === 0) {
console.log('no Messaging Services on this account');
return;
}
let bad = 0;
for (const svc of services) {
const campaigns = await listV1(auth, `${MSG}/Services/${svc.sid}/Compliance/Usa2p`,
'compliance');
const pool = await listV1(auth, `${MSG}/Services/${svc.sid}/PhoneNumbers`,
'phone_numbers');
const [state, detail] = verdict(svc, campaigns, usLongCodes(pool).length);
const line = `${state.padEnd(22)} ${svc.friendly_name ?? svc.sid} ${detail}`;
if (state === 'registered') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'blocked' || state === 'unregistered') {
console.warn(` repair: POST ${MSG}/Services/${svc.sid}/Compliance/Usa2p with ` +
'BrandRegistrationSid, Description, MessageFlow, MessageSamples, ' +
'UsAppToPersonUsecase, HasEmbeddedLinks, HasEmbeddedPhone');
}
}
console.log(`${services.length} service(s), ${bad} unable to send to US numbers`);
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
Two cases carry the note. A campaign that exists but is not VERIFIED has to be as loud as no campaign at all, because it sends identically. And an unregistered service with no US senders has to stay separate from one with senders: same missing campaign, but only one of them is currently losing messages.
from twilio_a2p_registration_audit import us_long_codes, verdict
REGISTERED = {"us_app_to_person_registered": True}
UNREGISTERED = {"us_app_to_person_registered": False}
VERIFIED = [{"sid": "QE0123456789", "campaign_status": "VERIFIED"}]
def test_unregistered_with_us_senders_is_an_outage():
state, detail = verdict(UNREGISTERED, [], 3)
assert state == "blocked"
assert "30034" in detail
def test_unregistered_with_no_us_senders_is_not_the_same_finding():
# Same missing campaign, but nothing is failing yet. Keeping these apart is
# what makes the report worth reading on a big account.
state, _ = verdict(UNREGISTERED, [], 0)
assert state == "unregistered"
def test_verified_campaign_and_flag_agree():
state, detail = verdict(REGISTERED, VERIFIED, 3)
assert state == "registered"
assert "QE0123456789" in detail
def test_campaign_in_progress_sends_like_no_campaign():
state, detail = verdict(REGISTERED, [{"campaign_status": "IN_PROGRESS"}], 2)
assert state == "campaign-in_progress"
assert "no campaign at all" in detail
def test_suspended_campaign_is_not_reported_as_registered():
state, _ = verdict(REGISTERED, [{"campaign_status": "SUSPENDED"}], 1)
assert state == "campaign-suspended"
def test_flag_disagreeing_with_the_subresource_is_reported():
state, _ = verdict(REGISTERED, [], 1)
assert state == "inconsistent"
def test_toll_free_and_short_codes_are_not_10dlc_senders():
pool = [{"phone_number": "+15550001111"}, {"phone_number": "+18885551234"},
{"phone_number": "+447700900123"}, {"phone_number": "12345"}]
assert us_long_codes(pool) == ["+15550001111"]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { usLongCodes, verdict } from './twilio-a2p-registration-audit.mjs';
const REGISTERED = { us_app_to_person_registered: true };
const UNREGISTERED = { us_app_to_person_registered: false };
const VERIFIED = [{ sid: 'QE0123456789', campaign_status: 'VERIFIED' }];
test('unregistered with us senders is an outage', () => {
const [state, detail] = verdict(UNREGISTERED, [], 3);
assert.equal(state, 'blocked');
assert.match(detail, /30034/);
});
test('unregistered with no us senders is not the same finding', () => {
assert.equal(verdict(UNREGISTERED, [], 0)[0], 'unregistered');
});
test('verified campaign and flag agree', () => {
const [state, detail] = verdict(REGISTERED, VERIFIED, 3);
assert.equal(state, 'registered');
assert.match(detail, /QE0123456789/);
});
test('campaign in progress sends like no campaign', () => {
const [state, detail] = verdict(REGISTERED, [{ campaign_status: 'IN_PROGRESS' }], 2);
assert.equal(state, 'campaign-in_progress');
assert.match(detail, /no campaign at all/);
});
test('suspended campaign is not reported as registered', () => {
assert.equal(verdict(REGISTERED, [{ campaign_status: 'SUSPENDED' }], 1)[0],
'campaign-suspended');
});
test('flag disagreeing with the subresource is reported', () => {
assert.equal(verdict(REGISTERED, [], 1)[0], 'inconsistent');
});
test('toll free and short codes are not 10dlc senders', () => {
const pool = [{ phone_number: '+15550001111' }, { phone_number: '+18885551234' },
{ phone_number: '+447700900123' }, { phone_number: '12345' }];
assert.deepEqual(usLongCodes(pool), ['+15550001111']);
});
FAQ
Our brand is approved. Why is this service still failing?
Because A2P registration attaches to the Messaging Service, not to the account or the brand. One approved brand can sit behind several services, and each needs its own campaign. A service created for staging or a new tenant starts with none.
What exactly does 30034 mean?
The message was sent from a US long code that carriers do not recognise as belonging to a registered campaign. It is a carrier-side rejection at send time, so it appears per message rather than as a configuration error you could have caught earlier.
Is us_app_to_person_registered enough on its own?
It is enough to find the unregistered services quickly, which is why the sweep starts there. It is not enough to declare one healthy: the flag can be true while the campaign underneath is IN_PROGRESS, FAILED or SUSPENDED, all of which send exactly like no campaign.
Does sending with an explicit From number avoid this?
No, it makes it worse. A bare From bypasses the Messaging Service entirely, so the number sends outside any registered campaign and gets the same 30034 with nothing to inspect. Send with MessagingServiceSid.
How long after registering can we send?
The campaign has to reach VERIFIED, and then the individual numbers are provisioned with the carriers, which can take up to a day and shows as 30035 or 30024 in the meantime. Re-run the audit the next day rather than treating the POST as the finish line.
Related field notes
- Inbound SMS disappears into a blank sms_url
- A number still points at the demo TwiML
- A number with no fallback URL drops the call
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.
- UsAppToPerson resource — Twilio Docs
- Messaging Service resource — Twilio Docs
- Error 30034: message from an unregistered number — Twilio Docs
- Messaging Service PhoneNumber 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.