Skip to content

Diagnostic Twilio

two toll-free numbers in one sender pool get both blocked

Two toll-free numbers were put in one Messaging Service so the send would go faster. Both were verified. For a few weeks it did go faster. Then toll-free traffic through that service started failing with 30032 — across the whole pool, including the number that had been verified and sending since last year.

Read-only key Python and Node.js Tests included
A grocery store
Photo by Brad on Unsplash
The short answer

Read each pool with GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers?PageSize=1000 and count the entries whose phone_number is a North American toll-free number: the +1 area codes 800, 833, 844, 855, 866, 877 and 888. More than one in a single service is the finding.

Twilio's guidance is one toll-free number per Messaging Service. Carriers treat several toll-free senders sharing a service as snowshoeing — volume spread across senders to dodge filtering — and block the numbers, which is why the damage lands on the whole pool rather than on the number you added last.

The problem in plain words

The change that causes this looks like a capacity fix and is applied by someone being sensible. Toll-free throughput is finite, a second verified toll-free number is sitting on the account, and adding it to the same service is one API call. Nothing warns you. The pool accepts it, sends start alternating between the two senders, and the service does exactly what a sender pool is advertised to do.

The judgement that follows is not Twilio's, which is the part that makes it hard to debug. Carriers look at one messaging service pushing volume through multiple toll-free senders and see the standard shape of a spam operation spreading traffic to stay under per-sender thresholds. The response is to block, and the block is not scoped to the new number. The number that had been running clean for a year goes down with it, so the timeline in your head — “we added a number and the old one broke” — reads as unrelated events.

Then the reflex fix makes it worse. The numbers look unverified, so somebody re-submits verification, waits, and re-submits again, while the pool is still shaped the way that caused the block. Nothing in the verification record explains that the problem is how many toll-free senders share the service.

One toll-freesenderverified, sendingSecond oneaddedfor morethroughputVolumealternatesbetween bothsendersCarrier seessnowshoeblocks the numbersWhole pool30032the old number tooresubmitting verification changes nothing
The verification records stay in order throughout. What changed is the shape of the pool, and the judgement comes from the carrier.

Why it happens

The rule is about the pool, not about any one number. Each toll-free number can be individually verified and completely legitimate. What the carrier reacts to is the arrangement: several toll-free senders behind a single service, taking turns. No property of any single number describes that, which is why no per-number check finds it.

Adding a sender is a success. The API returns a created sender, the console lists it, and there is no warning at the moment the second toll-free number joins. The consequence arrives days or weeks later from a third party, with a delay long enough that nobody connects the two.

30032 says the number is unverified, which is not what you are looking at. The same code covers a genuinely unverified toll-free number, and that is a different note with a different fix. Reading the code literally sends you to the verification record, where everything is in order, rather than to the pool.

The blast radius is the service. A block that lands on the pool takes out the sender your production traffic has used for a year, not just the one that was added. That asymmetry is why this is worth auditing before the second number goes in rather than after.

Nothing puts the numbers back together for you. The repair is to split them across services, one toll-free number each, which also means the traffic that was pointed at one service has to be split. That is a change to your application, not just to a pool, and it is easier to plan before a carrier forces the schedule.

The fix, as a flow

The rule is a property of the pool rather than of any number in it, so the count is per service and the toll-free test matches the numbering plan rather than a prefix string.

PhoneNumbers per servicenumbering plan, not prefixesNo toll-free in poolnot this noteExactly onethe recommended shapeTwo or morecarriers read it as snowshoeingTwo, with 30032salready blocked, split now
Long codes in the same pool are printed as context, not as a finding. The rule being checked is strictly the toll-free count.

How to fix it

List the services, then read each sender pool

GET https://messaging.twilio.com/v1/Services, paging on meta.next_page_url, then GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers per service. The finding is a property of one pool, so the count has to be per service and never per account.

Identify toll-free numbers by the numbering plan, not by a substring

Strip everything that is not a digit, require eleven digits starting with 1, and match the area code against 800, 833, 844, 855, 866, 877, 888. A naive startswith("+1800") check misses the other six, and a bare "800" in number check matches a UK freephone number and a subscriber number that happens to contain 800.

Count long codes in the same pool as context, not as a finding

A pool with one toll-free number and a set of long codes is a normal, working arrangement; it is worth printing the shape so the reader can see what they have, but the thing being flagged is strictly more than one toll-free sender in one service.

Corroborate with the 30032s, grouped by sender

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and count error_code 30032 per from yourself: the Messages list has no error filter, so the filtering is client side. Failures on both toll-free numbers in a shared pool is the signature; failures on one number only usually means that number really is unverified.

Split them, one toll-free number per service

The repair is DELETE https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers/{PNSid} for the extras and a service of its own for each toll-free number, with the sending code updated to name the right MessagingServiceSid for each stream of traffic. Then leave the audit on a schedule, because the next capacity problem invites exactly the same fix.

How to check it worked

Re-run the script. Every service should hold at most one toll-free sender.

python3 twilio_tollfree_pool_audit.py
# 6 service(s), 0 holding more than one toll-free sender

The full code

One GET for the services, one per service for its pool, and with --check-errors one paginated pass over the Messages list — all of it on an API Key with read access. Two pure functions carry the note: what counts as a North American toll-free number, and what counts as too many of them in one pool. The first is where the false positives would come from, so it is the one with the most tests.

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_tollfree_pool_audit.py
"""Report Messaging Services holding more than one toll-free sender.

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 collections
import datetime as dt
import logging
import os
import sys

import requests

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

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

# North American toll-free area codes. Matching the numbering plan rather than a
# prefix string keeps a UK freephone number and a subscriber number containing
# 800 out of the report.
TOLL_FREE_AREA_CODES = frozenset({"800", "833", "844", "855", "866", "877", "888"})

TOLL_FREE_ERROR = "30032"


def is_toll_free(phone_number):
    """True for a North American toll-free number in any formatting.

    Pure. Eleven digits beginning with the country code 1, and an area code from
    the toll-free set: that is the rule, and it is deliberately not a substring
    test on the E.164 string.
    """
    digits = "".join(c for c in str(phone_number or "") if c.isdigit())
    if len(digits) != 11 or not digits.startswith("1"):
        return False
    return digits[1:4] in TOLL_FREE_AREA_CODES


def verdict(entries):
    """Classify one sender pool by how many toll-free numbers share it.

    `entries` is the pool's phone_numbers list. Pure, so the rule is testable
    without a network. Returns (state, detail).
    """
    entries = entries or []
    if not entries:
        return ("empty", "no phone numbers in this pool at all, which is 21704 "
                         "on every send and a different note.")

    toll_free = [str(e.get("phone_number") or "")
                 for e in entries if is_toll_free(e.get("phone_number"))]
    others = len(entries) - len(toll_free)

    if not toll_free:
        return ("no-toll-free",
                "%d sender(s), none of them toll-free." % len(entries))
    if len(toll_free) == 1:
        return ("single-toll-free",
                "one toll-free sender (%s) alongside %d other sender(s), which "
                "is the shape Twilio's guidance asks for."
                % (toll_free[0], others))
    return ("multiple-toll-free",
            "%d toll-free senders share this pool: %s. Carriers read that as "
            "snowshoeing and block the numbers, including ones verified long "
            "before the extras were added."
            % (len(toll_free), ", ".join(toll_free)))


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_services(session, limit):
    url = "%s/Services" % MESSAGING
    params = {"PageSize": 100}
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("services", []))
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out[:limit]


def read_pool(session, service_sid):
    url = "%s/Services/%s/PhoneNumbers" % (MESSAGING, service_sid)
    params = {"PageSize": 100}
    out = []
    while url:
        page = get(session, url, **params)
        out.extend(page.get("phone_numbers", []))
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out


def count_blocks(session, account, days, max_messages):
    """Count 30032 per sender. The Messages list has no error code filter and no
    status filter, so the window is paged and filtered here."""
    since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
    url = "%s/Accounts/%s/Messages.json" % (BASE, account)
    params = {"DateSent>": since, "PageSize": 1000}
    seen = 0
    tally = collections.Counter()
    while url and seen < max_messages:
        page = get(session, url, **params)
        rows = page.get("messages", [])
        seen += len(rows)
        for m in rows:
            if str(m.get("error_code") or "") == TOLL_FREE_ERROR:
                tally[m.get("from")] += 1
        nxt = page.get("next_page_uri")
        url, params = (HOST + nxt) if nxt else None, {}
    return tally


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-services", type=int, default=200,
                    help="stop paging after this many Messaging Services")
    ap.add_argument("--check-errors", action="store_true",
                    help="page the Messages list and count 30032 per sender")
    ap.add_argument("--days", type=int, default=7,
                    help="window in days for --check-errors")
    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)

    blocks = count_blocks(session, account, args.days, args.max_messages) if args.check_errors else {}

    services = list_services(session, args.max_services)
    bad = 0
    for svc in services:
        entries = read_pool(session, svc.get("sid"))
        state, detail = verdict(entries)
        label = svc.get("friendly_name") or svc.get("sid")
        line = "%-19s %s  %s" % (state, label, detail)
        if state != "multiple-toll-free":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        for e in entries:
            number = e.get("phone_number")
            if is_toll_free(number) and blocks.get(number):
                log.warning("  %s has %d message(s) failing %s in the last %d day(s)",
                            number, blocks[number], TOLL_FREE_ERROR, args.days)
        log.warning("  repair: give each toll-free number its own Messaging "
                    "Service, then DELETE %s/Services/%s/PhoneNumbers/{PNSid} "
                    "for the extras and point each traffic stream at the right "
                    "MessagingServiceSid.", MESSAGING, svc.get("sid"))

    log.info("%d service(s), %d holding more than one toll-free sender",
             len(services), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-tollfree-pool-audit.mjs
/**
 * Report Messaging Services holding more than one toll-free sender.
 *
 * 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 MESSAGING = 'https://messaging.twilio.com/v1';

// North American toll-free area codes. Matching the numbering plan rather than a
// prefix string keeps a UK freephone number and a subscriber number containing
// 800 out of the report.
const TOLL_FREE_AREA_CODES = new Set(['800', '833', '844', '855', '866', '877', '888']);

const TOLL_FREE_ERROR = '30032';

/**
 * True for a North American toll-free number in any formatting. Pure: eleven
 * digits beginning with country code 1, and an area code from the toll-free set.
 */
export function isTollFree(phoneNumber) {
  const digits = String(phoneNumber ?? '').replace(/\D/g, '');
  if (digits.length !== 11 || !digits.startsWith('1')) return false;
  return TOLL_FREE_AREA_CODES.has(digits.slice(1, 4));
}

/**
 * Classify one sender pool by how many toll-free numbers share it. Pure, so the
 * rule is testable without a network. Returns [state, detail].
 */
export function verdict(entries) {
  const pool = entries ?? [];
  if (!pool.length) {
    return ['empty',
      'no phone numbers in this pool at all, which is 21704 on every send and ' +
      'a different note.'];
  }

  const tollFree = pool.filter((e) => isTollFree(e.phone_number))
                       .map((e) => String(e.phone_number ?? ''));
  const others = pool.length - tollFree.length;

  if (!tollFree.length) {
    return ['no-toll-free', `${pool.length} sender(s), none of them toll-free.`];
  }
  if (tollFree.length === 1) {
    return ['single-toll-free',
      `one toll-free sender (${tollFree[0]}) alongside ${others} other ` +
      "sender(s), which is the shape Twilio's guidance asks for."];
  }
  return ['multiple-toll-free',
    `${tollFree.length} toll-free senders share this pool: ${tollFree.join(', ')}. ` +
    'Carriers read that as snowshoeing and block the numbers, including ones ' +
    'verified long before the extras were added.'];
}

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 listServices(auth, limit = 200) {
  let url = `${MESSAGING}/Services`;
  let params = { PageSize: 100 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.services ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, limit);
}

async function readPool(auth, serviceSid) {
  let url = `${MESSAGING}/Services/${serviceSid}/PhoneNumbers`;
  let params = { PageSize: 100 };
  const out = [];
  while (url) {
    const page = await get(auth, url, params);
    out.push(...(page.phone_numbers ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out;
}

/** Count 30032 per sender. The Messages list has no error code filter. */
async function countBlocks(auth, account, days, maxMessages) {
  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
  let url = `${BASE}/Accounts/${account}/Messages.json`;
  let params = { 'DateSent>': since, PageSize: 1000 };
  const tally = new Map();
  let seen = 0;
  while (url && seen < maxMessages) {
    const page = await get(auth, url, params);
    const rows = page.messages ?? [];
    seen += rows.length;
    for (const m of rows) {
      if (String(m.error_code ?? '') === TOLL_FREE_ERROR) {
        tally.set(m.from, (tally.get(m.from) ?? 0) + 1);
      }
    }
    url = page.next_page_uri ? HOST + page.next_page_uri : null;
    params = {};
  }
  return tally;
}

function flagValue(name, fallback) {
  const i = process.argv.indexOf(name);
  return i === -1 ? fallback : Number(process.argv[i + 1]);
}

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 days = flagValue('--days', 7);
  const blocks = process.argv.includes('--check-errors')
    ? await countBlocks(auth, account, days, flagValue('--max-messages', 20000))
    : new Map();

  const services = await listServices(auth);
  let bad = 0;
  for (const svc of services) {
    const entries = await readPool(auth, svc.sid);
    const [state, detail] = verdict(entries);
    const label = svc.friendly_name ?? svc.sid;
    const line = `${state.padEnd(19)} ${label}  ${detail}`;
    if (state !== 'multiple-toll-free') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    for (const e of entries) {
      if (isTollFree(e.phone_number) && blocks.get(e.phone_number)) {
        console.warn(`  ${e.phone_number} has ${blocks.get(e.phone_number)} ` +
                     `message(s) failing ${TOLL_FREE_ERROR} in the last ${days} day(s)`);
      }
    }
    console.warn('  repair: give each toll-free number its own Messaging Service, ' +
                 `then DELETE ${MESSAGING}/Services/${svc.sid}/PhoneNumbers/{PNSid} ` +
                 'for the extras and point each traffic stream at the right ' +
                 'MessagingServiceSid.');
  }

  console.log(`${services.length} service(s), ${bad} holding more than one toll-free sender`);
  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

Everything that could go wrong here is a misidentified number, so that is where the tests are. All seven toll-free area codes have to match, a UK freephone number starting +44 800 must not, and neither must a subscriber number that merely contains 800. After that the pool rule is small: one toll-free sender is the recommended shape, two is the finding, and a pool of long codes is not this note's business.

test_twilio_tollfree_pool_audit.py
from twilio_tollfree_pool_audit import is_toll_free, verdict


def pn(number):
    return {"phone_number": number, "sid": "PN" + number[-4:]}


def test_every_toll_free_area_code_is_recognised():
    for area in ("800", "833", "844", "855", "866", "877", "888"):
        assert is_toll_free("+1" + area + "5550123"), area


def test_a_uk_freephone_number_is_not_north_american_toll_free():
    # +44 800 is freephone in the UK and nothing to do with this rule.
    assert not is_toll_free("+448001234567")


def test_a_subscriber_number_containing_800_is_not_toll_free():
    assert not is_toll_free("+12028005550")
    assert not is_toll_free("+15558675309")


def test_formatting_does_not_change_the_answer():
    assert is_toll_free("+1 (833) 555-0123")
    assert is_toll_free("18335550123")


def test_one_toll_free_number_is_the_recommended_shape():
    state, detail = verdict([pn("+18005550123"), pn("+12025550100")])
    assert state == "single-toll-free"
    assert "+18005550123" in detail


def test_two_toll_free_numbers_in_one_pool_is_the_finding():
    state, detail = verdict([pn("+18005550123"), pn("+18445550199")])
    assert state == "multiple-toll-free"
    assert "snowshoeing" in detail
    assert "+18445550199" in detail


def test_a_pool_of_long_codes_is_not_this_note():
    assert verdict([pn("+12025550100"), pn("+12025550101")])[0] == "no-toll-free"


def test_an_empty_pool_points_at_the_other_note():
    state, detail = verdict([])
    assert state == "empty"
    assert "21704" in detail
twilio-tollfree-pool-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTollFree, verdict } from './twilio-tollfree-pool-audit.mjs';

const pn = (number) => ({ phone_number: number, sid: `PN${number.slice(-4)}` });

test('every toll free area code is recognised', () => {
  for (const area of ['800', '833', '844', '855', '866', '877', '888']) {
    assert.ok(isTollFree(`+1${area}5550123`), area);
  }
});

test('a uk freephone number is not north american toll free', () => {
  assert.ok(!isTollFree('+448001234567'));
});

test('a subscriber number containing 800 is not toll free', () => {
  assert.ok(!isTollFree('+12028005550'));
  assert.ok(!isTollFree('+15558675309'));
});

test('formatting does not change the answer', () => {
  assert.ok(isTollFree('+1 (833) 555-0123'));
  assert.ok(isTollFree('18335550123'));
});

test('one toll free number is the recommended shape', () => {
  const [state, detail] = verdict([pn('+18005550123'), pn('+12025550100')]);
  assert.equal(state, 'single-toll-free');
  assert.match(detail, /\+18005550123/);
});

test('two toll free numbers in one pool is the finding', () => {
  const [state, detail] = verdict([pn('+18005550123'), pn('+18445550199')]);
  assert.equal(state, 'multiple-toll-free');
  assert.match(detail, /snowshoeing/);
  assert.match(detail, /\+18445550199/);
});

test('a pool of long codes is not this note', () => {
  assert.equal(verdict([pn('+12025550100'), pn('+12025550101')])[0], 'no-toll-free');
});

test('an empty pool points at the other note', () => {
  const [state, detail] = verdict([]);
  assert.equal(state, 'empty');
  assert.match(detail, /21704/);
});

FAQ

Both numbers are verified. Why are they being blocked?

Verification is per number and this rule is about the pool. Carriers look at one Messaging Service pushing volume through several toll-free senders and read it as snowshoeing, which is spreading traffic across senders to stay under filtering thresholds. The senders being individually legitimate does not change the shape they make together.

Why did the number that was already working go down too?

Because the block lands on the arrangement rather than on the newest sender. That is what makes the timeline confusing: the number you added is fine on paper and the one you have relied on for a year stops delivering.

How is this different from a toll-free number that was never verified?

Same error code, different cause. An unverified number fails on its own, from its first US or Canadian send, and the fix is to submit the verification. This one fails after the fact, across a pool of numbers whose verification records are all in order, and the fix is to split the pool.

Can I keep two toll-free numbers if I only send from one?

The recommendation is one toll-free number per Messaging Service, and the sender pool exists precisely so that Twilio can select between the numbers in it. If a second toll-free number is in the pool it is a candidate, whatever your application intends. Park the spare outside the service.

Does the script remove the extra number?

No. It prints the DELETE for the extra pool member and leaves it to you, because taking a sender out of a live pool moves traffic onto whatever remains. Everything in this section runs on an API Key with read access and cannot write even if the key leaked.

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.