Diagnostic Twilio
a short code used outside its own country fails 21612
The short code has been delivering domestic traffic for two years at a throughput no long code can match. Then a customer in Canada is added to the same campaign and those messages come back 21612. Nothing changed about the short code, the Messaging Service or the campaign registration — the destination changed, and a short code is licensed for exactly one country.
Enumerate the account's short codes with GET /2010-04-01/Accounts/{AccountSid}/SMS/ShortCodes.json, then find which Messaging Services can select one with GET https://messaging.twilio.com/v1/Services/{ServiceSid}/ShortCodes. A service whose pool holds both a short code and long codes will eventually hand an international message to the short code.
Confirm it in the traffic: page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=…, filter error_code in {21612, 21606} client-side, and group by from. Failures whose sender is a short code and whose destination is outside its country are this problem; the same codes from a long code are something else.
The problem in plain words
A short code is a national licence, not a phone number that happens to be short. A US short code reaches US handsets and nothing else. That constraint is invisible in the resource: GET /SMS/ShortCodes.json returns the digits, the SID and the webhook URLs, and says nothing about the country the code is licensed in, so no configuration audit can compare a short code against a destination.
What makes it a production incident rather than a known limitation is sender selection. A Messaging Service picks a sender from its pool per message. Put a short code in a pool alongside long codes and the choice is made per send, at send time, by logic your code does not see. Domestic traffic works. The first international recipient gets whichever sender was picked, and if that is the short code the message is rejected outright with 21612, or 21606, at request time.
Why it happens
Short codes are licensed per country and nothing in the API says which. The ShortCode resource carries no country field, so the licensing country has to come from you. This script takes it as an argument and treats every other destination as cross-border, which is honest about where the knowledge lives.
Sender selection is per message, so the failure is intermittent by design. The same service, the same code, the same campaign: two sends to two countries take different paths. That is why this arrives as "some customers never get the code" rather than as an outage, and why it survives a test suite that only sends domestically.
Short codes sit outside A2P 10DLC, which reads as approval. They need no brand and no campaign, so a team that fought through 10DLC registration and then added a short code to the same service reasonably assumes the hard part is behind them. The geographic constraint is a separate rule that the registration says nothing about.
The error codes are shared with other faults. 21606 is also what a voice-only long code returns, and 21612 covers several unreachable combinations. Grouping by sender rather than by code is what separates this from those: a short code that fails only on foreign destinations is this note, and a long code failing everywhere is a different one.
The rejection is at request time, so it may leave no message row. Sends refused before a message is created appear in the Alerts log rather than in the message list, which means the count you get from the traffic is a floor rather than a total.
The fix, as a flow
The script takes the licensing country as an argument, because the ShortCode resource carries the digits and no country. Everything else is read: the pool shape from the service, the destinations from the traffic.
How to fix it
List the account's short codes
GET /2010-04-01/Accounts/{AccountSid}/SMS/ShortCodes.json?PageSize=100. Read short_code and sid. This is the set of senders the rest of the audit is about; an account with none of them has nothing to check here.
Find every Messaging Service that can select one
GET https://messaging.twilio.com/v1/Services, then GET /v1/Services/{ServiceSid}/ShortCodes and GET /v1/Services/{ServiceSid}/PhoneNumbers for each. A pool holding both kinds is the configuration that makes the failure possible, and it is visible before anyone sends a message.
Read the traffic the service actually carried
Page the message list over a window and group by messaging_service_sid. Count the destinations outside the short code's licensing country, and count the sends already rejected with 21612 or 21606 from a short code. The first number says the exposure is real; the second says it has already cost you, and it is the only one that can see the border inside +1, where a US short code and a Canadian handset share a calling code.
Tell the two kinds of 21606 apart
Group by sender before you group by error code. A short code failing only on foreign destinations is this problem. A long code failing on every destination is a sender with no SMS capability, which is a different note and a different repair.
Segregate the pools by destination
Take the short code out of the mixed service and give international traffic its own Messaging Service with long codes or a registered alphanumeric sender. Then re-run: the check is cheap, and the next person to add a short code to a working service will do it for the same good reason as the last one.
How to check it worked
Re-run after splitting the pools. No service should report a mixed pool, and no short code should carry cross-border rejections.
python3 twilio_short_code_audit.py --home-country 1 --days 7
# 3 service(s), 0 with a short code exposed to cross-border traffic
The full code
Four reads: the account's short codes, the Messaging Services, each service's two pool subresources, and the message list for the window. The classifier takes one service's pool and its traffic and decides whether the short code is already failing, exposed, or fine — and it takes the licensing country as an argument, because the ShortCode resource does not carry one and inventing a default that looks authoritative would be worse than asking.
"""Report Twilio short codes exposed to destinations they are not licensed for.
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 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_short_code_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MESSAGING = "https://messaging.twilio.com/v1"
# 21612 is the combination of To and From that cannot be delivered; 21606 is the
# From that cannot send to this destination. Both are what a short code returns
# for a handset outside its own country, and both are request-time rejections.
CROSS_BORDER = (21612, 21606)
DIAL_CODES = {
"1", "7", "20", "27", "30", "31", "32", "33", "34", "36", "39", "40", "43",
"44", "45", "46", "47", "48", "49", "51", "52", "54", "55", "56", "57",
"58", "60", "61", "62", "63", "64", "65", "66", "81", "82", "84", "86",
"90", "91", "92", "94", "212", "213", "234", "254", "351", "353", "358",
"380", "420", "421", "852", "880", "886", "966", "971", "972", "977", "998",
}
def error_code(message):
"""Read error_code as an integer, or None. Some exports return a string, and
comparing the raw value finds nothing on an account full of findings."""
raw = message.get("error_code")
if raw is None or raw == "":
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def is_short_code(value):
"""True for a short code, which in a message row is simply a short run of
digits with no plus sign. Long codes are E.164 and alphanumeric senders are
not digits at all, so this is the whole distinction available."""
raw = str(value or "").strip()
return bool(raw) and raw.isdigit() and 3 <= len(raw) <= 8
def dial_code(to):
"""Longest matching country calling code for an E.164 destination, or None.
A destination this cannot resolve is left out of the cross-border count
rather than assumed foreign: the point of the count is to be believed. For
the same reason the count cannot see the border inside +1, where a US short
code and a Canadian handset share a calling code; that pairing is caught by
the observed 21612 rejections instead.
"""
raw = str(to or "").strip()
if not raw.startswith("+"):
return None
digits = "".join(c for c in raw[1:] if c.isdigit())
for size in (3, 2, 1):
if digits[:size] in DIAL_CODES:
return digits[:size]
return None
def tally(messages):
"""Bucket outbound messages by the Messaging Service that carried them.
Pure, so the grouping can be tested without a network. Sends with no
messaging_service_sid are grouped under the empty key: a short code used
directly as From is exposed in exactly the same way, minus the selection.
"""
out = {}
for m in messages:
if str(m.get("direction") or "").startswith("inbound"):
continue
sid = str(m.get("messaging_service_sid") or "")
row = out.setdefault(sid, {"service": sid, "total": 0, "blocked": 0,
"destinations": {}, "sids": []})
row["total"] += 1
code = dial_code(m.get("to"))
if code:
row["destinations"][code] = row["destinations"].get(code, 0) + 1
if error_code(m) in CROSS_BORDER and is_short_code(m.get("from")):
row["blocked"] += 1
if len(row["sids"]) < 3:
row["sids"].append(m.get("sid"))
return out
def verdict(service, home="1"):
"""Classify one Messaging Service's exposure to cross-border short code use.
`service` carries the pool (`short_codes`, `long_codes`, `alpha_senders`)
and the traffic tally (`total`, `blocked`, `destinations`). `home` is the
calling code the short codes are licensed in, and it is an argument because
the ShortCode resource does not carry a country: guessing one and printing
it as fact would be worse than asking.
Pure. Returns (state, detail).
"""
short = list(service.get("short_codes") or [])
longs = int(service.get("long_codes") or 0)
alpha = int(service.get("alpha_senders") or 0)
blocked = int(service.get("blocked") or 0)
destinations = service.get("destinations") or {}
foreign = sum(n for code, n in destinations.items() if code != str(home))
if not short:
return ("no-short-code",
"no short code in the pool, so nothing here can be selected for a "
"country it is not licensed in.")
if blocked:
return ("blocked",
"%d send(s) from a short code rejected with 21612 or 21606. The "
"short code %s is licensed for +%s only, and selection handed it "
"a handset somewhere else."
% (blocked, ", ".join(short[:2]), home))
if foreign and not longs and not alpha:
return ("unreachable-abroad",
"%d message(s) went to destinations outside +%s and the pool has "
"nothing but short codes. There is no sender here that can carry "
"them, so every one of those sends fails at request time."
% (foreign, home))
if foreign:
return ("exposed",
"the pool mixes %d short code(s) with %d long code(s), and %d "
"message(s) went outside +%s. Selection is per message, so the "
"one that draws the short code is rejected while the rest "
"deliver." % (len(short), longs, foreign, home))
return ("domestic-only",
"%d short code(s) in the pool and all %d message(s) stayed inside "
"+%s. Correct today; the first international recipient is what "
"changes it." % (len(short), int(service.get("total") or 0), home))
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_short_codes(session, account):
"""The account's short codes. The resource carries the digits, the SID and
the handler URLs, and no country: that has to come from the operator."""
page = get(session, "%s/Accounts/%s/SMS/ShortCodes.json" % (BASE, account),
PageSize=100)
return [str(s.get("short_code") or "") for s in page.get("short_codes", [])]
def list_messages(session, account, since, limit):
"""Page Messages.json. No Status or ErrorCode filter exists here, so the
window and the page cap are the only bounds."""
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 pools(session):
"""Every Messaging Service with the shape of its sender pool."""
out = {}
services = get(session, "%s/Services" % MESSAGING, PageSize=100).get("services", [])
for svc in services:
sid = svc.get("sid")
codes = get(session, "%s/Services/%s/ShortCodes" % (MESSAGING, sid),
PageSize=100).get("short_codes", [])
numbers = get(session, "%s/Services/%s/PhoneNumbers" % (MESSAGING, sid),
PageSize=100).get("phone_numbers", [])
alpha = get(session, "%s/Services/%s/AlphaSenders" % (MESSAGING, sid),
PageSize=100).get("alpha_senders", [])
out[sid] = {"service": sid,
"name": svc.get("friendly_name"),
"short_codes": [str(c.get("short_code") or "") for c in codes],
"long_codes": len(numbers),
"alpha_senders": len(alpha)}
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--home-country", default="1",
help="calling code the short codes are licensed in; the "
"ShortCode resource does not carry one")
ap.add_argument("--days", type=int, default=7,
help="how far back to read the message list")
ap.add_argument("--max-messages", type=int, default=20000,
help="stop paging after this many messages")
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)
account_codes = list_short_codes(session, account)
if not account_codes:
log.info("no short codes on this account")
return 0
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
traffic = tally(list_messages(session, account, since, args.max_messages))
services = pools(session)
# Sends with no MessagingServiceSid used a From directly. Judge them too,
# with the account's short codes standing in for a pool.
if "" in traffic:
services.setdefault("", {"service": "", "name": "direct From sends",
"short_codes": account_codes, "long_codes": 1,
"alpha_senders": 0})
bad = 0
for sid, pool in sorted(services.items()):
row = dict(pool)
row.update({k: v for k, v in traffic.get(sid, {}).items() if k != "service"})
state, detail = verdict(row, args.home_country)
label = row.get("name") or sid or "direct"
line = "%-18s %-24s %s" % (state, str(label)[:24], detail)
if state in ("no-short-code", "domestic-only"):
log.info(line)
continue
bad += 1
log.warning(line)
if row.get("sids"):
log.warning(" message sids: %s", ", ".join(str(s) for s in row["sids"]))
log.warning(" repair: detach the short code from this pool (a delete on "
"%s/Services/%s/ShortCodes/{Sid}) and route traffic outside "
"+%s through a separate Messaging Service holding long codes "
"or a registered alphanumeric sender.",
MESSAGING, sid or "{ServiceSid}", args.home_country)
log.info("%d service(s), %d with a short code exposed to cross-border "
"traffic", len(services), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio short codes exposed to destinations they are not licensed for.
*
* 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 MESSAGING = 'https://messaging.twilio.com/v1';
/**
* 21612 is the To and From combination that cannot be delivered; 21606 is the
* From that cannot send to this destination. Both are what a short code returns
* for a handset outside its own country, and both are request-time rejections.
*/
const CROSS_BORDER = [21612, 21606];
const DIAL_CODES = new Set([
'1', '7', '20', '27', '30', '31', '32', '33', '34', '36', '39', '40', '43',
'44', '45', '46', '47', '48', '49', '51', '52', '54', '55', '56', '57', '58',
'60', '61', '62', '63', '64', '65', '66', '81', '82', '84', '86', '90', '91',
'92', '94', '212', '213', '234', '254', '351', '353', '358', '380', '420',
'421', '852', '880', '886', '966', '971', '972', '977', '998',
]);
/** Read error_code as a number, or null. */
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;
}
/**
* True for a short code, which in a message row is a short run of digits with
* no plus sign. Long codes are E.164 and alphanumeric senders are not digits.
*/
export function isShortCode(value) {
const raw = String(value ?? '').trim();
return /^\d{3,8}$/.test(raw);
}
/** Longest matching country calling code for an E.164 destination, or null. */
export function dialCode(to) {
const raw = String(to ?? '').trim();
if (!raw.startsWith('+')) return null;
const digits = raw.slice(1).replace(/\D/g, '');
for (const size of [3, 2, 1]) {
const head = digits.slice(0, size);
if (DIAL_CODES.has(head)) return head;
}
return null;
}
/**
* Bucket outbound messages by the Messaging Service that carried them. Pure, so
* the grouping can be tested without a network. Sends with no service are
* grouped under the empty key: a short code used directly as From is exposed
* the same way, minus the selection.
*/
export function tally(messages) {
const out = new Map();
for (const m of messages) {
if (String(m.direction ?? '').startsWith('inbound')) continue;
const sid = String(m.messaging_service_sid ?? '');
if (!out.has(sid)) {
out.set(sid, { service: sid, total: 0, blocked: 0, destinations: {}, sids: [] });
}
const row = out.get(sid);
row.total += 1;
const code = dialCode(m.to);
if (code) row.destinations[code] = (row.destinations[code] ?? 0) + 1;
if (CROSS_BORDER.includes(errorCode(m)) && isShortCode(m.from)) {
row.blocked += 1;
if (row.sids.length < 3) row.sids.push(m.sid);
}
}
return out;
}
/**
* Classify one Messaging Service's exposure to cross-border short code use.
* `home` is the calling code the short codes are licensed in, and it is an
* argument because the ShortCode resource does not carry a country.
* Pure. Returns [state, detail].
*/
export function verdict(service, home = '1') {
const short = service.short_codes ?? [];
const longs = Number(service.long_codes ?? 0);
const alpha = Number(service.alpha_senders ?? 0);
const blocked = Number(service.blocked ?? 0);
const destinations = service.destinations ?? {};
const foreign = Object.entries(destinations)
.filter(([code]) => code !== String(home))
.reduce((sum, [, n]) => sum + n, 0);
if (short.length === 0) {
return ['no-short-code',
'no short code in the pool, so nothing here can be selected for a country ' +
'it is not licensed in.'];
}
if (blocked) {
return ['blocked',
`${blocked} send(s) from a short code rejected with 21612 or 21606. The ` +
`short code ${short.slice(0, 2).join(', ')} is licensed for +${home} only, ` +
'and selection handed it a handset somewhere else.'];
}
if (foreign && !longs && !alpha) {
return ['unreachable-abroad',
`${foreign} message(s) went to destinations outside +${home} and the pool ` +
'has nothing but short codes. There is no sender here that can carry them, ' +
'so every one of those sends fails at request time.'];
}
if (foreign) {
return ['exposed',
`the pool mixes ${short.length} short code(s) with ${longs} long code(s), ` +
`and ${foreign} message(s) went outside +${home}. Selection is per ` +
'message, so the one that draws the short code is rejected while the rest ' +
'deliver.'];
}
return ['domestic-only',
`${short.length} short code(s) in the pool and all ${Number(service.total ?? 0)} ` +
`message(s) stayed inside +${home}. Correct today; the first international ` +
'recipient is what changes it.'];
}
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 pools(auth) {
const out = new Map();
const services = (await get(auth, `${MESSAGING}/Services`, { PageSize: 100 })).services ?? [];
for (const svc of services) {
const codes = (await get(auth, `${MESSAGING}/Services/${svc.sid}/ShortCodes`,
{ PageSize: 100 })).short_codes ?? [];
const numbers = (await get(auth, `${MESSAGING}/Services/${svc.sid}/PhoneNumbers`,
{ PageSize: 100 })).phone_numbers ?? [];
const alpha = (await get(auth, `${MESSAGING}/Services/${svc.sid}/AlphaSenders`,
{ PageSize: 100 })).alpha_senders ?? [];
out.set(svc.sid, {
service: svc.sid,
name: svc.friendly_name,
short_codes: codes.map((c) => String(c.short_code ?? '')),
long_codes: numbers.length,
alpha_senders: alpha.length,
});
}
return out;
}
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 homeIdx = process.argv.indexOf('--home-country');
const home = homeIdx === -1 ? '1' : String(process.argv[homeIdx + 1]);
const days = Number(process.argv[process.argv.indexOf('--days') + 1]) || 7;
const shortCodes = (await get(auth, `${BASE}/Accounts/${account}/SMS/ShortCodes.json`,
{ PageSize: 100 })).short_codes ?? [];
if (shortCodes.length === 0) {
console.log('no short codes on this account');
return;
}
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const traffic = tally(await listMessages(auth, account, since));
const services = await pools(auth);
// Sends with no MessagingServiceSid used a From directly. Judge them too,
// with the account's short codes standing in for a pool.
if (traffic.has('') && !services.has('')) {
services.set('', {
service: '', name: 'direct From sends',
short_codes: shortCodes.map((c) => String(c.short_code ?? '')),
long_codes: 1, alpha_senders: 0,
});
}
let bad = 0;
for (const sid of [...services.keys()].sort()) {
const row = { ...services.get(sid), ...(traffic.get(sid) ?? {}) };
row.service = sid;
const [state, detail] = verdict(row, home);
const label = String(row.name ?? sid ?? 'direct').slice(0, 24);
const line = `${state.padEnd(18)} ${label.padEnd(24)} ${detail}`;
if (state === 'no-short-code' || state === 'domestic-only') {
console.log(line);
continue;
}
bad += 1;
console.warn(line);
if (row.sids?.length) console.warn(` message sids: ${row.sids.join(', ')}`);
console.warn(' repair: detach the short code from this pool (a delete on ' +
`${MESSAGING}/Services/${sid || '{ServiceSid}'}/ShortCodes/{Sid}) ` +
`and route traffic outside +${home} through a separate Messaging ` +
'Service holding long codes or a registered alphanumeric sender.');
}
console.log(`${services.size} service(s), ${bad} with a short code exposed to ` +
'cross-border traffic');
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not start an audit and fail on the missing credentials.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The two cases that decide whether this report is worth reading are the mixed pool that has not failed yet and the pool that has. Both are findings; only one of them has message SIDs attached. The rest of the tests keep a short-code-only domestic service out of the list, and keep a 21606 from a long code from being counted as this problem at all.
from twilio_short_code_audit import dial_code, is_short_code, tally, verdict
def make(sender, to, code=None, service="MG1", direction="outbound-api", sid="SM1"):
return {"from": sender, "to": to, "error_code": code, "sid": sid,
"messaging_service_sid": service, "direction": direction}
def pool(**over):
p = {"short_codes": ["12345"], "long_codes": 2, "alpha_senders": 0,
"total": 0, "blocked": 0, "destinations": {}}
p.update(over)
return p
def test_short_code_already_rejected_abroad():
row = tally([make("12345", "+14165550123", 21612)])["MG1"]
state, detail = verdict(pool(**{k: v for k, v in row.items() if k != "service"}))
assert state == "blocked"
assert "21612" in detail
def test_mixed_pool_with_foreign_traffic_is_exposed_before_it_fails():
row = tally([make("+12025550123", "+447700900123"),
make("+12025550123", "+12025550124")])["MG1"]
state, detail = verdict(pool(**{k: v for k, v in row.items() if k != "service"}))
assert state == "exposed"
assert "per message" in detail
def test_pool_of_short_codes_only_cannot_reach_abroad_at_all():
row = tally([make("12345", "+447700900123")])["MG1"]
state, detail = verdict(pool(long_codes=0, alpha_senders=0,
**{k: v for k, v in row.items() if k != "service"}))
assert state == "unreachable-abroad"
assert "request time" in detail
def test_domestic_only_traffic_is_not_a_finding():
row = tally([make("12345", "+12025550123")])["MG1"]
state, _ = verdict(pool(**{k: v for k, v in row.items() if k != "service"}))
assert state == "domestic-only"
def test_service_with_no_short_code_is_skipped():
assert verdict(pool(short_codes=[], destinations={"44": 5}))[0] == "no-short-code"
def test_21606_from_a_long_code_is_not_counted_as_this_problem():
# Same error code, different fault: a voice-only long code fails everywhere.
row = tally([make("+12025550123", "+14165550123", 21606)])["MG1"]
assert row["blocked"] == 0
def test_home_country_is_an_argument_because_the_resource_has_no_country():
row = tally([make("12345", "+447700900123")])["MG1"]
stats = {k: v for k, v in row.items() if k != "service"}
assert verdict(pool(**stats), home="1")[0] == "exposed"
assert verdict(pool(**stats), home="44")[0] == "domestic-only"
def test_the_border_inside_plus_one_is_only_visible_in_the_rejections():
# A US short code cannot reach a Canadian handset, but both share calling
# code 1, so the destination count cannot see it and only the 21612 does.
quiet = tally([make("12345", "+14165550123")])["MG1"]
assert verdict(pool(**{k: v for k, v in quiet.items() if k != "service"}))[0] == "domestic-only"
loud = tally([make("12345", "+14165550123", 21612)])["MG1"]
assert verdict(pool(**{k: v for k, v in loud.items() if k != "service"}))[0] == "blocked"
def test_short_code_and_dial_code_helpers():
assert is_short_code("12345")
assert not is_short_code("+12025550123")
assert not is_short_code("MyBrand")
assert dial_code("+447700900123") == "44"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dialCode, isShortCode, tally, verdict } from './twilio-short-code-audit.mjs';
const make = (sender, to, code = null, service = 'MG1') => ({
from: sender, to, error_code: code, sid: 'SM1',
messaging_service_sid: service, direction: 'outbound-api',
});
const pool = (over = {}) => ({
short_codes: ['12345'], long_codes: 2, alpha_senders: 0,
total: 0, blocked: 0, destinations: {}, ...over,
});
const stats = (messages) => {
const { service, ...rest } = tally(messages).get('MG1');
return rest;
};
test('short code already rejected abroad', () => {
const [state, detail] = verdict(pool(stats([make('12345', '+14165550123', 21612)])));
assert.equal(state, 'blocked');
assert.match(detail, /21612/);
});
test('mixed pool with foreign traffic is exposed before it fails', () => {
const [state, detail] = verdict(pool(stats([
make('+12025550123', '+447700900123'), make('+12025550123', '+12025550124'),
])));
assert.equal(state, 'exposed');
assert.match(detail, /per message/);
});
test('pool of short codes only cannot reach abroad at all', () => {
const [state, detail] = verdict(pool({
long_codes: 0, alpha_senders: 0, ...stats([make('12345', '+447700900123')]),
}));
assert.equal(state, 'unreachable-abroad');
assert.match(detail, /request time/);
});
test('domestic only traffic is not a finding', () => {
const [state] = verdict(pool(stats([make('12345', '+12025550123')])));
assert.equal(state, 'domestic-only');
});
test('service with no short code is skipped', () => {
assert.equal(verdict(pool({ short_codes: [], destinations: { 44: 5 } }))[0],
'no-short-code');
});
test('21606 from a long code is not counted as this problem', () => {
assert.equal(stats([make('+12025550123', '+14165550123', 21606)]).blocked, 0);
});
test('home country is an argument because the resource has no country', () => {
const s = stats([make('12345', '+447700900123')]);
assert.equal(verdict(pool(s), '1')[0], 'exposed');
assert.equal(verdict(pool(s), '44')[0], 'domestic-only');
});
test('the border inside +1 is only visible in the rejections', () => {
// A US short code cannot reach a Canadian handset, but both share calling
// code 1, so the destination count cannot see it and only the 21612 does.
assert.equal(verdict(pool(stats([make('12345', '+14165550123')])))[0], 'domestic-only');
assert.equal(verdict(pool(stats([make('12345', '+14165550123', 21612)])))[0], 'blocked');
});
test('short code and dial code helpers', () => {
assert.equal(isShortCode('12345'), true);
assert.equal(isShortCode('+12025550123'), false);
assert.equal(isShortCode('MyBrand'), false);
assert.equal(dialCode('+447700900123'), '44');
});
FAQ
Why does the script ask which country the short code belongs to?
Because the API does not say. The ShortCode resource returns the digits, the SID, the handler URLs and the API version, and no country field, so the licensing country has to come from you. A default that looked authoritative would be worse than an argument you have to fill in, so it is a flag with a documented US default.
Is this the same as error 21606 from a long code?
No, and the report separates them by sender for exactly that reason. A long code returning 21606 on every destination is a number without SMS capability. A short code returning it only on foreign destinations is a national licence meeting an international recipient. Same code, different repair.
The pool has never had an international send. Is it still a finding?
It is reported, at a lower level than a pool that has already failed. Sender selection happens per message, so nothing is wrong until the first foreign recipient and then everything is. Knowing which pools carry that fuse is cheaper than finding out from a customer.
Does A2P 10DLC registration cover the short code?
It does not apply to it at all: short codes sit outside 10DLC and need no brand or campaign. That is why the assumption is so easy to make. Registration says nothing about geography, and the geographic rule is the one being broken here.
Why might the failure count be lower than the real one?
Two reasons, and both make the count a floor rather than a total. 21612 and 21606 are request-time rejections, so a send refused before a message row exists appears in the Alerts log instead of the message list. And the destination count groups by calling code, which cannot see the border inside +1: a US short code and a Canadian handset share it, so that pairing shows up only once it has already been rejected. Treat a mixed pool with no failures as unproven rather than clean.
Related field notes
- A voice-only From number failing every SMS
- A Messaging Service with no sender at all
- The sender ID unregistered where you send
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.
- ShortCode resource — Twilio Docs
- Error 21612: the To phone number is not currently reachable via SMS — Twilio Docs
- Messaging Service resource — 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.