Diagnostic Twilio
SMS to a landline fails with 30006 and retrying never helps
The same twelve numbers fail every night. error_code 30006, status undelivered, and a retry scheduled by a queue that assumes failures are temporary. They are not. Those numbers are desk phones, and no amount of retrying will make a desk phone receive an SMS — but you are billed for each attempt, and the customer is on your list as unreachable rather than as unreachable-by-this-channel.
Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and collect the distinct to values on rows with error_code 30006 (undelivered, after billing) or 21614 (rejected at request time, not billed).
Then confirm each one with GET https://lookups.twilio.com/v2/PhoneNumbers/{E164}?Fields=line_type_intelligence and read line_type_intelligence.type. landline or fixedVoip means permanently undeliverable. mobile means the line is fine and your sender cannot reach it, which is a completely different repair.
The problem in plain words
A permanent failure that looks temporary is worse than a loud one, because the system built around it keeps working. The message goes out, comes back undelivered, and lands in the retry queue with the connection timeouts and the carrier hiccups. Tomorrow it goes out again. There is no counter anywhere that says "this address has failed thirty nights running", so nothing ever escalates it to a human.
The two error codes also arrive from opposite ends of the pipeline, which splits the evidence. 21614 is a request-time rejection: the number was never sent to, never billed, and the failure is visible immediately. 30006 comes back after the segment is priced and the message has been handed to a carrier. Read only one of them and you either miss the paid failures or miss the rejected ones.
Why it happens
Landlines are indistinguishable from mobiles in an E.164 string. Nothing in +15551234567 says whether it rings on a desk or in a pocket. In North America the number ranges have been portable for two decades, so area codes and prefixes tell you nothing either. Only a carrier lookup knows, and only if you ask.
Contact forms collect whatever the customer types. Someone gives you their office line because it is the number they know by heart. The signup succeeds, the record is valid, and the failure surfaces weeks later in a channel nobody watches.
30006 is not exclusively about landlines. The same code comes back when the sending route cannot reach the destination carrier at all — classically a short code with no long-code fallback in the pool. Same code, mobile handset, and dropping the contact would be the wrong fix, which is precisely why the line type is worth the lookup.
Retries are the default and they are free to schedule. Message queues retry failed sends because most failures are transient. Nothing in the message resource marks 30006 as permanent, so the queue has no way to know it should stop, and each cycle bills you again for a message that physically cannot arrive.
The fix, as a flow
The script asks Lookup for the line type only on numbers that already failed, because Line Type Intelligence is billed per number and the failure history is free.
How to fix it
Page the Messages list and keep the two codes
GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. There is no ErrorCode filter, so read error_code yourself and keep 30006 and 21614. Count them separately: one was billed and the other was not.
Group by destination and count the nights
One failure could be anything. The same to failing on several distinct days is the shape of a permanent problem, and it is also the shape a retry queue makes when it never gives up. Keep a couple of Message SIDs per number so the finding can be checked by hand.
Confirm the line type with Lookup
GET https://lookups.twilio.com/v2/PhoneNumbers/{E164}?Fields=line_type_intelligence. It is a GET, so it stays inside a read-only credential, but Line Type Intelligence is billed per lookup — put it behind a flag and a cap rather than running it over every number in the window.
Split landline from unreachable
landline and fixedVoip are permanent: that contact will never receive SMS. mobile with repeated 30006 is the opposite finding — the handset is fine and your sender cannot reach the carrier, which usually means a short code with no long-code fallback in the sender pool.
Gate at capture time, not at send time
Run the same Lookup when the number is first collected and route landline contacts to voice or email instead. Suppress the confirmed landlines you already have, and if the failures are on a short code, widen the Messaging Service sender pool. Re-run the audit after a month; new bad numbers arrive with new customers.
How to check it worked
Re-run over the same window once the suppression list is live. Confirmed landlines should stop appearing because nothing is being sent to them.
python3 twilio_landline_audit.py --days 30 --confirm-with-lookup
# 8 destination(s) over 30 day(s), 0 still being retried
The full code
One paginated GET over the Messages list, plus at most one Lookup per flagged number when you ask for it — both GETs, both inside an API Key with read access. The verdict is pure and takes the line type as an argument rather than fetching it, so the interesting decision (landline versus a sender that cannot reach the carrier) is testable without spending anything.
"""Report SMS destinations that can never receive a message: 30006 and 21614.
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 spend money.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_landline_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
LOOKUPS = "https://lookups.twilio.com/v2/PhoneNumbers"
UNDELIVERABLE = 30006 # undelivered, after the segment was billed
NOT_MOBILE = 21614 # rejected at request time, never billed
NO_SMS = ("landline", "fixedvoip")
def error_code(message):
"""Read error_code as an integer, or None. It is null on healthy messages
and a number on failed ones; a string comparison finds nothing."""
raw = message.get("error_code")
if raw is None or raw == "":
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def tally(messages):
"""Group failures by destination, keeping the two codes apart.
Pure, so the counting can be tested without a network. 30006 was billed and
21614 was not, and a report that adds them together loses the only number
anyone will ask you for.
"""
out = {}
for m in messages:
if str(m.get("direction") or "").startswith("inbound"):
continue
code = error_code(m)
if code not in (UNDELIVERABLE, NOT_MOBILE):
continue
row = out.setdefault(str(m.get("to") or "unknown"),
{"attempts": 0, "undelivered": 0, "rejected": 0,
"sids": []})
row["attempts"] += 1
if code == UNDELIVERABLE:
row["undelivered"] += 1
else:
row["rejected"] += 1
if len(row["sids"]) < 3:
row["sids"].append(m.get("sid"))
return out
def describe(record):
"""Say which of the two failures this destination produced, and at what
cost. Pure."""
parts = []
if record.get("undelivered"):
parts.append("%d undelivered with 30006 and billed"
% record["undelivered"])
if record.get("rejected"):
parts.append("%d rejected at request time with 21614 and not billed"
% record["rejected"])
return " and ".join(parts) if parts else "no refused attempts"
def verdict(record, line_type=None):
"""Classify one destination. `line_type` is line_type_intelligence.type from
Lookup when it was fetched, and None when it was not.
Pure, so the distinction that matters here can be tested without spending a
lookup. Returns (state, detail).
"""
failed = int(record.get("undelivered") or 0) + int(record.get("rejected") or 0)
if not failed:
return ("clean", "%d attempt(s), none refused" % (record.get("attempts") or 0))
told = describe(record)
kind = str(line_type or "").strip()
if kind.lower() in NO_SMS:
return ("landline",
"Lookup says %s, which cannot receive SMS at any price: %s. "
"Retrying never helps." % (kind, told))
if kind.lower() == "mobile":
return ("sender-cannot-reach",
"Lookup says mobile, so this is not a landline: %s. The handset "
"is fine and the sending route cannot reach that carrier, which "
"is what a short code with no long code fallback looks like."
% told)
if kind and kind.lower() != "unknown":
return ("not-sms-capable",
"Lookup says %s, which is not an SMS capable line: %s."
% (kind, told))
if failed == 1:
return ("one-off",
"a single failure and no line type: %s. Confirm with Lookup "
"before dropping the contact." % told)
return ("undeliverable",
"%d refused attempt(s) with no line type: %s. Treat it as permanent "
"and confirm with Lookup Line Type Intelligence." % (failed, told))
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_messages(session, account, since, limit):
"""Page Messages.json. No ErrorCode filter exists on this resource, so the
window and the cap are the only bounds available."""
url = "%s/Accounts/%s/Messages.json" % (BASE, account)
params = {"PageSize": 1000, "DateSent>=": since}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("messages", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def line_type(session, e164):
"""One billed Lookup. A 404 means the number is not valid at all, which is
an answer rather than an error."""
r = session.get("%s/%s" % (LOOKUPS, e164),
params={"Fields": "line_type_intelligence"}, timeout=30)
if r.status_code == 404:
return "invalid"
if r.status_code in (401, 403):
raise SystemExit("%d from Lookups: the API key needs read access to "
"Lookup as well" % r.status_code)
r.raise_for_status()
body = r.json()
if body.get("valid") is False:
return "invalid"
return (body.get("line_type_intelligence") or {}).get("type")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="how far back to read the Messages list")
ap.add_argument("--max-messages", type=int, default=20000,
help="stop paging after this many messages")
ap.add_argument("--confirm-with-lookup", action="store_true",
help="one billed Lookup per flagged destination")
ap.add_argument("--max-lookups", type=int, default=50,
help="hard cap on billed lookups per run")
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)
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
messages = list_messages(session, account, since, args.max_messages)
destinations = tally(messages)
if not destinations:
log.info("no 30006 or 21614 failures since %s", since)
return 0
spent = 0
bad = 0
for number, record in sorted(destinations.items()):
kind = None
if args.confirm_with_lookup and spent < args.max_lookups:
kind = line_type(session, number)
spent += 1
state, detail = verdict(record, kind)
line = "%-20s %s %s" % (state, number, detail)
if state == "clean":
log.info(line)
continue
bad += 1
log.warning(line)
log.warning(" message sids: %s", ", ".join(str(s) for s in record["sids"]))
if state == "sender-cannot-reach":
log.warning(" repair: add a long code sender to the Messaging "
"Service pool with POST %s/Services/{ServiceSid}"
"/PhoneNumbers PhoneNumberSid=PN...",
"https://messaging.twilio.com/v1")
else:
log.warning(" repair: suppress %s in your own database and gate new "
"numbers at capture time with GET %s/{E164}"
"?Fields=line_type_intelligence", number, LOOKUPS)
log.info("%d destination(s) over %d day(s), %d still being retried",
len(destinations), args.days, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report SMS destinations that can never receive a message: 30006 and 21614.
*
* 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 LOOKUPS = 'https://lookups.twilio.com/v2/PhoneNumbers';
const MSG = 'https://messaging.twilio.com/v1';
const UNDELIVERABLE = 30006; // undelivered, after the segment was billed
const NOT_MOBILE = 21614; // rejected at request time, never billed
const NO_SMS = ['landline', 'fixedvoip'];
/** Read error_code as a number, or null. A string comparison finds nothing. */
export function errorCode(message) {
const raw = message.error_code;
if (raw === null || raw === undefined || raw === '') return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
/**
* Group failures by destination, keeping the two codes apart: 30006 was billed
* and 21614 was not. Pure, so the counting can be tested without a network.
*/
export function tally(messages) {
const out = new Map();
for (const m of messages) {
if (String(m.direction ?? '').startsWith('inbound')) continue;
const code = errorCode(m);
if (code !== UNDELIVERABLE && code !== NOT_MOBILE) continue;
const k = String(m.to ?? 'unknown');
if (!out.has(k)) out.set(k, { attempts: 0, undelivered: 0, rejected: 0, sids: [] });
const row = out.get(k);
row.attempts += 1;
if (code === UNDELIVERABLE) row.undelivered += 1; else row.rejected += 1;
if (row.sids.length < 3) row.sids.push(m.sid);
}
return out;
}
/** Say which failures this destination produced, and at what cost. Pure. */
export function describe(record) {
const parts = [];
if (record.undelivered) {
parts.push(`${record.undelivered} undelivered with 30006 and billed`);
}
if (record.rejected) {
parts.push(`${record.rejected} rejected at request time with 21614 and not billed`);
}
return parts.length ? parts.join(' and ') : 'no refused attempts';
}
/**
* Classify one destination. `lineType` is line_type_intelligence.type from
* Lookup when it was fetched, and null when it was not. Pure, so the
* distinction that matters can be tested without spending a lookup.
* Returns [state, detail].
*/
export function verdict(record, lineType = null) {
const failed = Number(record.undelivered ?? 0) + Number(record.rejected ?? 0);
if (!failed) return ['clean', `${record.attempts ?? 0} attempt(s), none refused`];
const told = describe(record);
const kind = String(lineType ?? '').trim();
if (NO_SMS.includes(kind.toLowerCase())) {
return ['landline',
`Lookup says ${kind}, which cannot receive SMS at any price: ${told}. ` +
'Retrying never helps.'];
}
if (kind.toLowerCase() === 'mobile') {
return ['sender-cannot-reach',
`Lookup says mobile, so this is not a landline: ${told}. The handset is ` +
'fine and the sending route cannot reach that carrier, which is what a ' +
'short code with no long code fallback looks like.'];
}
if (kind && kind.toLowerCase() !== 'unknown') {
return ['not-sms-capable',
`Lookup says ${kind}, which is not an SMS capable line: ${told}.`];
}
if (failed === 1) {
return ['one-off',
`a single failure and no line type: ${told}. Confirm with Lookup before ` +
'dropping the contact.'];
}
return ['undeliverable',
`${failed} refused attempt(s) with no line type: ${told}. Treat it as ` +
'permanent and confirm with Lookup Line Type Intelligence.'];
}
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 listMessages(auth, account, since, limit = 20000) {
let url = `${BASE}/Accounts/${account}/Messages.json`;
let params = { PageSize: 1000, 'DateSent>=': since };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.messages ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out.slice(0, limit);
}
async function lineType(auth, e164) {
const u = new URL(`${LOOKUPS}/${e164}`);
u.searchParams.set('Fields', 'line_type_intelligence');
const res = await fetch(u, { headers: { Authorization: auth } });
if (res.status === 404) return 'invalid';
if (!res.ok) throw new Error(`${res.status} from Lookups for ${e164}`);
const body = await res.json();
if (body.valid === false) return 'invalid';
return body.line_type_intelligence?.type ?? null;
}
async function main() {
const account = process.env.TWILIO_ACCOUNT_SID;
const key = process.env.TWILIO_API_KEY;
const secret = process.env.TWILIO_API_SECRET;
if (!account || !key || !secret) {
console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
'(an API Key with read access, not the auth token)');
process.exitCode = 2;
return;
}
const auth = authHeader(key, secret);
const days = Number(process.argv.includes('--days')
? process.argv[process.argv.indexOf('--days') + 1] : 30) || 30;
const confirm = process.argv.includes('--confirm-with-lookup');
const maxLookups = 50;
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const destinations = tally(await listMessages(auth, account, since));
if (destinations.size === 0) {
console.log(`no 30006 or 21614 failures since ${since}`);
return;
}
let spent = 0;
let bad = 0;
for (const [number, record] of [...destinations.entries()].sort()) {
let kind = null;
if (confirm && spent < maxLookups) { kind = await lineType(auth, number); spent += 1; }
const [state, detail] = verdict(record, kind);
const line = `${state.padEnd(20)} ${number} ${detail}`;
if (state === 'clean') { console.log(line); continue; }
bad += 1;
console.warn(line);
console.warn(` message sids: ${record.sids.join(', ')}`);
if (state === 'sender-cannot-reach') {
console.warn(' repair: add a long code sender to the Messaging Service ' +
`pool with POST ${MSG}/Services/{ServiceSid}/PhoneNumbers ` +
'PhoneNumberSid=PN...');
} else {
console.warn(` repair: suppress ${number} in your own database and gate ` +
`new numbers at capture time with GET ${LOOKUPS}/{E164}` +
'?Fields=line_type_intelligence');
}
}
console.log(`${destinations.size} destination(s) over ${days} day(s), ${bad} ` +
'still being retried');
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 case that earns the lookup is the mobile one: a handset that keeps returning 30006 is not a landline, and dropping that contact would be exactly the wrong repair. The rest of the tests hold the two codes apart, because the first question anyone asks about this report is how much of it was billed.
from twilio_landline_audit import describe, tally, verdict
DESK = "+15551230000"
def failure(sid, code, to=DESK):
return {"sid": sid, "direction": "outbound-api", "to": to,
"status": "undelivered", "error_code": code}
def test_the_two_codes_are_counted_separately():
rows = tally([failure("SM1", 30006), failure("SM2", 21614),
failure("SM3", 30006), failure("SM4", 30007)])
assert rows[DESK]["undelivered"] == 2
assert rows[DESK]["rejected"] == 1
assert rows[DESK]["attempts"] == 3 # 30007 belongs to a different report
def test_describe_says_which_half_was_billed():
told = describe({"undelivered": 2, "rejected": 1})
assert "billed" in told
assert "not billed" in told
def test_lookup_landline_is_permanent():
state, detail = verdict({"undelivered": 4}, "landline")
assert state == "landline"
assert "Retrying never helps" in detail
def test_fixed_voip_is_treated_like_a_landline():
assert verdict({"undelivered": 2}, "fixedVoip")[0] == "landline"
def test_a_mobile_that_keeps_failing_is_the_senders_problem():
state, detail = verdict({"undelivered": 6}, "mobile")
assert state == "sender-cannot-reach"
assert "short code" in detail
def test_no_lookup_and_one_failure_is_not_yet_a_verdict():
state, detail = verdict({"rejected": 1})
assert state == "one-off"
assert "Confirm with Lookup" in detail
def test_no_lookup_and_repeated_failures_is_treated_as_permanent():
state, detail = verdict({"undelivered": 5})
assert state == "undeliverable"
assert "5 refused" in detail
def test_an_unknown_line_type_does_not_pretend_to_know():
assert verdict({"undelivered": 5}, "unknown")[0] == "undeliverable"
assert verdict({"undelivered": 5}, "invalid")[0] == "not-sms-capable"
assert verdict({"attempts": 3})[0] == "clean"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { describe as told, tally, verdict } from './twilio-landline-audit.mjs';
const DESK = '+15551230000';
const failure = (sid, code, to = DESK) => ({
sid, direction: 'outbound-api', to, status: 'undelivered', error_code: code,
});
test('the two codes are counted separately', () => {
const rows = tally([failure('SM1', 30006), failure('SM2', 21614),
failure('SM3', 30006), failure('SM4', 30007)]);
const row = rows.get(DESK);
assert.equal(row.undelivered, 2);
assert.equal(row.rejected, 1);
assert.equal(row.attempts, 3); // 30007 belongs to a different report
});
test('describe says which half was billed', () => {
const line = told({ undelivered: 2, rejected: 1 });
assert.match(line, /and billed/);
assert.match(line, /not billed/);
});
test('lookup landline is permanent', () => {
const [state, detail] = verdict({ undelivered: 4 }, 'landline');
assert.equal(state, 'landline');
assert.match(detail, /Retrying never helps/);
});
test('fixed voip is treated like a landline', () => {
assert.equal(verdict({ undelivered: 2 }, 'fixedVoip')[0], 'landline');
});
test('a mobile that keeps failing is the sender problem', () => {
const [state, detail] = verdict({ undelivered: 6 }, 'mobile');
assert.equal(state, 'sender-cannot-reach');
assert.match(detail, /short code/);
});
test('no lookup and one failure is not yet a verdict', () => {
const [state, detail] = verdict({ rejected: 1 });
assert.equal(state, 'one-off');
assert.match(detail, /Confirm with Lookup/);
});
test('no lookup and repeated failures is treated as permanent', () => {
const [state, detail] = verdict({ undelivered: 5 });
assert.equal(state, 'undeliverable');
assert.match(detail, /5 refused/);
});
test('an unknown line type does not pretend to know', () => {
assert.equal(verdict({ undelivered: 5 }, 'unknown')[0], 'undeliverable');
assert.equal(verdict({ undelivered: 5 }, 'invalid')[0], 'not-sms-capable');
assert.equal(verdict({ attempts: 3 })[0], 'clean');
});
FAQ
What is the difference between 30006 and 21614?
21614 is a request-time rejection: Twilio decides the To number is not a valid mobile number, nothing is sent and nothing is billed. 30006 comes back later, after the message was accepted, priced and handed onward, and the carrier reported that the destination is a landline or unreachable. Same underlying fact, opposite ends of the pipeline.
Does a Lookup cost money if the script is read-only?
Read-only and free are different things. Line Type Intelligence is a GET, so it fits inside an API Key with read access, but it is a billed lookup per number. That is why it sits behind a flag and a hard cap: the Messages read is free and the confirmation is not.
Can I tell a landline from a mobile without paying for a lookup?
Not reliably. Number ranges have been portable in North America for twenty years, so prefixes tell you nothing about the device. The failure history is the free signal, and it is a good one: the same destination refusing on several distinct days is close to proof even before the lookup confirms it.
The line type came back mobile but the messages keep failing. Now what?
Then the destination is not the problem and the sender is. A short code that cannot reach that carrier returns 30006 exactly like a landline does. Add a long-code fallback sender to the Messaging Service pool so those messages have another route, and stop suppressing the contact.
Should the script delete the bad numbers for me?
No, and nothing here writes. Suppression belongs in your own database where you can see who did it and when, and a script holding a messaging credential should not be the thing that edits your customer list at 3am.
Related field notes
- Carrier filtering drops your SMS silently
- Messages that never reach a final state
- 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 30006: landline or unreachable carrier — Twilio Docs
- Error 21614: 'To' number is not a valid mobile number — Twilio Docs
- Lookup v2 Line Type Intelligence — Twilio Docs
- Message resource — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.