Skip to content

Diagnostic Twilio

error 21703: the pool has senders but none reach the To

The Messaging Service has senders in it. Sends to the UK go out all day. Every send to a US number comes back 21703, “The Messaging Service does not have a phone number available to send a message”, which sounds like the pool is empty and is not what it means. The pool is fine. It has nothing in it that can reach that destination.

Read-only key Python and Node.js Tests included
Warehouse storage racks
Photo by Petr on Unsplash
The short answer

Read the pool as three lists: GET https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers (each entry carries country_code and a capabilities list of SMS, MMS, voice), plus /ShortCodes and /AlphaSenders. Then resolve the destination's country with GET https://lookups.twilio.com/v2/PhoneNumbers/{E164} and check the pool against it.

Two shapes produce 21703. Nothing in the pool matches the destination country — and for US or Canadian destinations an alphanumeric sender ID does not count, because it cannot deliver there. Or the country is covered but the message carries MediaUrl and no matching sender lists MMS.

The problem in plain words

21703 is read as “empty pool” by almost everyone who meets it, because the message says the service does not have a phone number available. It does have phone numbers. Sender selection ran, looked for one that supports this destination and this message type, and found none, which is a different sentence with a different repair. The service that fails is often the one that has been working for a year, right up to the first customer in a new country.

It also fails per destination rather than per service, so nothing about it is reproducible from the outside. Half the traffic goes out. The half that does not is defined by a property of the recipient, not of your code, so the bug report says “messages to the US are broken” and the service dashboard says the service is healthy. Both are accurate.

The MMS variant is worse, because the same recipient works and fails depending on whether the message has an attachment. A pool with SMS-only US long codes sends the text version of a notification perfectly and rejects the one with a receipt image, and the difference lives in a capabilities list nobody has read.

Pool hassendersthree alphasendersSend to a USnumberfirst US customerSelection runslooks for a USsenderNothingeligiblealpha cannot reachUSRejected 21703reads as emptypool
The service has senders and sends all day. Alphanumeric sender IDs are simply not candidates for a US or Canadian destination.

Why it happens

Sender selection matches on country and on message type together. A pool member has to support the destination and the thing you are sending. A US destination needs a US or Canadian long code, toll-free number or short code; a message with MediaUrl needs a sender that lists MMS. Satisfying one and not the other still returns 21703.

Alphanumeric sender IDs cannot reach the US or Canada. They are legitimate senders, they appear in the pool, and they make a service look populated. For a US destination they are not a candidate at all, which is exactly how a service with three alpha senders and no US number produces this error while looking well configured.

The failure is at request time, so there is often no Message row. 21703 is returned synchronously and no Message resource is created, which means paging Messages.json will not find it. The read-only evidence lives in GET https://monitor.twilio.com/v1/Alerts instead, and the audit itself has to be predictive: read the pool, name the destinations you care about, and check coverage before anyone sends.

Capabilities are spelled differently on either side of the join. The pool entry carries a list like ["SMS", "MMS"]; the account's IncomingPhoneNumbers carries an object with lowercase keys. Comparing them naively is how an MMS-capable pool gets reported as having no MMS sender.

A brand new sender can behave like a missing one. A number added minutes ago may still be pending on the carrier side, so the pool looks correct and selection still fails. That is a different note and a different action, which is to wait.

The fix, as a flow

Coverage is decided on two axes at once, country and message type, because a pool can satisfy either one alone and still leave sender selection with no candidate at all.

Three sender lists plus Lookupcountry and capabilitiesLocal sender, right typecoveredNo sender at all21704, other noteNo US or CA senderalpha does not countNo MMS in countrytext sends, media 21703
An empty pool is kept as its own answer: that is 21704 on every send rather than 21703 on this destination, and a different repair.

How to fix it

Read all three sender lists for the service

/PhoneNumbers, /ShortCodes and /AlphaSenders under https://messaging.twilio.com/v1/Services/{ServiceSid}. A pool whose only sender is a short code has an empty phone_numbers[] and sends perfectly well, so reading one list and concluding from it produces a confident wrong answer.

Resolve the destination country rather than guessing from the prefix

GET https://lookups.twilio.com/v2/PhoneNumbers/{E164} returns country_code. Prefix arithmetic works until it meets the North American numbering plan, where +1 covers the US, Canada and a list of Caribbean countries that do not share sender rules.

Treat US and Canadian destinations by their own rule

For a US or CA destination the pool needs a US or Canadian phone number or short code. Alphanumeric sender IDs cannot deliver there, so a pool of alpha senders is uncovered no matter how many it holds.

Check the message type as well as the country

If the traffic carries MediaUrl, the matching sender has to list MMS. Compare capabilities case-insensitively: the pool spells them SMS and MMS, the account API spells the same facts as lowercase keys on an object.

Confirm the live failures in Alerts, not in the Messages list

GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=... and filter for error_code 21703 yourself; the Messages list has no error filter and, for a request-time rejection, usually has no row at all. Alerts are retained thirty days, which bounds every trend you can draw from them.

Add a sender that matches, then re-check the destinations you actually serve

POST https://messaging.twilio.com/v1/Services/{ServiceSid}/PhoneNumbers with the PhoneNumberSid of a number in the destination country carrying the capability you need. For US MMS that means an MMS-capable US long code specifically. Re-run the audit with the country list your product actually sells into.

How to check it worked

Re-run the script with the destinations you care about. Every service and destination pair should report covered.

python3 twilio_sender_coverage_audit.py --to +12025550123 --to +447700900123 --media
# 2 service(s) x 2 destination(s), 0 uncovered

The full code

One GET per sender list, one Lookup per destination, and nothing else — an API Key with read access is the whole credential. The coverage rule is pure and takes a pool and one destination, because the interesting content of this note is a matching rule with two dimensions, and a rule with two dimensions is worth reading next to the cases that pin it.

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_sender_coverage_audit.py
"""Report Messaging Services whose sender pool cannot reach a destination.

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_coverage_audit")

MESSAGING = "https://messaging.twilio.com/v1"
LOOKUPS = "https://lookups.twilio.com/v2"

# Alphanumeric sender IDs are not candidates for these destinations, so a pool
# holding nothing else is uncovered however full it looks.
ALPHA_EXCLUDED = ("US", "CA")


def has_capability(entry, name):
    """Case-insensitive capability test that survives both spellings.

    The Messaging Service pool returns a list like ["SMS", "MMS", "voice"]. The
    account API returns an object with lowercase keys for the same facts.
    Comparing raw strings across the two is how an MMS-capable pool gets
    reported as having no MMS sender.
    """
    caps = entry.get("capabilities") or []
    if isinstance(caps, dict):
        return bool(caps.get(name.lower()))
    return name.lower() in [str(c).lower() for c in caps]


def coverage(pool, destination):
    """Decide whether one sender pool can reach one destination.

    `pool` is {"phone_numbers": [...], "short_codes": [...], "alpha_senders": [...]}
    as returned by the three subresources. `destination` is
    {"country_code": "US", "needs_mms": bool}.

    Pure, so the matching rule can be tested without a network.
    Returns (state, detail).
    """
    country = str(destination.get("country_code") or "").upper()
    needs_mms = bool(destination.get("needs_mms"))
    numbers = pool.get("phone_numbers") or []
    codes = pool.get("short_codes") or []
    alphas = pool.get("alpha_senders") or []

    if not (numbers or codes or alphas):
        return ("no-senders",
                "the pool holds no senders at all, which is 21704 on every send "
                "rather than 21703 on this destination.")
    if not country:
        return ("unresolved",
                "the destination country was not resolved, so coverage cannot "
                "be decided. Read country_code from Lookup v2 first.")

    local = [n for n in numbers
             if str(n.get("country_code") or "").upper() == country]
    local_codes = [c for c in codes
                   if str(c.get("country_code") or "").upper() == country]

    if not (local or local_codes):
        if country in ALPHA_EXCLUDED:
            return ("unreachable",
                    "no %s number or short code in the pool. The %d alphanumeric "
                    "sender(s) do not count: they cannot deliver to %s."
                    % (country, len(alphas), country))
        if alphas:
            return ("alpha-only",
                    "no %s number in the pool, only %d alphanumeric sender(s). "
                    "They are one way and are not accepted everywhere, so this "
                    "is deliverable in some countries and 21703 in others."
                    % (country, len(alphas)))
        return ("no-local-sender",
                "no %s sender in the pool. Selection may still pick a foreign "
                "long code, and this is the shape that returns 21703 when it "
                "does not." % country)

    if needs_mms and not any(has_capability(n, "MMS") for n in local):
        return ("no-mms",
                "%d %s sender(s) in the pool and not one of them lists MMS, so "
                "any message carrying MediaUrl is 21703 while the text only "
                "version of it sends." % (len(local), country))

    kinds = []
    if local:
        kinds.append("%d number(s)" % len(local))
    if local_codes:
        kinds.append("%d short code(s)" % len(local_codes))
    return ("covered", "%s in %s%s"
            % (", ".join(kinds), country, ", MMS capable" if needs_mms else ""))


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):
    """All three sender lists. Reading one and concluding from it is how a
    short code only service gets reported as broken."""
    pool = {}
    for path, key in (("PhoneNumbers", "phone_numbers"),
                      ("ShortCodes", "short_codes"),
                      ("AlphaSenders", "alpha_senders")):
        page = get(session, "%s/Services/%s/%s" % (MESSAGING, service_sid, path),
                   PageSize=100)
        pool[key] = page.get(key, [])
    return pool


def resolve(session, e164):
    """Destination country from Lookup v2. Prefix arithmetic breaks on +1,
    which covers the US, Canada and several Caribbean countries."""
    page = get(session, "%s/PhoneNumbers/%s" % (LOOKUPS, e164))
    return str(page.get("country_code") or "").upper()


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--to", action="append", default=[],
                    help="destination in E.164, repeatable")
    ap.add_argument("--media", action="store_true",
                    help="the traffic carries MediaUrl, so a sender must do MMS")
    ap.add_argument("--service", action="append", default=[],
                    help="limit to these Messaging Service SIDs")
    args = ap.parse_args()

    if not args.to:
        log.error("give at least one destination with --to +15551234567")
        return 2

    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)

    destinations = []
    for e164 in args.to:
        country = resolve(session, e164)
        destinations.append({"phone_number": e164, "country_code": country,
                             "needs_mms": args.media})
        log.info("destination %s resolves to %s", e164, country or "?")

    services = list_services(session, 200)
    if args.service:
        services = [s for s in services if s.get("sid") in set(args.service)]

    bad = 0
    for svc in services:
        pool = read_pool(session, svc.get("sid"))
        for dest in destinations:
            state, detail = coverage(pool, dest)
            line = "%-16s %s -> %s  %s" % (state, svc.get("sid"),
                                           dest["phone_number"], detail)
            if state == "covered":
                log.info(line)
                continue
            bad += 1
            log.warning(line)
            log.warning("  repair: POST %s/Services/%s/PhoneNumbers "
                        "PhoneNumberSid=PN... for a %s number%s",
                        MESSAGING, svc.get("sid"), dest["country_code"] or "?",
                        " that is MMS capable" if dest["needs_mms"] else "")

    log.info("%d service(s) x %d destination(s), %d uncovered",
             len(services), len(destinations), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-sender-coverage-audit.mjs
/**
 * Report Messaging Services whose sender pool cannot reach a destination.
 *
 * 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';
const LOOKUPS = 'https://lookups.twilio.com/v2';

// Alphanumeric sender IDs are not candidates for these destinations, so a pool
// holding nothing else is uncovered however full it looks.
const ALPHA_EXCLUDED = ['US', 'CA'];

/**
 * Case-insensitive capability test that survives both spellings: the pool
 * returns a list like ['SMS', 'MMS'], the account API returns an object with
 * lowercase keys for the same facts.
 */
export function hasCapability(entry, name) {
  const caps = entry.capabilities ?? [];
  if (!Array.isArray(caps)) return Boolean(caps[name.toLowerCase()]);
  return caps.map((c) => String(c).toLowerCase()).includes(name.toLowerCase());
}

/**
 * Decide whether one sender pool can reach one destination. Pure, so the
 * matching rule can be tested without a network. Returns [state, detail].
 */
export function coverage(pool, destination) {
  const country = String(destination.country_code ?? '').toUpperCase();
  const needsMms = Boolean(destination.needs_mms);
  const numbers = pool.phone_numbers ?? [];
  const codes = pool.short_codes ?? [];
  const alphas = pool.alpha_senders ?? [];

  if (!numbers.length && !codes.length && !alphas.length) {
    return ['no-senders',
      'the pool holds no senders at all, which is 21704 on every send rather ' +
      'than 21703 on this destination.'];
  }
  if (!country) {
    return ['unresolved',
      'the destination country was not resolved, so coverage cannot be ' +
      'decided. Read country_code from Lookup v2 first.'];
  }

  const local = numbers.filter(
    (n) => String(n.country_code ?? '').toUpperCase() === country);
  const localCodes = codes.filter(
    (c) => String(c.country_code ?? '').toUpperCase() === country);

  if (!local.length && !localCodes.length) {
    if (ALPHA_EXCLUDED.includes(country)) {
      return ['unreachable',
        `no ${country} number or short code in the pool. The ${alphas.length} ` +
        `alphanumeric sender(s) do not count: they cannot deliver to ${country}.`];
    }
    if (alphas.length) {
      return ['alpha-only',
        `no ${country} number in the pool, only ${alphas.length} alphanumeric ` +
        'sender(s). They are one way and are not accepted everywhere, so this ' +
        'is deliverable in some countries and 21703 in others.'];
    }
    return ['no-local-sender',
      `no ${country} sender in the pool. Selection may still pick a foreign ` +
      'long code, and this is the shape that returns 21703 when it does not.'];
  }

  if (needsMms && !local.some((n) => hasCapability(n, 'MMS'))) {
    return ['no-mms',
      `${local.length} ${country} sender(s) in the pool and not one of them ` +
      'lists MMS, so any message carrying MediaUrl is 21703 while the text ' +
      'only version of it sends.'];
  }

  const kinds = [];
  if (local.length) kinds.push(`${local.length} number(s)`);
  if (localCodes.length) kinds.push(`${localCodes.length} short code(s)`);
  return ['covered',
    `${kinds.join(', ')} in ${country}${needsMms ? ', MMS capable' : ''}`];
}

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 [['PhoneNumbers', 'phone_numbers'],
                             ['ShortCodes', 'short_codes'],
                             ['AlphaSenders', 'alpha_senders']]) {
    const page = await get(auth, `${MESSAGING}/Services/${serviceSid}/${path}`,
                           { PageSize: 100 });
    pool[key] = page[key] ?? [];
  }
  return pool;
}

async function resolve(auth, e164) {
  const page = await get(auth, `${LOOKUPS}/PhoneNumbers/${e164}`);
  return String(page.country_code ?? '').toUpperCase();
}

function repeatedFlag(name) {
  const out = [];
  process.argv.forEach((a, i) => { if (a === name) out.push(process.argv[i + 1]); });
  return out.filter(Boolean);
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  const tos = repeatedFlag('--to');
  if (!tos.length) {
    console.error('give at least one destination with --to +15551234567');
    process.exitCode = 2;
    return;
  }
  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 needsMms = process.argv.includes('--media');
  const only = new Set(repeatedFlag('--service'));

  const destinations = [];
  for (const e164 of tos) {
    const country = await resolve(auth, e164);
    destinations.push({ phone_number: e164, country_code: country, needs_mms: needsMms });
    console.log(`destination ${e164} resolves to ${country || '?'}`);
  }

  let services = await listServices(auth);
  if (only.size) services = services.filter((s) => only.has(s.sid));

  let bad = 0;
  for (const svc of services) {
    const pool = await readPool(auth, svc.sid);
    for (const dest of destinations) {
      const [state, detail] = coverage(pool, dest);
      const line = `${state.padEnd(16)} ${svc.sid} -> ${dest.phone_number}  ${detail}`;
      if (state === 'covered') { console.log(line); continue; }
      bad += 1;
      console.warn(line);
      console.warn(`  repair: POST ${MESSAGING}/Services/${svc.sid}/PhoneNumbers ` +
                   `PhoneNumberSid=PN... for a ${dest.country_code || '?'} number` +
                   `${dest.needs_mms ? ' that is MMS capable' : ''}`);
    }
  }

  console.log(`${services.length} service(s) x ${destinations.length} ` +
              `destination(s), ${bad} uncovered`);
  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 cases worth pinning are the ones where the pool looks full. Three alphanumeric senders and a US destination is uncovered, not covered, because alpha senders cannot deliver to the US or Canada. A US long code without MMS covers the text and fails the picture. And an empty pool is deliberately its own state, because that is 21704 and a different note.

test_twilio_sender_coverage_audit.py
from twilio_sender_coverage_audit import coverage, has_capability

US_SMS = {"phone_number": "+12025550100", "country_code": "US", "capabilities": ["SMS"]}
US_MMS = {"phone_number": "+12025550101", "country_code": "US", "capabilities": ["SMS", "MMS"]}
GB_SMS = {"phone_number": "+447700900100", "country_code": "GB", "capabilities": ["SMS"]}
ALPHA = {"sid": "AS1", "alpha_sender": "ACME"}

US = {"country_code": "US", "needs_mms": False}
US_MEDIA = {"country_code": "US", "needs_mms": True}
GB = {"country_code": "GB", "needs_mms": False}


def test_a_us_number_covers_a_us_destination():
    state, detail = coverage({"phone_numbers": [US_SMS]}, US)
    assert state == "covered"
    assert "US" in detail


def test_alpha_senders_do_not_cover_the_us():
    # The whole note: the pool is populated and the destination is unreachable.
    state, detail = coverage({"alpha_senders": [ALPHA, ALPHA, ALPHA]}, US)
    assert state == "unreachable"
    assert "cannot deliver to US" in detail


def test_a_uk_only_pool_cannot_reach_the_us():
    assert coverage({"phone_numbers": [GB_SMS]}, US)[0] == "unreachable"


def test_media_needs_an_mms_capable_sender_in_that_country():
    state, detail = coverage({"phone_numbers": [US_SMS]}, US_MEDIA)
    assert state == "no-mms"
    assert "MediaUrl" in detail
    assert coverage({"phone_numbers": [US_SMS, US_MMS]}, US_MEDIA)[0] == "covered"


def test_an_empty_pool_is_21704_and_says_so():
    state, detail = coverage({}, US)
    assert state == "no-senders"
    assert "21704" in detail


def test_a_short_code_in_the_destination_country_counts():
    pool = {"short_codes": [{"short_code": "12345", "country_code": "US"}]}
    assert coverage(pool, US)[0] == "covered"


def test_a_non_north_american_gap_is_not_reported_as_unreachable():
    # Selection may still pick a foreign long code, so this is a softer state.
    assert coverage({"phone_numbers": [US_SMS]}, GB)[0] == "no-local-sender"
    assert coverage({"phone_numbers": [US_SMS], "alpha_senders": [ALPHA]}, GB)[0] == "alpha-only"


def test_an_unresolved_country_is_never_guessed_at():
    assert coverage({"phone_numbers": [US_SMS]}, {"country_code": ""})[0] == "unresolved"


def test_capabilities_match_across_both_spellings():
    assert has_capability({"capabilities": ["SMS", "MMS"]}, "mms")
    assert has_capability({"capabilities": {"sms": True, "mms": True}}, "MMS")
    assert not has_capability({"capabilities": ["SMS"]}, "MMS")
twilio-sender-coverage-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { coverage, hasCapability } from './twilio-sender-coverage-audit.mjs';

const US_SMS = { phone_number: '+12025550100', country_code: 'US', capabilities: ['SMS'] };
const US_MMS = { phone_number: '+12025550101', country_code: 'US', capabilities: ['SMS', 'MMS'] };
const GB_SMS = { phone_number: '+447700900100', country_code: 'GB', capabilities: ['SMS'] };
const ALPHA = { sid: 'AS1', alpha_sender: 'ACME' };

const US = { country_code: 'US', needs_mms: false };
const US_MEDIA = { country_code: 'US', needs_mms: true };
const GB = { country_code: 'GB', needs_mms: false };

test('a us number covers a us destination', () => {
  const [state, detail] = coverage({ phone_numbers: [US_SMS] }, US);
  assert.equal(state, 'covered');
  assert.match(detail, /US/);
});

test('alpha senders do not cover the us', () => {
  const [state, detail] = coverage({ alpha_senders: [ALPHA, ALPHA, ALPHA] }, US);
  assert.equal(state, 'unreachable');
  assert.match(detail, /cannot deliver to US/);
});

test('a uk only pool cannot reach the us', () => {
  assert.equal(coverage({ phone_numbers: [GB_SMS] }, US)[0], 'unreachable');
});

test('media needs an mms capable sender in that country', () => {
  const [state, detail] = coverage({ phone_numbers: [US_SMS] }, US_MEDIA);
  assert.equal(state, 'no-mms');
  assert.match(detail, /MediaUrl/);
  assert.equal(coverage({ phone_numbers: [US_SMS, US_MMS] }, US_MEDIA)[0], 'covered');
});

test('an empty pool is 21704 and says so', () => {
  const [state, detail] = coverage({}, US);
  assert.equal(state, 'no-senders');
  assert.match(detail, /21704/);
});

test('a short code in the destination country counts', () => {
  const pool = { short_codes: [{ short_code: '12345', country_code: 'US' }] };
  assert.equal(coverage(pool, US)[0], 'covered');
});

test('a non north american gap is not reported as unreachable', () => {
  assert.equal(coverage({ phone_numbers: [US_SMS] }, GB)[0], 'no-local-sender');
  assert.equal(
    coverage({ phone_numbers: [US_SMS], alpha_senders: [ALPHA] }, GB)[0], 'alpha-only');
});

test('an unresolved country is never guessed at', () => {
  assert.equal(coverage({ phone_numbers: [US_SMS] }, { country_code: '' })[0], 'unresolved');
});

test('capabilities match across both spellings', () => {
  assert.ok(hasCapability({ capabilities: ['SMS', 'MMS'] }, 'mms'));
  assert.ok(hasCapability({ capabilities: { sms: true, mms: true } }, 'MMS'));
  assert.ok(!hasCapability({ capabilities: ['SMS'] }, 'MMS'));
});

FAQ

What is the difference between 21703 and 21704?

21704 means the pool has no senders at all, so every send through that service fails. 21703 means the pool has senders and none of them can serve this particular destination and message type, so the same service sends fine to other countries. Different code, different repair, and the script keeps an empty pool as its own state for that reason.

Why do alphanumeric sender IDs not count for a US destination?

They cannot deliver to the US or Canada, so sender selection never considers one for those destinations. That is what makes this failure so convincing: the pool is populated, the console shows senders, and for a US recipient there is nothing eligible in it.

Why is a message with an image rejected when the same text sends?

MMS is a per-sender capability. If no sender in the destination country lists MMS, a send carrying MediaUrl has no eligible sender and returns 21703 while the text-only version of the same message goes out normally.

Why can I not find the 21703s in the Messages list?

Because the rejection happens at request time, before a Message resource is created, and the Messages list has no error code filter in any case. The read-only evidence is in the Alerts API, filtered client-side on error_code, and alerts are retained for thirty days.

I added the right number and it still fails. What now?

A sender added minutes ago can still be pending on the carrier side, which looks identical to a missing sender from the outside. Check when it was added before changing anything else; if the pool matches the destination and the country rule, waiting is the correct action.

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.