Diagnostic Twilio
an a2p campaign parked at IN_PROGRESS is not a live campaign
Registration was submitted, the console went quiet, and three weeks later the launch shipped on the assumption that quiet meant done. It did not. campaign_status still reads IN_PROGRESS, campaign_id is still null, and every US message is coming back 30034. Nothing is broken. It simply was never approved.
Read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p and flag any campaign whose campaign_status is PENDING or IN_PROGRESS and whose date_created is older than your launch SLA. Corroborate with campaign_id, which stays null until the registry issues one.
Two variants are worth separating from plain waiting: a campaign still IN_PROGRESS whose errors[] is already populated, which means the vetting result has landed and the status has not caught up, and one that has a campaign_id while the status still says it is in progress.
The problem in plain words
Every other registration failure gives you something to read. This one gives you an empty errors[] and a status word that sounds like progress. There is no rejection, no code, no callback that says stop — and because nothing has gone wrong, nothing in your monitoring will ever mention it. The campaign is simply not finished, and TCR review has run to three weeks during backlogs.
What turns that into an outage is the deploy. A rollout gated on "we submitted the registration" rather than on campaign_status == "VERIFIED" goes out into a state where numbers in the sender pool cannot reach REGISTERED, so every US message fails 30034 on launch day. The registration was fine. The gate was wrong.
Why it happens
Waiting and failing look identical from the send side. Both produce 30034 on every US message. The only thing that distinguishes "not approved yet" from "rejected" is the campaign resource, and if nobody reads it, the two are the same event with different fixes.
Callbacks are fire and forget. Most integrations register a status callback at submission time and never poll again. A callback missed during a deploy, or an endpoint that 500s once, leaves the campaign parked with no second chance to notice. Polling is one GET and does not depend on your own uptime.
The review has no SLA you can plan against. Sometimes hours, sometimes three weeks. That variance is exactly what makes "it has probably gone through by now" so tempting and so unreliable, and it is why the check has to be a scheduled read rather than a memory.
The status field can lag the outcome. A campaign can carry entries in errors[] while campaign_status still says IN_PROGRESS. Reading only the status there means waiting out a review that has already returned an answer.
The fix, as a flow
The age is an argument to the classifier rather than a clock read inside it, so the SLA boundary, the escalation point and the two states where the fields disagree are all ordinary tests.
How to fix it
Read the campaign on every service, on a schedule
GET https://messaging.twilio.com/v1/Services/{ServiceSid}/Compliance/Usa2p, campaigns under compliance. This belongs in cron rather than in a runbook, because the whole failure mode is that nobody looked again after submitting.
Age the campaign against a launch SLA you actually chose
date_created comes back as ISO 8601 with a trailing Z, which datetime.fromisoformat would not accept before Python 3.11. Compare the age against a number you picked — seven days is a reasonable "somebody should look", twenty-one is the point Twilio Support becomes the next step.
Corroborate with campaign_id
campaign_id is null until the registry issues one, so a campaign that is IN_PROGRESS with no campaign_id is genuinely still in review. One that has a campaign_id while the status has not moved is a disagreement, and worth reporting as such rather than picking whichever field you read first.
Check errors[] even while the status says in progress
An empty errors[] is part of what confirms a campaign is still waiting. A populated one under an IN_PROGRESS status means the vetting result has arrived and the status is behind it, so there is something to read and act on now rather than more waiting to do.
Gate the rollout on VERIFIED, and have an interim sender
There is no API action that speeds this up. What you control is the release gate: check campaign_status == "VERIFIED" before enabling US sends, and route the interim traffic through a verified toll-free number or Twilio Verify. Escalate to Support past about three weeks, quoting the campaign SID.
How to check it worked
Re-run the script. Every campaign should report verified, and nothing should be sitting past the SLA.
python3 twilio_a2p_campaign_wait_audit.py --sla-days 7
# 4 service(s), 0 campaign(s) still waiting past 7 days
The full code
One paginated GET for the services and one per service for its campaign, reads only, with an API Key that has read access. The clock is kept out of the classifier: verdict() takes an age in days, so the interesting decisions — waiting, overdue, escalate, and the two states where the fields disagree — can be tested without freezing time.
"""Report A2P 10DLC campaigns still waiting for approval past a launch SLA.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. Nothing here can speed up a review; the
script exists so a rollout is gated on VERIFIED rather than on a memory of
having submitted the registration.
"""
import argparse
import datetime
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_campaign_wait_audit")
MSG = "https://messaging.twilio.com/v1"
WAITING = ("PENDING", "IN_PROGRESS")
def parse_time(value):
"""Parse a messaging v1 timestamp. Pure.
These come back as ISO 8601 with a trailing Z, which
datetime.fromisoformat did not accept before Python 3.11.
"""
text = str(value or "").strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.datetime.fromisoformat(text)
except ValueError:
return None
def age_days(date_created, now):
"""Age of a campaign in days, or None when the timestamp is unreadable."""
created = parse_time(date_created)
if created is None or now is None:
return None
return (now - created).total_seconds() / 86400.0
def verdict(campaign, age, sla_days=7, escalate_days=21):
"""Classify one UsAppToPerson campaign that may still be in review.
`age` is the campaign's age in days, or None. Taking it as an argument keeps
the clock out of the classifier, so every state below is testable without
freezing time. Returns (state, detail).
"""
if not campaign:
return ("no-campaign", "no A2P campaign on this Messaging Service.")
status = str(campaign.get("campaign_status") or "").upper()
campaign_id = str(campaign.get("campaign_id") or "").strip()
errors = campaign.get("errors") or []
if status == "VERIFIED":
if not campaign_id:
return ("verified-no-campaign-id",
"campaign_status is VERIFIED but campaign_id is null, which "
"is what an unfinished registration looks like.")
return ("verified", "VERIFIED with campaign_id %s" % campaign_id)
if status in ("FAILED", "SUSPENDED"):
return ("not-waiting",
"campaign_status is %s: this is a rejection, not a queue. Read "
"errors[] rather than waiting any longer." % status)
if status not in WAITING:
return ("unknown-status",
"campaign_status is %s, which this script does not recognise."
% (status or "unset"))
if errors:
return ("waiting-with-errors",
"still %s, but errors[] already has %d entr%s: the vetting "
"result has arrived and the status is behind it."
% (status, len(errors), "y" if len(errors) == 1 else "ies"))
if campaign_id:
return ("waiting-with-campaign-id",
"still %s, but campaign_id is %s. The registry has issued an "
"id while the status says the review is running."
% (status, campaign_id))
if age is None:
return ("waiting-unknown-age",
"still %s and date_created could not be read, so this cannot be "
"aged against the SLA." % status)
if age >= escalate_days:
return ("escalate",
"still %s after %.0f days. Past about three weeks this is a "
"support ticket quoting the campaign SID, not more waiting."
% (status, age))
if age >= sla_days:
return ("overdue",
"still %s after %.0f days, past the %d day SLA. US sends will "
"keep returning 30034 until it is VERIFIED."
% (status, age, sla_days))
return ("waiting",
"still %s after %.0f days, inside the %d day SLA. Not live yet: do "
"not enable US sends." % (status, age, sla_days))
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("--sla-days", type=int, default=7,
help="how long a campaign may sit in review before it is a finding")
ap.add_argument("--escalate-days", type=int, default=21,
help="age past which this becomes a support ticket")
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)
now = datetime.datetime.now(datetime.timezone.utc)
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:
campaigns = list_v1(session,
"%s/Services/%s/Compliance/Usa2p" % (MSG, svc["sid"]),
"compliance")
campaign = campaigns[0] if campaigns else None
age = age_days((campaign or {}).get("date_created"), now)
state, detail = verdict(campaign, age, args.sla_days, args.escalate_days)
name = svc.get("friendly_name") or svc["sid"]
line = "%-24s %s %s" % (state, name, detail)
if state in ("verified", "waiting"):
log.info(line)
continue
bad += 1
log.warning(line)
if state in ("overdue", "escalate", "waiting-unknown-age"):
log.warning(" repair: none by API. Gate the rollout on "
"campaign_status == VERIFIED and send the interim traffic "
"from a verified toll-free number or Twilio Verify")
elif state == "waiting-with-errors":
log.warning(" repair: read errors[] on %s now; it has already been "
"reviewed", campaign.get("sid", "the campaign"))
log.info("%d service(s), %d campaign(s) still waiting past %d days",
len(services), bad, args.sla_days)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report A2P 10DLC campaigns still waiting for approval past a launch SLA.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. Nothing here can speed up a
* review; the script exists so a rollout is gated on VERIFIED.
*/
const MSG = 'https://messaging.twilio.com/v1';
const WAITING = ['PENDING', 'IN_PROGRESS'];
/** Parse a messaging v1 ISO 8601 timestamp. Pure. Returns a Date or null. */
export function parseTime(value) {
const text = String(value ?? '').trim();
if (!text) return null;
const t = Date.parse(text);
return Number.isNaN(t) ? null : new Date(t);
}
/** Age of a campaign in days, or null when the timestamp is unreadable. */
export function ageDays(dateCreated, now) {
const created = parseTime(dateCreated);
if (created === null || !now) return null;
return (now.getTime() - created.getTime()) / 86400000;
}
/**
* Classify one UsAppToPerson campaign that may still be in review. `age` is in
* days, or null; taking it as an argument keeps the clock out of the
* classifier. Pure. Returns [state, detail].
*/
export function verdict(campaign, age, slaDays = 7, escalateDays = 21) {
if (!campaign) return ['no-campaign', 'no A2P campaign on this Messaging Service.'];
const status = String(campaign.campaign_status ?? '').toUpperCase();
const campaignId = String(campaign.campaign_id ?? '').trim();
const errors = campaign.errors ?? [];
if (status === 'VERIFIED') {
if (!campaignId) {
return ['verified-no-campaign-id',
'campaign_status is VERIFIED but campaign_id is null, which is what an ' +
'unfinished registration looks like.'];
}
return ['verified', `VERIFIED with campaign_id ${campaignId}`];
}
if (status === 'FAILED' || status === 'SUSPENDED') {
return ['not-waiting',
`campaign_status is ${status}: this is a rejection, not a queue. Read ` +
'errors[] rather than waiting any longer.'];
}
if (!WAITING.includes(status)) {
return ['unknown-status',
`campaign_status is ${status || 'unset'}, which this script does not recognise.`];
}
if (errors.length) {
return ['waiting-with-errors',
`still ${status}, but errors[] already has ${errors.length} ` +
`entr${errors.length === 1 ? 'y' : 'ies'}: the vetting result has ` +
'arrived and the status is behind it.'];
}
if (campaignId) {
return ['waiting-with-campaign-id',
`still ${status}, but campaign_id is ${campaignId}. The registry has ` +
'issued an id while the status says the review is running.'];
}
if (age === null) {
return ['waiting-unknown-age',
`still ${status} and date_created could not be read, so this cannot be ` +
'aged against the SLA.'];
}
if (age >= escalateDays) {
return ['escalate',
`still ${status} after ${age.toFixed(0)} days. Past about three weeks ` +
'this is a support ticket quoting the campaign SID, not more waiting.'];
}
if (age >= slaDays) {
return ['overdue',
`still ${status} after ${age.toFixed(0)} days, past the ${slaDays} day ` +
'SLA. US sends will keep returning 30034 until it is VERIFIED.'];
}
return ['waiting',
`still ${status} after ${age.toFixed(0)} days, inside the ${slaDays} day ` +
'SLA. Not live yet: do not enable US sends.'];
}
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 slaFlag = process.argv.indexOf('--sla-days');
const slaDays = slaFlag >= 0 ? Number(process.argv[slaFlag + 1]) : 7;
const now = new Date();
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 campaign = campaigns[0] ?? null;
const age = ageDays(campaign?.date_created, now);
const [state, detail] = verdict(campaign, age, slaDays);
const name = svc.friendly_name ?? svc.sid;
const line = `${state.padEnd(24)} ${name} ${detail}`;
if (state === 'verified' || state === 'waiting') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'overdue' || state === 'escalate' || state === 'waiting-unknown-age') {
console.warn(' repair: none by API. Gate the rollout on campaign_status == ' +
'VERIFIED and send the interim traffic from a verified toll-free ' +
'number or Twilio Verify');
} else if (state === 'waiting-with-errors') {
console.warn(` repair: read errors[] on ${campaign.sid ?? 'the campaign'} ` +
'now; it has already been reviewed');
}
}
console.log(`${services.length} service(s), ${bad} campaign(s) still waiting past ` +
`${slaDays} days`);
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
Because the age is an argument rather than a clock read inside the function, the boundary cases are ordinary tests: one day inside the SLA, one day past it, and past the escalation point. The two that earn their place are the disagreements — IN_PROGRESS with a populated errors[], and VERIFIED with a null campaign_id — because both read as fine if you only look at the status word.
import datetime
from twilio_a2p_campaign_wait_audit import age_days, verdict
IN_PROGRESS = {"sid": "QE0123456789", "campaign_status": "IN_PROGRESS"}
NOW = datetime.datetime(2026, 8, 30, tzinfo=datetime.timezone.utc)
def test_inside_the_sla_is_waiting_not_a_finding():
state, detail = verdict(IN_PROGRESS, 3.0, sla_days=7)
assert state == "waiting"
assert "do not enable US sends" in detail
def test_past_the_sla_is_overdue():
state, detail = verdict(IN_PROGRESS, 9.0, sla_days=7)
assert state == "overdue"
assert "30034" in detail
def test_past_three_weeks_is_a_support_ticket():
state, _ = verdict(IN_PROGRESS, 25.0, sla_days=7, escalate_days=21)
assert state == "escalate"
def test_in_progress_with_errors_is_already_decided():
# The status lags the outcome. Waiting longer here achieves nothing.
state, detail = verdict(dict(IN_PROGRESS, errors=[{"error_code": 30886}]), 2.0)
assert state == "waiting-with-errors"
assert "1 entry" in detail
def test_a_campaign_id_while_still_in_progress_is_a_disagreement():
state, _ = verdict(dict(IN_PROGRESS, campaign_id="CX123"), 2.0)
assert state == "waiting-with-campaign-id"
def test_verified_without_a_campaign_id_is_not_reported_as_live():
state, _ = verdict({"campaign_status": "VERIFIED", "campaign_id": None}, 30.0)
assert state == "verified-no-campaign-id"
def test_failed_is_a_rejection_not_a_queue():
state, detail = verdict({"campaign_status": "FAILED"}, 30.0)
assert state == "not-waiting"
assert "errors[]" in detail
def test_age_days_reads_the_trailing_z_timestamp():
assert round(age_days("2026-08-23T00:00:00Z", NOW)) == 7
assert age_days("not a date", NOW) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ageDays, verdict } from './twilio-a2p-campaign-wait-audit.mjs';
const IN_PROGRESS = { sid: 'QE0123456789', campaign_status: 'IN_PROGRESS' };
const NOW = new Date('2026-08-30T00:00:00Z');
test('inside the sla is waiting, not a finding', () => {
const [state, detail] = verdict(IN_PROGRESS, 3.0, 7);
assert.equal(state, 'waiting');
assert.match(detail, /do not enable US sends/);
});
test('past the sla is overdue', () => {
const [state, detail] = verdict(IN_PROGRESS, 9.0, 7);
assert.equal(state, 'overdue');
assert.match(detail, /30034/);
});
test('past three weeks is a support ticket', () => {
assert.equal(verdict(IN_PROGRESS, 25.0, 7, 21)[0], 'escalate');
});
test('in progress with errors is already decided', () => {
const [state, detail] = verdict({ ...IN_PROGRESS, errors: [{ error_code: 30886 }] },
2.0);
assert.equal(state, 'waiting-with-errors');
assert.match(detail, /1 entry/);
});
test('a campaign id while still in progress is a disagreement', () => {
assert.equal(verdict({ ...IN_PROGRESS, campaign_id: 'CX123' }, 2.0)[0],
'waiting-with-campaign-id');
});
test('verified without a campaign id is not reported as live', () => {
assert.equal(verdict({ campaign_status: 'VERIFIED', campaign_id: null }, 30.0)[0],
'verified-no-campaign-id');
});
test('failed is a rejection, not a queue', () => {
const [state, detail] = verdict({ campaign_status: 'FAILED' }, 30.0);
assert.equal(state, 'not-waiting');
assert.match(detail, /errors/);
});
test('ageDays reads the trailing z timestamp', () => {
assert.equal(Math.round(ageDays('2026-08-23T00:00:00Z', NOW)), 7);
assert.equal(ageDays('not a date', NOW), null);
});
FAQ
How long should a campaign take to reach VERIFIED?
Sometimes hours, sometimes three weeks during a registry backlog. There is no SLA you can plan a launch around, which is why the release has to be gated on the status rather than on elapsed time or on somebody's recollection of having submitted it.
Is there any way to speed up the review?
No API action exists. Past about three weeks it is worth a Twilio Support ticket quoting the campaign SID, but before that there is nothing to do except not ship the US traffic yet. Deleting and resubmitting puts you at the back of the same queue and pays the vetting fee again.
Why is campaign_id worth reading if I already have campaign_status?
Because it is issued by the registry rather than set alongside the status, so the two can disagree. A VERIFIED campaign with a null campaign_id, or an IN_PROGRESS one that already has an id, is a state worth looking at rather than trusting.
What can we send in the meantime?
A verified toll-free number, or Twilio Verify for one-time passcodes. Both are separate registration paths from 10DLC, so neither is blocked by this campaign. What you cannot do is send from an unregistered long code and hope.
Does the script poll for me?
It performs one read per Messaging Service, so putting it in cron with a sensible SLA is the intended use. It never writes: a script that resubmits a compliance registration on a timer can burn the review queue and the vetting fee at the same time.
Related field notes
- A campaign is FAILED and errors[] names the field
- An A2P brand stuck at FAILED blocks every campaign
- An unverified toll-free number is blocked outright
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
- Troubleshooting and rectifying A2P campaigns — Twilio Docs
- Error 30034: message from an unregistered number — Twilio Docs
- Messaging Service 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.