Diagnostic Twilio
a voice-only From number fails every SMS with error 21606
The number works. People call it, the IVR answers, it has been on the account for two years. Then a new notification job starts sending from it and every single message is rejected with 21606: 'From' number is not a valid message-capable Twilio number for this account. Both halves of that sentence are load-bearing, and only one of them is about SMS.
Look the sender up directly: GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PhoneNumber={E164}. Read capabilities.sms, capabilities.mms, capabilities.voice and account_sid on whatever comes back.
Three separate findings share the one error code. capabilities.sms == false is a voice-only number. An empty result set means the number is not on this account at all. An account_sid that differs from the SID you authenticate with means it belongs to another subaccount. A From that is not in E.164 fails before any of that is even checked.
The problem in plain words
21606 reads like a capability error and is thrown for at least four unrelated causes, which is why it survives a whole debugging session. The number in front of you demonstrably works — you can dial it — so the error looks wrong, and the natural next step is to retry, or to blame the client library, rather than to ask which of the four things happened.
Toll-free numbers bought for an IVR are the classic case: voice-capable, not message-capable, and indistinguishable from a working SMS sender in every internal document. The subaccount case is worse, because the number is genuinely message-capable and genuinely yours; it just is not owned by the account whose credentials the job is holding, and no amount of reading the number's capabilities will explain that.
Why it happens
Capabilities are per number and are not uniform. Voice, SMS, MMS and fax are independent flags. Many non-US numbers cannot do SMS at all, plenty of toll-free numbers are sold voice-only, and nothing about the number's appearance tells you which. The flags are on the resource; they are just never read until something breaks.
The error says "for this account", and means it. A number on a sibling subaccount is not usable as a From by the parent's credentials or by another subaccount's. This is the cause that wastes the most time, because every capability on the number is correct and the fix has nothing to do with capabilities.
Format is checked before ownership. A From passed as (555) 010-1234 or 07700900123 is rejected with the same code as a number you do not own. Sending From in E.164 is the cheapest of the four fixes and the easiest to overlook, because the value came out of a database column that looks fine to a human.
Porting and hosting have a gap. A number mid-port, or an SMS-hosted number still being provisioned, exists in your plans and not yet in IncomingPhoneNumbers. The lookup returns nothing, the send fails, and the answer is to wait rather than to change anything.
The fix, as a flow
One error code with four unrelated causes, so the classifier checks them in the order Twilio does: format, then ownership, then capabilities. A subaccount number reported as voice only sends somebody the wrong way.
How to fix it
Normalise the sender before you ask Twilio about it
E.164 means a leading +, a country code, no spaces, no punctuation. Check it in your own code first: a malformed From produces the same 21606 as a number you do not own, and telling those apart after the fact costs far more than a regex.
Look the number up by value, not by paging the list
GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PhoneNumber={E164}. The filter takes the exact number, so this is one request per sender rather than a walk of the whole inventory, and it works the same on an account with four numbers and one with four hundred.
Treat an empty result as its own finding
No match means the number is not on this account: a typo, a number on a different subaccount, a port or host still provisioning, or a production number being used with test credentials. None of those are capability problems, and reporting them as one sends people to look at the wrong field.
Compare account_sid with the account you authenticated as
Where a record does come back, account_sid must equal the AccountSid in the request path. Subaccount sprawl makes this common and it is invisible in the console, where you are usually already looking at the subaccount that owns the number.
Read capabilities.mms too if you send media
capabilities.sms true and capabilities.mms false is a number that sends text and rejects anything with a MediaUrl. It is worth flagging separately rather than discovering it on the first campaign that includes an image. The repair is a replacement number: GET …/AvailablePhoneNumbers/US/Local.json?SmsEnabled=true, then buy it.
How to check it worked
Re-run with the senders your application actually uses. Every one should report ok.
python3 twilio_from_number_capability_audit.py +15550001111 +15550002222
# 2 sender(s), 0 that cannot send SMS
The full code
One GET per sender, filtered by the exact number — an API Key with read access covers it. The classifier takes the E.164 check, the ownership check and the capability check in that order, because that is the order Twilio applies them and because a report that says “voice only” about a number on another subaccount is worse than no report at all.
"""Explain 21606 for a set of Twilio From numbers before they are used.
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 re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_from_number_capability_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
E164 = re.compile(r"^\+[1-9]\d{6,14}$")
def is_e164(value):
"""A leading plus, a country code, digits only. Pure.
Twilio rejects a national-format From with the same 21606 it uses for a
number you do not own, so this has to be a separate answer rather than a
guess made after the lookup comes back empty.
"""
return bool(E164.match(str(value or "").strip()))
def verdict(sender, matches, account, need_mms=False):
"""Say why one From number would be rejected with 21606, or that it is fine.
Pure, so the four unrelated causes behind one error code are testable
without a network. `matches` is whatever IncomingPhoneNumbers returned when
filtered by this exact number; `account` is the AccountSid the credentials
authenticate as.
Returns (state, detail).
"""
if not is_e164(sender):
return ("not-e164",
"%r is not E.164. Send From as +<country><number> with no spaces "
"or punctuation; this is rejected with 21606 before ownership or "
"capabilities are looked at." % sender)
matches = list(matches or [])
if not matches:
return ("not-on-account",
"no IncomingPhoneNumber on account %s matches. A typo, a number "
"owned by another subaccount, a port or SMS-hosted number still "
"provisioning, or production digits used with test credentials."
% account)
number = matches[0]
owner = str(number.get("account_sid") or "").strip()
if owner and account and owner != account:
return ("wrong-account",
"owned by %s, but these credentials authenticate as %s. The "
"number is message capable and still cannot be used as a From "
"here: 21606 says 'for this account' and means it."
% (owner, account))
caps = number.get("capabilities")
if not isinstance(caps, dict):
return ("unresolved",
"the record carried no capabilities object, so nothing can be "
"said about SMS without re-reading it")
if not caps.get("sms"):
return ("voice-only",
"capabilities.sms is false%s. Every SMS from this number is "
"rejected with 21606; no setting turns messaging on, the repair "
"is an SMS capable replacement number."
% (" (voice is true)" if caps.get("voice") else ""))
if need_mms and not caps.get("mms"):
return ("no-mms",
"SMS works and capabilities.mms is false, so any send carrying a "
"MediaUrl fails. Add an MMS capable US or Canadian long code.")
return ("ok", "sms%s, owned by this account"
% (" and mms" if caps.get("mms") else " only"))
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 lookup(session, account, sender):
"""One request per sender, filtered by the exact number, so this costs the
same on an account with four numbers and one with four hundred."""
page = get(session, "%s/Accounts/%s/IncomingPhoneNumbers.json" % (BASE, account),
PhoneNumber=sender, PageSize=20)
return page.get("incoming_phone_numbers", [])
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("senders", nargs="+", help="the From numbers your app sends with")
ap.add_argument("--mms", action="store_true",
help="also require MMS, for senders that carry MediaUrl")
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)
bad = 0
for sender in args.senders:
matches = lookup(session, account, sender) if is_e164(sender) else []
state, detail = verdict(sender, matches, account, args.mms)
line = "%-16s %s %s" % (state, sender, detail)
if state == "ok":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" repair: find an SMS capable replacement with GET %s/Accounts/"
"%s/AvailablePhoneNumbers/US/Local.json?SmsEnabled=true and buy "
"it, or send From the subaccount that owns the number. Always "
"pass From in E.164.", BASE, account)
log.info("%d sender(s), %d that cannot send SMS", len(args.senders), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Explain 21606 for a set of Twilio From numbers before they are used.
*
* 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 E164 = /^\+[1-9]\d{6,14}$/;
/**
* A leading plus, a country code, digits only. Pure. Twilio rejects a
* national-format From with the same 21606 it uses for a number you do not own,
* so this has to be a separate answer rather than a guess made afterwards.
*/
export function isE164(value) {
return E164.test(String(value ?? '').trim());
}
/**
* Say why one From number would be rejected with 21606, or that it is fine.
* Pure, so the four unrelated causes behind one error code are testable without
* a network. `matches` is whatever IncomingPhoneNumbers returned when filtered
* by this exact number; `account` is the AccountSid the credentials
* authenticate as. Returns [state, detail].
*/
export function verdict(sender, matches, account, needMms = false) {
if (!isE164(sender)) {
return ['not-e164',
`${JSON.stringify(sender)} is not E.164. Send From as +<country><number> with ` +
'no spaces or punctuation; this is rejected with 21606 before ownership or ' +
'capabilities are looked at.'];
}
const found = [...(matches ?? [])];
if (found.length === 0) {
return ['not-on-account',
`no IncomingPhoneNumber on account ${account} matches. A typo, a number owned ` +
'by another subaccount, a port or SMS-hosted number still provisioning, or ' +
'production digits used with test credentials.'];
}
const number = found[0];
const owner = String(number.account_sid ?? '').trim();
if (owner && account && owner !== account) {
return ['wrong-account',
`owned by ${owner}, but these credentials authenticate as ${account}. The ` +
'number is message capable and still cannot be used as a From here: 21606 ' +
"says 'for this account' and means it."];
}
const caps = number.capabilities;
if (caps === null || typeof caps !== 'object') {
return ['unresolved',
'the record carried no capabilities object, so nothing can be said about SMS ' +
'without re-reading it'];
}
if (!caps.sms) {
return ['voice-only',
`capabilities.sms is false${caps.voice ? ' (voice is true)' : ''}. Every SMS ` +
'from this number is rejected with 21606; no setting turns messaging on, the ' +
'repair is an SMS capable replacement number.'];
}
if (needMms && !caps.mms) {
return ['no-mms',
'SMS works and capabilities.mms is false, so any send carrying a MediaUrl ' +
'fails. Add an MMS capable US or Canadian long code.'];
}
return ['ok', `sms${caps.mms ? ' and mms' : ' only'}, owned by this account`];
}
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 lookup(auth, account, sender) {
const page = await get(auth, `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`,
{ PhoneNumber: sender, PageSize: 20 });
return page.incoming_phone_numbers ?? [];
}
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 needMms = process.argv.includes('--mms');
const senders = process.argv.slice(2).filter((a) => !a.startsWith('--'));
if (senders.length === 0) {
console.error('usage: node twilio-from-number-capability-audit.mjs +1555... [--mms]');
process.exitCode = 2;
return;
}
let bad = 0;
for (const sender of senders) {
const matches = isE164(sender) ? await lookup(auth, account, sender) : [];
const [state, detail] = verdict(sender, matches, account, needMms);
const line = `${state.padEnd(16)} ${sender} ${detail}`;
if (state === 'ok') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` repair: find an SMS capable replacement with GET ${BASE}/Accounts/` +
`${account}/AvailablePhoneNumbers/US/Local.json?SmsEnabled=true and ` +
'buy it, or send From the subaccount that owns the number. Always ' +
'pass From in E.164.');
}
console.log(`${senders.length} sender(s), ${bad} that cannot send SMS`);
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
One error code, four causes, and the tests exist to keep them apart. A number on a sibling subaccount must not be reported as voice-only, because its capabilities are perfect and the repair is somewhere else entirely. A national-format From must be caught before the lookup, since an empty result would otherwise be blamed on ownership. And MMS is only a finding when the caller says they send media.
from twilio_from_number_capability_audit import is_e164, verdict
ACCOUNT = "AC11111111111111111111111111111111"
SUB = "AC22222222222222222222222222222222"
def number(sms=True, mms=True, voice=True, account=ACCOUNT):
return {"phone_number": "+15550001111", "account_sid": account,
"capabilities": {"sms": sms, "mms": mms, "voice": voice}}
def test_e164_is_checked_the_way_twilio_checks_it():
assert is_e164("+15550001111")
assert not is_e164("(555) 010-1234")
assert not is_e164("15550001111")
assert not is_e164("+0123456789")
assert not is_e164(None)
def test_national_format_is_named_rather_than_blamed_on_ownership():
state, detail = verdict("(555) 010-1234", [], ACCOUNT)
assert state == "not-e164"
assert "21606" in detail
def test_a_voice_only_number_is_the_capability_case():
state, detail = verdict("+15550001111", [number(sms=False, mms=False)], ACCOUNT)
assert state == "voice-only"
assert "capabilities.sms is false" in detail
assert "voice is true" in detail
def test_a_number_on_another_subaccount_is_not_a_capability_problem():
# Perfect capabilities, still 21606. Reporting this as voice-only sends
# somebody to buy a number they already own.
state, detail = verdict("+15550001111", [number(account=SUB)], ACCOUNT)
assert state == "wrong-account"
assert SUB in detail and ACCOUNT in detail
def test_no_match_at_all_is_its_own_finding():
state, detail = verdict("+15550001111", [], ACCOUNT)
assert state == "not-on-account"
assert "provisioning" in detail
def test_mms_is_only_a_finding_when_media_is_sent():
assert verdict("+15550001111", [number(mms=False)], ACCOUNT)[0] == "ok"
state, _ = verdict("+15550001111", [number(mms=False)], ACCOUNT, need_mms=True)
assert state == "no-mms"
def test_a_record_without_capabilities_is_not_guessed_at():
state, _ = verdict("+15550001111", [{"account_sid": ACCOUNT}], ACCOUNT)
assert state == "unresolved"
def test_a_healthy_sender_says_what_it_can_do():
state, detail = verdict("+15550001111", [number()], ACCOUNT)
assert state == "ok"
assert "sms and mms" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isE164, verdict } from './twilio-from-number-capability-audit.mjs';
const ACCOUNT = 'AC11111111111111111111111111111111';
const SUB = 'AC22222222222222222222222222222222';
const number = ({ sms = true, mms = true, voice = true, account = ACCOUNT } = {}) => ({
phone_number: '+15550001111', account_sid: account,
capabilities: { sms, mms, voice },
});
test('e164 is checked the way Twilio checks it', () => {
assert.ok(isE164('+15550001111'));
assert.ok(!isE164('(555) 010-1234'));
assert.ok(!isE164('15550001111'));
assert.ok(!isE164('+0123456789'));
assert.ok(!isE164(null));
});
test('national format is named rather than blamed on ownership', () => {
const [state, detail] = verdict('(555) 010-1234', [], ACCOUNT);
assert.equal(state, 'not-e164');
assert.match(detail, /21606/);
});
test('a voice only number is the capability case', () => {
const [state, detail] = verdict('+15550001111', [number({ sms: false, mms: false })], ACCOUNT);
assert.equal(state, 'voice-only');
assert.match(detail, /capabilities\.sms is false/);
assert.match(detail, /voice is true/);
});
test('a number on another subaccount is not a capability problem', () => {
const [state, detail] = verdict('+15550001111', [number({ account: SUB })], ACCOUNT);
assert.equal(state, 'wrong-account');
assert.match(detail, new RegExp(SUB));
assert.match(detail, new RegExp(ACCOUNT));
});
test('no match at all is its own finding', () => {
const [state, detail] = verdict('+15550001111', [], ACCOUNT);
assert.equal(state, 'not-on-account');
assert.match(detail, /provisioning/);
});
test('mms is only a finding when media is sent', () => {
assert.equal(verdict('+15550001111', [number({ mms: false })], ACCOUNT)[0], 'ok');
assert.equal(
verdict('+15550001111', [number({ mms: false })], ACCOUNT, true)[0], 'no-mms');
});
test('a record without capabilities is not guessed at', () => {
assert.equal(
verdict('+15550001111', [{ account_sid: ACCOUNT }], ACCOUNT)[0], 'unresolved');
});
test('a healthy sender says what it can do', () => {
const [state, detail] = verdict('+15550001111', [number()], ACCOUNT);
assert.equal(state, 'ok');
assert.match(detail, /sms and mms/);
});
FAQ
The number works for voice. Why is SMS rejected?
Because capabilities are per channel. capabilities.voice true with capabilities.sms false is an ordinary, supported configuration, common on toll-free numbers bought for an IVR and on many non-US numbers. There is no setting that adds messaging to a number that was not sold with it.
Can I enable SMS on a number that does not have it?
No. The capability is a property of the number as provisioned by the carrier, not an account setting. The repair is to buy a replacement filtered on SmsEnabled=true, or to use a number that already has the capability.
Why does a number I definitely own still return 21606?
Most often it is owned by a different subaccount than the credentials sending the message. Compare account_sid on the number with the AccountSid you authenticate as. Same organisation, same console, different account as far as the API is concerned.
Does the From format really matter?
Yes, and it is checked before ownership. A national-format From is rejected with the same 21606 as a number you do not own, which is why the script checks the format itself before it asks Twilio anything.
What if the number is mid-port or SMS-hosted?
Then it will not appear in IncomingPhoneNumbers yet and the lookup returns nothing. That reads as not-on-account, and the repair is to wait for provisioning rather than to change any configuration.
Related field notes
- A Messaging Service with no senders at all
- SMS to landlines that can never receive it
- A Messaging Service with no A2P campaign
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 21606: 'From' is not a valid message-capable number — Twilio Docs
- IncomingPhoneNumber resource — Twilio Docs
- Message resource — Twilio Docs
- API keys — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.