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.
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.
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.
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.
"""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())
/**
* 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.
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"
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
- A Messaging Service with no A2P campaign
- A service that never reports a delivery failure
- Inbound SMS disappears into a blank sms_url
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.
- Error 21704: the Messaging Service contains no phone numbers — Twilio Docs
- Messaging Service PhoneNumber resource — Twilio Docs
- Messaging Service AlphaSender resource — Twilio Docs
- Messaging Services — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.