Skip to content

Diagnostic Twilio

an empty sender pool fails every send with error 21704

The Messaging Service was created by a setup script in March. It has a friendly name, a SID your application has been passing on every send since, an inbound webhook, a status callback — and nothing in the sender pool. Every Messages.create against it comes back 21704, “The Messaging Service contains no phone numbers”, before a carrier is ever involved.

Read-only key Python and Node.js Tests included
A calculator sitting on top of a pile of money
Photo by Jakub Żerdzicki on Unsplash
The short answer

Per service, read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers and flag an empty phone_numbers[]. Then read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/AlphaSenders and check alpha_senders[]. Both empty guarantees 21704 on every send that passes that MessagingServiceSid.

Read GET https://messaging.twilio.com/v1/Services/{ServiceSid}/ShortCodes too before you call a pool empty. A service whose only sender is a short code has no phone numbers and no alpha senders and still sends perfectly well, and a report that flags it teaches everyone to ignore the report.

The problem in plain words

An empty pool is the failure mode of automation. Nobody creates a Messaging Service by hand and forgets to add a number — the console walks you through the sender pool on the way in. Terraform, a bootstrap script or a copied setup notebook creates the service in one call and adds senders in another, and when the second call is missing, skipped in a dry run, or applied against the wrong account, you get a service that exists, reports healthy, and cannot send.

The same shape appears at the other end of a service's life. The last number in the pool is released during a cleanup, or moved to a new service during a migration, and the old SID is still hard-coded in one job nobody remembered. That job has been returning 21704 nightly ever since, and because the failure is at request time it never becomes a Message row, never appears in the Messages list, and never shows up on the bill.

Service createdby a setup scriptSenders neveraddedsecond callskippedApp sends bySIDMessagingServiceSidRejected 21704at request timeNo Message rownothing to findlater
The rejection happens before a Message exists, so nothing appears in the Messages list and nothing appears on the bill.

Why it happens

The service looks complete without a single sender. Every other field is set: friendly name, inbound request URL, validity period, use-case flags. Nothing in the Service resource itself says the pool is empty, because the pool is a subresource you have to ask for separately.

The rejection happens before a Message exists. 21704 is returned to the API caller synchronously; no Message resource is created, so paging Messages.json will never find it. If your send path swallows exceptions, or logs them at a level nobody reads, the traffic simply stops and no Twilio-side artifact records that it ever tried.

Sender types live in three different lists. Long codes and toll-free numbers are under /PhoneNumbers, alphanumeric sender IDs under /AlphaSenders, short codes under /ShortCodes. A check that reads one of the three reports false findings on the accounts most likely to be doing something deliberate.

Not empty is not the same as usable. A pool holding only an alphanumeric sender ID cannot send to the United States or Canada and cannot receive a reply, so a US destination fails sender selection with 21703 rather than 21704. Different code, different note, same afternoon lost if the audit lumps them together.

The fix, as a flow

Three sender lists, read separately, and a classifier that refuses to call a pool empty until all three are in hand. An unread list and an empty one are different facts with opposite repairs.

Numbers, alpha senders, shortcodesthree lists per serviceA long code in the poolready to sendShort code onlysends, no long code fallbackAlpha senders onlyUS and CA fail with 21703All three emptyevery send 21704
Not empty is not the same as usable. Alphanumeric only sends nothing to the US or Canada, and that is 21703, not 21704.

How to fix it

List the services before you list the senders

GET https://messaging.twilio.com/v1/Services?PageSize=100, following meta.next_page_url. Accounts accumulate services faster than anyone expects — one per environment, one per experiment — and the empty one is rarely the one you would have thought to check.

Read all three sender subresources

/PhoneNumbers, /AlphaSenders and /ShortCodes under each service. Three GETs per service is cheap, and it is the difference between a report you act on and a report that cries wolf about the one service deliberately fronted by a short code.

Keep unread and empty as different states

A request that failed, was skipped, or came back without the list key is not an empty pool. Treat a missing list as unknown and say so; an audit that reports "empty" because a page of results never arrived will have somebody adding senders to a service that already has them.

Separate 21704 from 21703

Nothing at all in any list means every send is rejected outright. Senders that exist but cannot reach the destination — alphanumeric only, or no US long code for a US recipient — is sender selection failing, which is 21703 and a different repair. The classifier should name which one it found.

Add the senders, then keep the check on a schedule

POST https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers with PhoneNumberSid=PN… for each owned number, or Console → Messaging → Services → Sender Pool → Add Senders. The default cap is 400 numbers per service. Re-run afterwards, and keep running it: the next environment somebody bootstraps will land the same way.

How to check it worked

Re-run after adding senders. Every service should report ready, and the count of pool problems should be zero.

python3 twilio_sender_pool_audit.py
# 6 service(s), 0 that cannot send

The full code

One GET to list the services and three per service for the sender lists — an API Key with read access is the whole credential. The pure part is deliberately fussy about one distinction: a list that came back empty and a list that was never read are different facts, and collapsing them is how an audit sends somebody to fix a service that was fine.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 40 Twilio fixes, free and open source.
twilio_sender_pool_audit.py
"""Report Twilio Messaging Services whose sender pool cannot send.

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

import requests

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

MESSAGING = "https://messaging.twilio.com/v1"

# (subresource path, the key its list response uses)
SENDER_LISTS = (
    ("PhoneNumbers", "phone_numbers"),
    ("AlphaSenders", "alpha_senders"),
    ("ShortCodes", "short_codes"),
)


def sender_count(payload, key):
    """How many senders a list response holds, or None when it was not read.

    Pure. The None is the point: a request that failed or was skipped must not
    be reported as an empty pool, because the repair for the two is opposite.
    """
    if not isinstance(payload, dict):
        return None
    items = payload.get(key)
    if items is None:
        return None
    return len(items)


def verdict(pool):
    """Classify one service's sender pool. Pure, so the 21704 rule and the
    21703 rule are readable side by side.

    `pool` maps a sender kind to a count or to None for "not read".

    Returns (state, detail).
    """
    numbers = pool.get("phone_numbers")
    alpha = pool.get("alpha_senders")
    short = pool.get("short_codes")

    if numbers is None:
        return ("unread", "the phone number pool was not read, so nothing here is "
                          "a finding yet")
    if numbers == 0 and (alpha is None or short is None):
        return ("unread", "no phone numbers, but the alpha sender or short code "
                          "list was not read. Do not call a pool empty until all "
                          "three lists are in hand.")

    total = numbers + alpha + short
    if total == 0:
        return ("empty",
                "no phone numbers, no alpha senders, no short codes. Every send "
                "that passes this MessagingServiceSid is rejected with 21704 at "
                "request time, before any carrier hop and before a Message row "
                "exists to find later.")
    if numbers == 0 and short == 0:
        return ("alpha-only",
                "%d alphanumeric sender(s) and nothing else. Not 21704, but "
                "alphanumeric senders are one way and are not supported for US "
                "or Canadian destinations, so those sends fail selection with "
                "21703 instead." % alpha)
    if numbers == 0:
        return ("short-code-only",
                "%d short code(s) and no long codes. It sends, but there is no "
                "long code to fall back to and no coverage outside the short "
                "code's own country." % short)
    return ("ready", "%d number(s), %d alpha sender(s), %d short code(s)"
            % (numbers, alpha, short))


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):
    """One GET per sender kind. Anything that does not come back stays None so
    the classifier can say 'unread' rather than 'empty'."""
    pool = {}
    for path, key in SENDER_LISTS:
        payload = get(session, "%s/Services/%s/%s" % (MESSAGING, service_sid, path),
                      PageSize=100)
        pool[key] = sender_count(payload, key)
    return pool


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-services", type=int, default=200,
                    help="stop paging after this many Messaging Services")
    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_services(session, args.max_services)
    if not services:
        log.info("no Messaging Services on this account")
        return 0

    bad = 0
    for svc in services:
        sid = svc.get("sid")
        state, detail = verdict(read_pool(session, sid))
        line = "%-16s %s (%s)  %s" % (state, sid, svc.get("friendly_name", "?"), detail)
        if state == "ready":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  repair: add a sender with POST %s/Services/%s/PhoneNumbers "
                    "PhoneNumberSid=PN..., or Console > Messaging > Services > "
                    "Sender Pool > Add Senders. The default cap is 400 numbers "
                    "per service.", MESSAGING, sid)

    log.info("%d service(s), %d that cannot send", len(services), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-sender-pool-audit.mjs
/**
 * Report Twilio Messaging Services whose sender pool cannot send.
 *
 * 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 MESSAGING = 'https://messaging.twilio.com/v1';

// [subresource path, the key its list response uses]
const SENDER_LISTS = [
  ['PhoneNumbers', 'phone_numbers'],
  ['AlphaSenders', 'alpha_senders'],
  ['ShortCodes', 'short_codes'],
];

/**
 * How many senders a list response holds, or null when it was not read. Pure.
 * The null is the point: a request that failed or was skipped must not be
 * reported as an empty pool, because the repair for the two is opposite.
 */
export function senderCount(payload, key) {
  if (payload === null || typeof payload !== 'object') return null;
  const items = payload[key];
  if (items === null || items === undefined) return null;
  return items.length;
}

/**
 * Classify one service's sender pool. Pure, so the 21704 rule and the 21703
 * rule are readable side by side. `pool` maps a sender kind to a count or to
 * null for "not read". Returns [state, detail].
 */
export function verdict(pool) {
  const numbers = pool.phone_numbers ?? null;
  const alpha = pool.alpha_senders ?? null;
  const short = pool.short_codes ?? null;

  if (numbers === null) {
    return ['unread', 'the phone number pool was not read, so nothing here is a finding yet'];
  }
  if (numbers === 0 && (alpha === null || short === null)) {
    return ['unread',
      'no phone numbers, but the alpha sender or short code list was not read. ' +
      'Do not call a pool empty until all three lists are in hand.'];
  }

  if (numbers + alpha + short === 0) {
    return ['empty',
      'no phone numbers, no alpha senders, no short codes. Every send that passes ' +
      'this MessagingServiceSid is rejected with 21704 at request time, before any ' +
      'carrier hop and before a Message row exists to find later.'];
  }
  if (numbers === 0 && short === 0) {
    return ['alpha-only',
      `${alpha} alphanumeric sender(s) and nothing else. Not 21704, but alphanumeric ` +
      'senders are one way and are not supported for US or Canadian destinations, ' +
      'so those sends fail selection with 21703 instead.'];
  }
  if (numbers === 0) {
    return ['short-code-only',
      `${short} short code(s) and no long codes. It sends, but there is no long code ` +
      "to fall back to and no coverage outside the short code's own country."];
  }
  return ['ready',
    `${numbers} number(s), ${alpha} alpha sender(s), ${short} short code(s)`];
}

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) {
  const pool = {};
  for (const [path, key] of SENDER_LISTS) {
    const payload = await get(auth, `${MESSAGING}/Services/${serviceSid}/${path}`,
                              { PageSize: 100 });
    pool[key] = senderCount(payload, key);
  }
  return pool;
}

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 listServices(auth);
  if (services.length === 0) {
    console.log('no Messaging Services on this account');
    return;
  }

  let bad = 0;
  for (const svc of services) {
    const [state, detail] = verdict(await readPool(auth, svc.sid));
    const line = `${state.padEnd(16)} ${svc.sid} (${svc.friendly_name ?? '?'})  ${detail}`;
    if (state === 'ready') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  repair: add a sender with POST ${MESSAGING}/Services/${svc.sid}` +
                 '/PhoneNumbers PhoneNumberSid=PN..., or Console > Messaging > ' +
                 'Services > Sender Pool > Add Senders. The default cap is 400 ' +
                 'numbers per service.');
  }

  console.log(`${services.length} service(s), ${bad} that cannot send`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests pin the two ways this audit could lie. A list that was never read must not be reported as an empty pool, so an unread short code list on a service with no numbers is unread and not empty. And a pool with senders in it that still cannot reach the destination is a different error code, so alphanumeric-only gets its own state rather than being quietly counted as fine.

test_twilio_sender_pool_audit.py
from twilio_sender_pool_audit import sender_count, verdict


def full(numbers=0, alpha=0, short=0):
    return {"phone_numbers": numbers, "alpha_senders": alpha, "short_codes": short}


def test_sender_count_separates_empty_from_unread():
    assert sender_count({"phone_numbers": []}, "phone_numbers") == 0
    assert sender_count({"phone_numbers": [{"sid": "PN1"}]}, "phone_numbers") == 1
    assert sender_count({}, "phone_numbers") is None
    assert sender_count(None, "phone_numbers") is None


def test_nothing_in_any_list_is_21704():
    state, detail = verdict(full())
    assert state == "empty"
    assert "21704" in detail


def test_an_unread_list_is_not_an_empty_pool():
    # The false positive worth preventing: somebody adds senders to a service
    # that already had them because one GET was skipped.
    state, detail = verdict({"phone_numbers": 0, "alpha_senders": 0,
                             "short_codes": None})
    assert state == "unread"
    assert "not read" in detail
    assert verdict({"phone_numbers": None})[0] == "unread"


def test_alpha_senders_only_is_21703_not_21704():
    state, detail = verdict(full(alpha=2))
    assert state == "alpha-only"
    assert "21703" in detail
    assert "21704" not in detail.replace("Not 21704", "")


def test_a_short_code_only_pool_still_sends():
    state, detail = verdict(full(short=1))
    assert state == "short-code-only"
    assert "1 short code(s)" in detail


def test_one_number_is_enough_to_be_ready():
    state, detail = verdict(full(numbers=1))
    assert state == "ready"
    assert "1 number(s)" in detail


def test_numbers_win_over_the_other_lists():
    assert verdict(full(numbers=3, alpha=1, short=1))[0] == "ready"
twilio-sender-pool-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { senderCount, verdict } from './twilio-sender-pool-audit.mjs';

const full = (numbers = 0, alpha = 0, short = 0) => ({
  phone_numbers: numbers, alpha_senders: alpha, short_codes: short,
});

test('sender count separates empty from unread', () => {
  assert.equal(senderCount({ phone_numbers: [] }, 'phone_numbers'), 0);
  assert.equal(senderCount({ phone_numbers: [{ sid: 'PN1' }] }, 'phone_numbers'), 1);
  assert.equal(senderCount({}, 'phone_numbers'), null);
  assert.equal(senderCount(null, 'phone_numbers'), null);
});

test('nothing in any list is 21704', () => {
  const [state, detail] = verdict(full());
  assert.equal(state, 'empty');
  assert.match(detail, /21704/);
});

test('an unread list is not an empty pool', () => {
  const [state, detail] = verdict({
    phone_numbers: 0, alpha_senders: 0, short_codes: null,
  });
  assert.equal(state, 'unread');
  assert.match(detail, /not read/);
  assert.equal(verdict({ phone_numbers: null })[0], 'unread');
});

test('alpha senders only is 21703, not 21704', () => {
  const [state, detail] = verdict(full(0, 2, 0));
  assert.equal(state, 'alpha-only');
  assert.match(detail, /21703/);
});

test('a short code only pool still sends', () => {
  const [state, detail] = verdict(full(0, 0, 1));
  assert.equal(state, 'short-code-only');
  assert.match(detail, /1 short code\(s\)/);
});

test('one number is enough to be ready', () => {
  const [state, detail] = verdict(full(1));
  assert.equal(state, 'ready');
  assert.match(detail, /1 number\(s\)/);
});

test('numbers win over the other lists', () => {
  assert.equal(verdict(full(3, 1, 1))[0], 'ready');
});

FAQ

What exactly does 21704 mean?

That the Messaging Service you passed as MessagingServiceSid has no sender Twilio can select. It is returned synchronously to the API caller, so no Message resource is created and nothing about the attempt appears in the Messages list afterwards.

Why does the Console show the service as configured?

Because everything on the Service resource itself is configured. The sender pool is a separate subresource, so a service can carry a friendly name, an inbound URL, a status callback and a validity period while holding no senders at all.

Do alphanumeric sender IDs count as senders?

They stop you getting 21704, and they are not a substitute for a number. Alphanumeric senders are one-way and are not supported for US or Canadian destinations, so those sends fail sender selection with 21703 instead. The script reports that as its own state for exactly that reason.

Why read the short code list as well?

To avoid a false positive. A service whose only sender is a short code has an empty phone_numbers[] and an empty alpha_senders[] and sends perfectly well. Flagging it once is enough to teach a team to ignore the whole report.

How many numbers can a sender pool hold?

400 by default. That is a cap on the pool, not a target: adding numbers to dodge throughput limits is what carriers read as snowshoeing, and toll-free numbers in particular belong one per service.

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.