Skip to content

Diagnostic Twilio

a 10DLC number outside the sender pool is never registered

Somebody bought a second number for the marketing send, because the support number was busy. The brand is APPROVED, the campaign is VERIFIED, so the number is registered — that is how everyone on the team understood it. Except registration does not attach to an account or to a brand. It attaches to numbers, one at a time, through the sender pool of the Messaging Service that carries the campaign, and this number was never added to one.

Read-only key Python and Node.js Tests included
A grocery store filled with lots of bags of food
Photo by Bernd 📷 Dittrich on Unsplash
The short answer

Take a set difference. GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json gives every number you own; keep the SMS-capable +1 long codes. GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers per Messaging Service gives every number in a pool. Anything in the first list and not in the second is UNREGISTERED, whatever the brand and campaign say.

Cross-check against Messages.json rows with error_code 30034 grouped by from, which tells you which of those gaps is already costing you deliveries and which is merely waiting to.

The problem in plain words

Every status a team checks is green. The brand is APPROVED, the campaign is VERIFIED, the Messaging Service reports us_app_to_person_registered as true. Then one number returns 30034 on everything it sends, and 30034 is documented as an unregistered number, which reads as a contradiction of all three.

It is not. The campaign is a registration of a use case; the pool is the list of numbers that registration covers. Adding a number to your account, or setting it as a From in your code, never touches the pool. So the failure is a gap between two lists that no single API response shows, and nothing errors at the moment the gap is created — not when you buy the number, not when you deploy the code that sends from it.

The send path makes it worse. Code that sets From=+1… directly bypasses the Messaging Service entirely, which means it bypasses the object that carries the campaign. That pattern is why the gap survives: the number works for inbound, it works for voice, it accepts the API call, and it fails only on the carrier's side of a US A2P send.

Number boughtSMS capable, noerrorNever added toa poolnothing submittedanywhereCode sets Fromdirectlyservice bypassedentirelyCarrier sees nocampaign30034 on every USsendBrand andcampaign greenso the number isblamed last
Every status a team checks is green. The gap is an absence from a list, and an absence is not something any single response reports.

Why it happens

Nothing in the account view is number-shaped. The A2P console and the compliance API are organised by brand, campaign and Messaging Service. Numbers appear inside a service's pool, so a number that is in no service appears in no A2P view at all. Absence is genuinely hard to see.

30034 is the same code for four different causes. No campaign at all, a failed campaign, a suspended one, and a number outside the pool all return it. The code tells you the carrier considered the sender unregistered, which is true in all four cases and diagnostic in none.

Buying a number never fails. The purchase succeeds, the number is SMS-capable, and the first US send is the first thing that fails. In a system that buys numbers programmatically that is a very long feedback loop.

A bare From looks like the simple option. It is one field instead of one field, and it works everywhere except US A2P. Teams that started before 10DLC still have it in the send path, and the code that reads it has no idea a Messaging Service exists.

A recently added number is a different finding. A number that is in the pool and still failing may be inside the carrier's registration window rather than misconfigured, and waiting is the correct action there. Reporting the two as one thing sends people to remove and re-add a number that was going to work in an hour.

The fix, as a flow

Two lists and a difference: every SMS capable US long code the account owns, and every number sitting in a Messaging Service pool. Nothing in the API shows both at once.

Owned numbers minus pooledjoined to the 30034s by fromIn a registered poolcovered by the campaignIn no pool, no trafficwill fail at launchIn no pool, failing nowunregistered, add itPool has no campaignfix the service, not the number
Toll-free numbers are excluded on purpose. They verify on a different path and fail with 30032, and mixing them hides both problems.

How to fix it

List every number the account owns

GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=1000, following next_page_uri. Keep the rows where capabilities.sms is true.

Narrow to the numbers 10DLC actually governs

US long codes only: +1, eleven digits, and not toll-free. Toll-free numbers under +1800, +1833, +1844, +1855, +1866, +1877 and +1888 have their own verification path and belong in a different report. Short codes and non-US numbers are out of scope entirely.

Build the pool from every Messaging Service

GET https://messaging.twilio.com/v1/Services, then GET /v1/Services/{ServiceSid}/PhoneNumbers for each. Keep the service alongside each number: whether that service has a campaign is the difference between two of the findings below.

Take the difference, and read us_app_to_person_registered on the service

A number in no pool is unregistered. A number in a pool whose service reports us_app_to_person_registered as false is also unregistered, and the repair is different: that service needs a campaign, not that number a pool.

Join the 30034s by from, to separate the urgent from the latent

Page Messages.json and group error_code 30034 by from. A gap with failures is live breakage. A gap with no traffic is a number that will fail the first time somebody uses it, which is worth fixing while it is cheap.

Add the number to the pool, then stop sending with a bare From

POST /v1/Services/{ServiceSid}/PhoneNumbers with PhoneNumberSid=PN…, and switch the send to MessagingServiceSid=MG…. Registration at the carrier takes up to 24 hours after that, so expect the number to keep failing briefly — with 30035 rather than 30034, which is how you know it worked.

How to check it worked

Re-run the script. Every SMS-capable US long code should sit in the pool of a Messaging Service that has a campaign.

python3 twilio_10dlc_sender_pool_gap.py
# 11 US long code(s), 0 outside a registered sender pool

The full code

Three lists and a difference: the account's numbers, each Messaging Service's pool, and the 30034s from the Messages window. The classifier takes one number, the service holding it (or None) and that number's failures, which is why the difference between a gap that is breaking today and a gap that will break at launch is a test rather than a comment.

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_10dlc_sender_pool_gap.py
"""Find SMS-capable US long codes that sit outside any registered A2P sender pool.

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

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_10dlc_sender_pool_gap")

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

UNREGISTERED = "30034"
# Toll-free has its own verification path and its own failure code, 30032. A
# report that mixes the two sends people to the wrong console page.
TOLLFREE_NPA = {"800", "833", "844", "855", "866", "877", "888"}


def is_us_long_code(phone_number):
    """True for a +1 ten digit number that is not toll-free. Pure.

    Short codes, non-US numbers and toll-free numbers are all out of scope for
    10DLC, and each of them has a different registration story.
    """
    number = str(phone_number or "")
    if not number.startswith("+1") or len(number) != 12 or not number[1:].isdigit():
        return False
    return number[2:5] not in TOLLFREE_NPA


def sms_capable(number):
    return bool((number.get("capabilities") or {}).get("sms"))


def bare_from_share(failures):
    """The share of these failures sent with a From rather than a service SID.

    A bare From bypasses the Messaging Service, and therefore the campaign the
    service carries, which is how a gap survives a green compliance dashboard.
    """
    if not failures:
        return 0.0
    bare = len([m for m in failures if not m.get("messaging_service_sid")])
    return bare / float(len(failures))


def verdict(number, service, failures):
    """Classify one owned number. Pure.

    number is an IncomingPhoneNumbers row, service is the Messaging Service
    whose pool contains it or None, failures are that number's 30034 rows.
    Returns (state, detail).
    """
    phone = str(number.get("phone_number") or "")
    if not sms_capable(number):
        return ("not-in-scope", "capabilities.sms is false: not an SMS sender.")
    if not is_us_long_code(phone):
        return ("not-in-scope",
                "not a US long code, so 10DLC registration does not govern it. "
                "Toll-free numbers verify separately and fail with 30032.")

    if service is None:
        if failures:
            return ("sending-direct",
                    "%d x 30034 from a number that is in no Messaging Service "
                    "pool, %.0f%% of them sent with a bare From. A2P approval "
                    "attaches through the pool, so this number is UNREGISTERED "
                    "whatever the brand and campaign say."
                    % (len(failures), bare_from_share(failures) * 100))
        return ("outside-the-pool",
                "SMS capable US long code in no Messaging Service pool, with no "
                "traffic yet. The first US A2P send from it will 30034.")

    name = service.get("friendly_name") or service.get("sid") or "?"
    if not service.get("us_app_to_person_registered"):
        return ("pool-without-a-campaign",
                "in the pool of %s, which has no A2P campaign at all. The pool "
                "is not the problem here; the service is." % name)

    if failures:
        return ("registered-but-failing",
                "%d x 30034 from a number that is already in %s. Either it was "
                "added in the last two weeks and is still PENDING_REGISTRATION, "
                "or the brand is Sole Proprietor and this is the extra number "
                "that never registers." % (len(failures), name))

    return ("registered", "in the pool of %s, which has a campaign." % name)


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_2010(session, url, key, limit=2000, **params):
    """Page a 2010-04-01 list. next_page_uri is a path, not a URL."""
    out = []
    params = dict(params, PageSize=1000)
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get(key, []))
        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=7,
                    help="how far back to read the Messages list for 30034s")
    ap.add_argument("--max-messages", type=int, default=20000,
                    help="stop paging the Messages list after this many rows")
    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)

    numbers = list_2010(session, "%s/Accounts/%s/IncomingPhoneNumbers.json"
                        % (BASE, account), "incoming_phone_numbers")

    pool = {}
    for service in list_v1(session, MSG + "/Services", "services"):
        for entry in list_v1(session, "%s/Services/%s/PhoneNumbers"
                             % (MSG, service["sid"]), "phone_numbers"):
            pool[str(entry.get("phone_number"))] = service

    since = (datetime.now(timezone.utc)
             - timedelta(days=args.days)).strftime("%Y-%m-%d")
    failures = {}
    for message in list_2010(session, "%s/Accounts/%s/Messages.json" % (BASE, account),
                             "messages", args.max_messages,
                             **{"DateSent>=": since}):
        if str(message.get("error_code") or "") == UNREGISTERED:
            failures.setdefault(str(message.get("from") or ""), []).append(message)

    in_scope = bad = 0
    for number in numbers:
        phone = str(number.get("phone_number") or "")
        state, detail = verdict(number, pool.get(phone), failures.get(phone, []))
        if state == "not-in-scope":
            continue
        in_scope += 1
        line = "%-23s %s  %s" % (state, phone, detail)
        if state == "registered":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state in ("sending-direct", "outside-the-pool"):
            log.warning("  repair: POST %s/Services/{ServiceSid}/PhoneNumbers with "
                        "PhoneNumberSid=%s, then send with MessagingServiceSid "
                        "rather than a bare From", MSG, number.get("sid", "PN..."))
        elif state == "pool-without-a-campaign":
            log.warning("  repair: register a campaign on that Messaging Service "
                        "before touching the pool")
        else:
            log.warning("  repair: wait out the carrier registration window "
                        "before changing anything; removing and re-adding the "
                        "number restarts it")

    log.info("%d US long code(s), %d outside a registered sender pool",
             in_scope, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-10dlc-sender-pool-gap.mjs
/**
 * Find SMS-capable US long codes that sit outside any registered A2P sender pool.
 *
 * 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 UNREGISTERED = '30034';
// Toll-free has its own verification path and its own failure code, 30032.
const TOLLFREE_NPA = new Set(['800', '833', '844', '855', '866', '877', '888']);

/**
 * True for a +1 ten digit number that is not toll-free. Pure. Short codes,
 * non-US numbers and toll-free numbers are all out of scope for 10DLC.
 */
export function isUsLongCode(phoneNumber) {
  const number = String(phoneNumber ?? '');
  if (!number.startsWith('+1') || number.length !== 12) return false;
  if (!/^[0-9]+$/.test(number.slice(1))) return false;
  return !TOLLFREE_NPA.has(number.slice(2, 5));
}

export function smsCapable(number) {
  return Boolean(number.capabilities?.sms);
}

/**
 * The share of these failures sent with a From rather than a service SID. A
 * bare From bypasses the Messaging Service, and therefore the campaign it
 * carries, which is how a gap survives a green compliance dashboard.
 */
export function bareFromShare(failures) {
  if (failures.length === 0) return 0;
  return failures.filter((m) => !m.messaging_service_sid).length / failures.length;
}

/**
 * Classify one owned number. Pure. number is an IncomingPhoneNumbers row,
 * service is the Messaging Service whose pool contains it or null, failures are
 * that number's 30034 rows. Returns [state, detail].
 */
export function verdict(number, service, failures) {
  const phone = String(number.phone_number ?? '');
  if (!smsCapable(number)) {
    return ['not-in-scope', 'capabilities.sms is false: not an SMS sender.'];
  }
  if (!isUsLongCode(phone)) {
    return ['not-in-scope',
      'not a US long code, so 10DLC registration does not govern it. Toll-free ' +
      'numbers verify separately and fail with 30032.'];
  }

  if (!service) {
    if (failures.length) {
      return ['sending-direct',
        `${failures.length} x 30034 from a number that is in no Messaging ` +
        `Service pool, ${(bareFromShare(failures) * 100).toFixed(0)}% of them ` +
        'sent with a bare From. A2P approval attaches through the pool, so this ' +
        'number is UNREGISTERED whatever the brand and campaign say.'];
    }
    return ['outside-the-pool',
      'SMS capable US long code in no Messaging Service pool, with no traffic ' +
      'yet. The first US A2P send from it will 30034.'];
  }

  const name = service.friendly_name ?? service.sid ?? '?';
  if (!service.us_app_to_person_registered) {
    return ['pool-without-a-campaign',
      `in the pool of ${name}, which has no A2P campaign at all. The pool is ` +
      'not the problem here; the service is.'];
  }

  if (failures.length) {
    return ['registered-but-failing',
      `${failures.length} x 30034 from a number that is already in ${name}. ` +
      'Either it was added in the last two weeks and is still ' +
      'PENDING_REGISTRATION, or the brand is Sole Proprietor and this is the ' +
      'extra number that never registers.'];
  }

  return ['registered', `in the pool of ${name}, which has a campaign.`];
}

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 list2010(auth, url, key, limit = 2000, extra = {}) {
  const out = [];
  let next = url;
  let params = { ...extra, PageSize: 1000 };
  while (next && out.length < limit) {
    const page = await get(auth, next, params);
    out.push(...(page[key] ?? []));
    next = 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 numbers = await list2010(auth,
    `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`, 'incoming_phone_numbers');

  const pool = new Map();
  for (const service of await listV1(auth, `${MSG}/Services`, 'services')) {
    for (const entry of await listV1(auth,
      `${MSG}/Services/${service.sid}/PhoneNumbers`, 'phone_numbers')) {
      pool.set(String(entry.phone_number), service);
    }
  }

  const days = Number(process.argv.includes('--days')
    ? process.argv[process.argv.indexOf('--days') + 1] : 7) || 7;
  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);

  const failures = new Map();
  const messages = await list2010(auth, `${BASE}/Accounts/${account}/Messages.json`,
    'messages', 20000, { 'DateSent>=': since });
  for (const message of messages) {
    if (String(message.error_code ?? '') !== UNREGISTERED) continue;
    const from = String(message.from ?? '');
    failures.set(from, [...(failures.get(from) ?? []), message]);
  }

  let inScope = 0;
  let bad = 0;
  for (const number of numbers) {
    const phone = String(number.phone_number ?? '');
    const [state, detail] = verdict(number, pool.get(phone) ?? null,
                                    failures.get(phone) ?? []);
    if (state === 'not-in-scope') continue;
    inScope += 1;
    const line = `${state.padEnd(23)} ${phone}  ${detail}`;
    if (state === 'registered') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (state === 'sending-direct' || state === 'outside-the-pool') {
      console.warn(`  repair: POST ${MSG}/Services/{ServiceSid}/PhoneNumbers ` +
                   `with PhoneNumberSid=${number.sid ?? 'PN...'}, then send with ` +
                   'MessagingServiceSid rather than a bare From');
    } else if (state === 'pool-without-a-campaign') {
      console.warn('  repair: register a campaign on that Messaging Service ' +
                   'before touching the pool');
    } else {
      console.warn('  repair: wait out the carrier registration window before ' +
                   'changing anything; removing and re-adding the number ' +
                   'restarts it');
    }
  }

  console.log(`${inScope} US long code(s), ${bad} outside a registered sender pool`);
  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

Five states, and the ones worth separating are the three that all render as “this number 30034s”: no pool at all, a pool on a service with no campaign, and a pool on a registered service where the number is simply new. They need three different actions, and one of those actions is to do nothing. The scope tests keep toll-free numbers out, because their failure code is 30032 and their console page is somewhere else.

test_twilio_10dlc_sender_pool_gap.py
from twilio_10dlc_sender_pool_gap import (bare_from_share, is_us_long_code,
                                             verdict)

LONG_CODE = {"sid": "PN1", "phone_number": "+15125550123",
             "capabilities": {"sms": True, "voice": True}}
REGISTERED = {"sid": "MG1", "friendly_name": "prod",
              "us_app_to_person_registered": True}
UNREGISTERED_SERVICE = {"sid": "MG2", "friendly_name": "staging",
                        "us_app_to_person_registered": False}


def fail(**kw):
    return dict({"error_code": 30034, "from": "+15125550123"}, **kw)


def test_a_number_in_no_pool_that_is_failing_is_sending_direct():
    state, detail = verdict(LONG_CODE, None, [fail(), fail()])
    assert state == "sending-direct"
    assert "100%" in detail and "UNREGISTERED" in detail


def test_a_number_in_no_pool_with_no_traffic_is_latent_not_broken():
    state, detail = verdict(LONG_CODE, None, [])
    assert state == "outside-the-pool"
    assert "will 30034" in detail


def test_a_pool_on_a_service_with_no_campaign_points_at_the_service():
    state, detail = verdict(LONG_CODE, UNREGISTERED_SERVICE, [fail()])
    assert state == "pool-without-a-campaign"
    assert "staging" in detail


def test_a_pooled_number_that_still_fails_may_just_be_new():
    # The one finding here where the right action is to wait rather than change
    # anything, so it must not share a state with the gaps.
    state, detail = verdict(LONG_CODE, REGISTERED, [fail()])
    assert state == "registered-but-failing"
    assert "PENDING_REGISTRATION" in detail


def test_a_pooled_number_with_no_failures_is_clean():
    assert verdict(LONG_CODE, REGISTERED, [])[0] == "registered"


def test_toll_free_is_out_of_scope_and_says_why():
    tf = dict(LONG_CODE, phone_number="+18885550123")
    state, detail = verdict(tf, None, [])
    assert state == "not-in-scope"
    assert "30032" in detail


def test_a_number_that_cannot_send_sms_is_out_of_scope():
    voice_only = dict(LONG_CODE, capabilities={"sms": False, "voice": True})
    assert verdict(voice_only, None, [fail()])[0] == "not-in-scope"


def test_scope_is_us_ten_digit_long_codes_only():
    assert is_us_long_code("+15125550123")
    assert not is_us_long_code("+442071838750")
    assert not is_us_long_code("+18445550123")
    assert not is_us_long_code("12345")
    assert not is_us_long_code(None)


def test_a_send_carrying_a_service_sid_is_not_a_bare_from():
    assert bare_from_share([fail(messaging_service_sid="MG1"), fail()]) == 0.5
twilio-10dlc-sender-pool-gap.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { bareFromShare, isUsLongCode, verdict }
  from './twilio-10dlc-sender-pool-gap.mjs';

const LONG_CODE = { sid: 'PN1', phone_number: '+15125550123',
  capabilities: { sms: true, voice: true } };
const REGISTERED = { sid: 'MG1', friendly_name: 'prod',
  us_app_to_person_registered: true };
const UNREGISTERED_SERVICE = { sid: 'MG2', friendly_name: 'staging',
  us_app_to_person_registered: false };

const fail = (extra = {}) => ({ error_code: 30034, from: '+15125550123', ...extra });

test('a number in no pool that is failing is sending direct', () => {
  const [state, detail] = verdict(LONG_CODE, null, [fail(), fail()]);
  assert.equal(state, 'sending-direct');
  assert.match(detail, /100%/);
  assert.match(detail, /UNREGISTERED/);
});

test('a number in no pool with no traffic is latent, not broken', () => {
  const [state, detail] = verdict(LONG_CODE, null, []);
  assert.equal(state, 'outside-the-pool');
  assert.match(detail, /will 30034/);
});

test('a pool on a service with no campaign points at the service', () => {
  const [state, detail] = verdict(LONG_CODE, UNREGISTERED_SERVICE, [fail()]);
  assert.equal(state, 'pool-without-a-campaign');
  assert.match(detail, /staging/);
});

test('a pooled number that still fails may just be new', () => {
  const [state, detail] = verdict(LONG_CODE, REGISTERED, [fail()]);
  assert.equal(state, 'registered-but-failing');
  assert.match(detail, /PENDING_REGISTRATION/);
});

test('a pooled number with no failures is clean', () => {
  assert.equal(verdict(LONG_CODE, REGISTERED, [])[0], 'registered');
});

test('toll free is out of scope and says why', () => {
  const [state, detail] = verdict({ ...LONG_CODE, phone_number: '+18885550123' },
                                  null, []);
  assert.equal(state, 'not-in-scope');
  assert.match(detail, /30032/);
});

test('a number that cannot send sms is out of scope', () => {
  const voiceOnly = { ...LONG_CODE, capabilities: { sms: false, voice: true } };
  assert.equal(verdict(voiceOnly, null, [fail()])[0], 'not-in-scope');
});

test('scope is us ten digit long codes only', () => {
  assert.equal(isUsLongCode('+15125550123'), true);
  assert.equal(isUsLongCode('+442071838750'), false);
  assert.equal(isUsLongCode('+18445550123'), false);
  assert.equal(isUsLongCode('12345'), false);
  assert.equal(isUsLongCode(null), false);
});

test('a send carrying a service sid is not a bare from', () => {
  assert.equal(bareFromShare([fail({ messaging_service_sid: 'MG1' }), fail()]), 0.5);
});

FAQ

The brand is APPROVED and the campaign VERIFIED. How can a number be unregistered?

Because A2P approval attaches to numbers through the sender pool of the Messaging Service that carries the campaign, and carriers register each number individually after the campaign is approved. A number you own but never added to that pool was never submitted to anyone.

Does sending with From instead of MessagingServiceSid actually matter?

For US A2P, yes. A bare From bypasses the Messaging Service, and the campaign lives on the service. It also loses everything else the service does: sender selection, sticky sender, the pool-level status callback and scheduling.

How do I tell this apart from a campaign that was never registered?

By where the number is. If it is in no pool at all, the number is the gap. If it is in a pool whose service reports us_app_to_person_registered as false, the service is the gap and adding numbers to it changes nothing. The script reports those as different states for that reason.

The number is in the pool and it still 30034s. What now?

Wait, probably. Carrier registration after a number joins a pool takes up to 24 hours and can show as PENDING_REGISTRATION for longer after a campaign approval backlog. The other cause is a Sole Proprietor brand, which allows exactly one number on its one campaign, so any extras stay unregistered forever.

Why not just check the console?

The A2P views are organised by brand, campaign and Messaging Service, so a number in no service appears in none of them. The gap is between two lists, and only one of those lists is on the compliance side.

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.