Diagnostic Twilio
13224: Twilio refuses the number your Dial verb asked for
The call connects, your TwiML runs, the <Dial> produces silence, and then the call carries on to the action URL as though the leg had simply not been answered. Nobody rang. 13224 Dial: Twilio does not support calling this number or the number is invalid is sitting in the Debugger, and about half the time it is not in the error level at all.
Sweep GET https://monitor.twilio.com/v1/Alerts at both LogLevel=error and LogLevel=warning and keep error_code 13224. Several 132xx Dial attribute errors are logged as warnings, so an error-only sweep will tell you this is not happening while it happens.
Take each alert's resource_sid and read GET /2010-04-01/Accounts/{AccountSid}/Calls/{CallSid}.json. When direction is outbound-api, outbound-dial or trunking, the record's to is the destination that was refused and you can classify it directly. When direction is inbound, to is your own number and the dial target is not on that record at all — the request variables live only on the single-alert fetch GET /v1/Alerts/{AlertSid}, never in the list response.
The problem in plain words
13224 is a refusal, not a failure, and refusals are quiet. Twilio looked at the destination, decided it would not place the call, and returned control to your TwiML. The <Dial> ends with no DialCallStatus worth branching on, your action URL runs its "nobody answered" path, and the caller hears whatever you wrote for that case. Which is usually an apology, so it sounds like the far end was busy.
The parent call's status is completed. There is no failed call to count, no child leg to inspect, no duration anomaly. The only artefact anywhere is a Debugger alert that a dashboard filtered to the error level may never show you. So the failure gets attributed to the recipients — they are not picking up, their numbers are stale, the list is bad — and the list is indeed bad, but in a way that a script can name exactly.
Why it happens
The numbers come out of a column that predates E.164. A CRM that stored (0161) 496 0000 or 0161 496 0000 for fifteen years is not wrong; it is national format, which is what a human writes down. Fed into <Number> it is a destination Twilio cannot resolve to a country, and the refusal is immediate and total.
Normalising on the way in feels like it has been done. Most such systems have a normaliser somewhere. It runs on the signup path, or the import path, or the path that was in scope when the ticket was written, and the rows that came in through the other three paths sit there looking exactly like the ones that were cleaned.
Some destinations are refused on purpose and read as valid. A premium-rate, shared-cost or special-service range is well-formed E.164, passes every regex you own, and is a range Twilio will not terminate on. The number is not invalid. It is unsupported, which is the other half of the error text and the half people skip.
The error names no number. Read the alert list and you get a count and a call SID. The destination is one join away on the Calls resource, and only when the call is outbound — on an inbound forwarding leg it is one extra fetch away, on the single alert, because the list response omits the request variables entirely.
The fix, as a flow
The script tests the destination strictly rather than tidying it first, because a number that only becomes E.164 after the audit cleans it is a number the application should have cleaned and did not.
How to fix it
Sweep the Alerts API at both log levels
GET https://monitor.twilio.com/v1/Alerts?LogLevel=error&StartDate=YYYY-MM-DD&PageSize=1000, then the same request at LogLevel=warning, following meta.next_page_url — this API paginates with an absolute URL rather than the relative next_page_uri the 2010-04-01 API uses. Merge on sid. Alerts are retained 30 days, so a 90-day window quietly becomes a 30-day one.
Resolve each alert to the call it was raised against
The alert's resource_sid is a CA call SID. GET /2010-04-01/Accounts/{AccountSid}/Calls/{CallSid}.json gives you to and direction. Cache by SID: one bad batch produces many alerts against a handful of calls, and the fetch is the expensive part of this check.
Decide whether the record can even carry the answer
direction is the gate. Outbound and trunking calls carry the refused destination in to. An inbound call does not: to is the number the caller dialled, which is yours and is fine, and reporting it as the bad destination is the mistake this check exists to avoid. For those, fetch GET https://monitor.twilio.com/v1/Alerts/{AlertSid} — alert_text, request_variables and the rest are populated only on the single-alert fetch.
Classify the destination string strictly, without cleaning it first
Test for a plus followed by digits and nothing else. Do not strip brackets, spaces or dashes before the test: the punctuation is the finding. A destination that only becomes E.164 after your script tidies it is a destination your application should have tidied and did not.
Normalise at the source, then validate what survives
Convert to E.164 where the row is stored, not in the dial path, so every caller of that column benefits. Then GET https://lookups.twilio.com/v2/PhoneNumbers/{E164} and keep only the numbers whose valid is true. Exclude premium and special-service ranges from the dial list outright; they will be refused every time and each attempt is a failed leg your campaign counts as a no-answer.
How to check it worked
Re-run the sweep over a window that begins after the deploy. The 13224 count should be zero.
python3 twilio_dial_target_audit.py --days 7
# 0 alert(s) with error_code 13224 in the last 7 day(s)
The full code
Two paginated alert sweeps, one cached call fetch per failing call, and one optional single-alert fetch for the inbound cases the call record cannot answer. Every request is a GET and an API Key with read access is enough. Three pure functions hold the diagnosis: one tests a destination for strict E.164, one matches it against the international ranges that are refused by allocation, and one turns a call into a verdict. The strictness of the first is the whole point — a parser that helpfully normalises the punctuation away reports a clean list of the numbers that just failed.
"""Report Twilio 13224 alerts and say why each Dial destination was refused.
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 place calls 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_dial_target_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MONITOR = "https://monitor.twilio.com/v1"
UNSUPPORTED = 13224
# Ranges that are premium, shared cost or special service by international
# allocation rather than by national convention. The table is deliberately
# short. Every country also has its own premium ranges, and a table of all of
# them is a maintenance project you will lose; Lookups settles the rest.
REFUSED_PREFIXES = (
("+979", "ITU international premium rate service"),
("+808", "ITU international shared cost service"),
("+882", "ITU international networks"),
("+883", "ITU international networks"),
("+881", "global mobile satellite system"),
("+870", "Inmarsat single network access code"),
("+4470", "UK personal numbering, forwarded at premium cost"),
("+449", "UK premium rate"),
("+1900", "North American premium rate"),
)
# Directions whose call record carries the destination that was dialled. An
# inbound call does not: its `to` is your own number.
OUTBOUND = ("outbound-api", "outbound-dial", "trunking")
def e164_digits(to):
"""The digits of a strictly E.164 destination, or an empty string.
Strict deliberately. A plus, then one to fifteen digits, and nothing else:
no spaces, no brackets, no dashes, no leading zero after the plus.
Normalising the punctuation away here would destroy the evidence, because a
column of national-format numbers going straight into the Dial noun is the
single most common cause of this error.
"""
v = str(to or "").strip()
if not v.startswith("+"):
return ""
digits = v[1:]
if not digits.isdigit() or not 1 <= len(digits) <= 15:
return ""
return digits
def refused_range(to):
"""The allocation a destination falls in, or an empty string.
Longest prefix wins, so +4470 is reported as personal numbering rather than
as UK premium rate.
"""
v = str(to or "").strip()
best, label = "", ""
for prefix, name in REFUSED_PREFIXES:
if v.startswith(prefix) and len(prefix) > len(best):
best, label = prefix, name
return label
def verdict(call):
"""Explain one 13224 from the call it was raised against.
Pure, so the rules can be tested without a network. `call` is the Call
resource the alert's resource_sid resolved to. Returns (state, detail).
"""
to = str(call.get("to") or "").strip()
direction = str(call.get("direction") or "").strip().lower()
if not to:
return ("no-destination",
"the call record has no `to`, so there is nothing to classify. "
"Read the single alert for the request variables.")
if direction and direction not in OUTBOUND:
return ("target-not-on-record",
"direction is %s, so `to` (%s) is the number the caller dialled "
"and not the destination that was refused. The dial target is "
"in the request variables, which are populated only on GET "
"/v1/Alerts/{AlertSid}." % (direction, to))
low = to.lower()
if low.startswith("sip:") or low.startswith("sips:") or low.startswith("client:"):
return ("non-pstn",
"%s is not a PSTN destination, so this refusal is about a "
"different Dial noun and E.164 has nothing to do with it." % to)
if not to.startswith("+"):
return ("not-e164",
"%s has no leading plus, so Twilio cannot tell which country it "
"belongs to. This is national format arriving straight from a "
"column that predates E.164." % to)
digits = e164_digits(to)
if not digits:
return ("malformed",
"%s starts with a plus but is not digits after it, or runs past "
"the fifteen digit E.164 ceiling. The punctuation is the "
"finding: the value was never normalised." % to)
if len(digits) < 8:
return ("too-short",
"%s carries only %d digits, which is shorter than a full "
"international destination. This is usually an internal "
"extension dialled as though it were a phone number."
% (to, len(digits)))
allocation = refused_range(to)
if allocation:
return ("refused-range",
"%s is in the %s range. It is well formed and it is unsupported, "
"which is the other half of the error text: Twilio will not "
"terminate on it, today or ever." % (to, allocation))
return ("unallocated",
"%s is shaped correctly and is outside the ranges this table knows, "
"so the number itself does not exist: an unassigned area code, a "
"country code that was never allocated, or a digit lost in "
"transcription. Lookups v2 will report valid false." % to)
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_alerts(session, since, limit, log_level):
"""Page the Monitor alerts at one log level. next_page_url is absolute."""
url = MONITOR + "/Alerts"
params = {"LogLevel": log_level, "StartDate": since, "PageSize": 1000}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("alerts", []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def sweep_alerts(session, since, limit, levels):
"""Both log levels, merged on sid.
Several of the 132xx Dial attribute errors are logged at warning rather than
error. A sweep that reads only the error level reports a clean account while
the legs keep failing, which is why this takes a list of levels at all.
"""
seen = {}
for level in levels:
for a in list_alerts(session, since, limit, level):
seen.setdefault(a.get("sid"), a)
return list(seen.values())
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=7,
help="how far back to sweep (alerts are retained 30 days)")
ap.add_argument("--max-alerts", type=int, default=10000,
help="stop after this many alerts per log level")
ap.add_argument("--errors-only", action="store_true",
help="skip the warning level, which will under-report")
ap.add_argument("--alert-detail", action="store_true",
help="one extra GET per inbound case for the request variables")
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)
days = min(args.days, 30)
since = (dt.date.today() - dt.timedelta(days=days)).isoformat()
levels = ["error"] if args.errors_only else ["error", "warning"]
alerts = sweep_alerts(session, since, args.max_alerts, levels)
hits = [a for a in alerts
if str(a.get("error_code") or "").strip() == str(UNSUPPORTED)]
if not hits:
log.info("0 alert(s) with error_code %d in the last %d day(s)",
UNSUPPORTED, days)
return 0
calls = {}
counts = {}
for a in hits:
sid = str(a.get("resource_sid") or "")
if not sid.startswith("CA"):
log.warning("13224 alert %s has no call sid to resolve", a.get("sid"))
continue
if sid not in calls:
calls[sid] = get(session, "%s/Accounts/%s/Calls/%s.json"
% (BASE, account, sid))
state, detail = verdict(calls[sid])
counts[state] = counts.get(state, 0) + 1
log.warning("%-21s %s %s", state, sid, detail)
if state == "target-not-on-record" and args.alert_detail:
one = get(session, "%s/Alerts/%s" % (MONITOR, a.get("sid")))
log.warning(" alert_text: %s", one.get("alert_text"))
log.warning("%d alert(s) with error_code %d across %d call(s): %s",
len(hits), UNSUPPORTED, len(calls),
", ".join("%s=%d" % kv for kv in sorted(counts.items())))
log.warning(" repair: normalise the destination column to E.164 where it "
"is stored, then validate with GET "
"https://lookups.twilio.com/v2/PhoneNumbers/{E164} and keep "
"only valid == true")
log.warning(" repair: exclude premium and special service ranges from the "
"dial list; they are refused every time")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio 13224 alerts and say why each Dial destination was refused.
*
* 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 MONITOR = 'https://monitor.twilio.com/v1';
const UNSUPPORTED = 13224;
// Premium, shared cost and special service ranges by international allocation.
// Deliberately short: national premium ranges are a table you will lose track
// of, and Lookups settles the rest.
const REFUSED_PREFIXES = [
['+979', 'ITU international premium rate service'],
['+808', 'ITU international shared cost service'],
['+882', 'ITU international networks'],
['+883', 'ITU international networks'],
['+881', 'global mobile satellite system'],
['+870', 'Inmarsat single network access code'],
['+4470', 'UK personal numbering, forwarded at premium cost'],
['+449', 'UK premium rate'],
['+1900', 'North American premium rate'],
];
const OUTBOUND = ['outbound-api', 'outbound-dial', 'trunking'];
/**
* The digits of a strictly E.164 destination, or an empty string. Strict on
* purpose: cleaning the punctuation here would destroy the evidence.
*/
export function e164Digits(to) {
const v = String(to ?? '').trim();
if (!v.startsWith('+')) return '';
const digits = v.slice(1);
if (!/^[0-9]+$/.test(digits) || digits.length > 15) return '';
return digits;
}
/** The allocation a destination falls in, or an empty string. Longest wins. */
export function refusedRange(to) {
const v = String(to ?? '').trim();
let best = '';
let label = '';
for (const [prefix, name] of REFUSED_PREFIXES) {
if (v.startsWith(prefix) && prefix.length > best.length) {
best = prefix;
label = name;
}
}
return label;
}
/**
* Explain one 13224 from the call it was raised against. Pure. Returns
* [state, detail].
*/
export function verdict(call) {
const to = String(call.to ?? '').trim();
const direction = String(call.direction ?? '').trim().toLowerCase();
if (!to) {
return ['no-destination',
'the call record has no `to`, so there is nothing to classify. Read the ' +
'single alert for the request variables.'];
}
if (direction && !OUTBOUND.includes(direction)) {
return ['target-not-on-record',
`direction is ${direction}, so \`to\` (${to}) is the number the caller ` +
'dialled and not the destination that was refused. The dial target is in ' +
'the request variables, which are populated only on GET ' +
'/v1/Alerts/{AlertSid}.'];
}
const low = to.toLowerCase();
if (low.startsWith('sip:') || low.startsWith('sips:') || low.startsWith('client:')) {
return ['non-pstn',
`${to} is not a PSTN destination, so this refusal is about a different ` +
'Dial noun and E.164 has nothing to do with it.'];
}
if (!to.startsWith('+')) {
return ['not-e164',
`${to} has no leading plus, so Twilio cannot tell which country it ` +
'belongs to. This is national format arriving straight from a column ' +
'that predates E.164.'];
}
const digits = e164Digits(to);
if (!digits) {
return ['malformed',
`${to} starts with a plus but is not digits after it, or runs past the ` +
'fifteen digit E.164 ceiling. The punctuation is the finding: the value ' +
'was never normalised.'];
}
if (digits.length < 8) {
return ['too-short',
`${to} carries only ${digits.length} digits, which is shorter than a ` +
'full international destination. This is usually an internal extension ' +
'dialled as though it were a phone number.'];
}
const allocation = refusedRange(to);
if (allocation) {
return ['refused-range',
`${to} is in the ${allocation} range. It is well formed and it is ` +
'unsupported, which is the other half of the error text: Twilio will not ' +
'terminate on it, today or ever.'];
}
return ['unallocated',
`${to} is shaped correctly and is outside the ranges this table knows, so ` +
'the number itself does not exist: an unassigned area code, a country code ' +
'that was never allocated, or a digit lost in transcription. Lookups v2 ' +
'will report valid false.'];
}
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();
}
async function listAlerts(auth, since, limit, logLevel) {
let url = `${MONITOR}/Alerts`;
let params = { LogLevel: logLevel, StartDate: since, PageSize: 1000 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.alerts ?? []));
url = page.meta?.next_page_url ?? null;
params = {};
}
return out.slice(0, limit);
}
/** Both log levels, merged on sid. Some 132xx errors are logged as warnings. */
export async function sweepAlerts(auth, since, limit, levels) {
const seen = new Map();
for (const level of levels) {
for (const a of await listAlerts(auth, since, limit, level)) {
if (!seen.has(a.sid)) seen.set(a.sid, a);
}
}
return [...seen.values()];
}
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 arg = (name, fallback) => {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : Number(process.argv[i + 1]);
};
const days = Math.min(arg('--days', 7), 30);
const detail = process.argv.includes('--alert-detail');
const levels = process.argv.includes('--errors-only') ? ['error'] : ['error', 'warning'];
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const alerts = await sweepAlerts(auth, since, 10000, levels);
const hits = alerts.filter((a) => String(a.error_code ?? '').trim() === String(UNSUPPORTED));
if (hits.length === 0) {
console.log(`0 alert(s) with error_code ${UNSUPPORTED} in the last ${days} day(s)`);
return;
}
const calls = new Map();
const counts = new Map();
for (const a of hits) {
const sid = String(a.resource_sid ?? '');
if (!sid.startsWith('CA')) {
console.warn(`13224 alert ${a.sid} has no call sid to resolve`);
continue;
}
if (!calls.has(sid)) {
calls.set(sid, await get(auth, `${BASE}/Accounts/${account}/Calls/${sid}.json`));
}
const [state, why] = verdict(calls.get(sid));
counts.set(state, (counts.get(state) ?? 0) + 1);
console.warn(`${state.padEnd(21)} ${sid} ${why}`);
if (state === 'target-not-on-record' && detail) {
const one = await get(auth, `${MONITOR}/Alerts/${a.sid}`);
console.warn(` alert_text: ${one.alert_text}`);
}
}
const summary = [...counts.entries()].sort().map(([k, v]) => `${k}=${v}`).join(', ');
console.warn(`${hits.length} alert(s) with error_code ${UNSUPPORTED} across ` +
`${calls.size} call(s): ${summary}`);
console.warn(' repair: normalise the destination column to E.164 where it is ' +
'stored, then validate with GET ' +
'https://lookups.twilio.com/v2/PhoneNumbers/{E164} and keep only ' +
'valid == true');
console.warn(' repair: exclude premium and special service ranges from the ' +
'dial list; they are refused every time');
process.exitCode = 1;
}
// 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
Two cases carry this note. The first is the inbound call whose to is a perfectly valid number that has nothing to do with the failure — a checker that classifies it will report your own inbound line as an invalid destination and send somebody to look at the wrong thing. The second is the punctuated number: +44 161 496 0000 must come back as malformed rather than being tidied into a pass, because tidying it is exactly what the application failed to do.
from twilio_dial_target_audit import e164_digits, refused_range, verdict
def test_national_format_is_the_common_cause():
state, detail = verdict({"to": "01614960000", "direction": "outbound-api"})
assert state == "not-e164"
assert "predates E.164" in detail
def test_punctuated_number_is_malformed_rather_than_tidied():
# Cleaning it here would hide the thing the application should have done.
state, _ = verdict({"to": "+44 161 496 0000", "direction": "outbound-api"})
assert state == "malformed"
def test_inbound_call_does_not_carry_the_dial_target():
state, detail = verdict({"to": "+441614960000", "direction": "inbound"})
assert state == "target-not-on-record"
assert "AlertSid" in detail
def test_premium_range_is_unsupported_not_invalid():
state, detail = verdict({"to": "+19005551234", "direction": "outbound-api"})
assert state == "refused-range"
assert "North American premium rate" in detail
def test_longest_prefix_wins_over_the_shorter_one():
assert refused_range("+447012345678") == \
"UK personal numbering, forwarded at premium cost"
assert refused_range("+449001234567") == "UK premium rate"
def test_extension_dialled_as_a_number_is_too_short():
state, _ = verdict({"to": "+4021", "direction": "outbound-dial"})
assert state == "too-short"
def test_well_formed_unknown_number_points_at_lookups():
state, detail = verdict({"to": "+15005550001", "direction": "outbound-api"})
assert state == "unallocated"
assert "valid false" in detail
def test_e164_digits_is_strict_about_the_ceiling_and_the_plus():
assert e164_digits("+441614960000") == "441614960000"
assert e164_digits("441614960000") == ""
assert e164_digits("+1234567890123456") == ""
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { e164Digits, refusedRange, verdict } from './twilio-dial-target-audit.mjs';
test('national format is the common cause', () => {
const [state, detail] = verdict({ to: '01614960000', direction: 'outbound-api' });
assert.equal(state, 'not-e164');
assert.match(detail, /predates E\.164/);
});
test('punctuated number is malformed rather than tidied', () => {
assert.equal(verdict({ to: '+44 161 496 0000', direction: 'outbound-api' })[0],
'malformed');
});
test('inbound call does not carry the dial target', () => {
const [state, detail] = verdict({ to: '+441614960000', direction: 'inbound' });
assert.equal(state, 'target-not-on-record');
assert.match(detail, /AlertSid/);
});
test('premium range is unsupported not invalid', () => {
const [state, detail] = verdict({ to: '+19005551234', direction: 'outbound-api' });
assert.equal(state, 'refused-range');
assert.match(detail, /North American premium rate/);
});
test('longest prefix wins over the shorter one', () => {
assert.equal(refusedRange('+447012345678'),
'UK personal numbering, forwarded at premium cost');
assert.equal(refusedRange('+449001234567'), 'UK premium rate');
});
test('extension dialled as a number is too short', () => {
assert.equal(verdict({ to: '+4021', direction: 'outbound-dial' })[0], 'too-short');
});
test('well formed unknown number points at lookups', () => {
const [state, detail] = verdict({ to: '+15005550001', direction: 'outbound-api' });
assert.equal(state, 'unallocated');
assert.match(detail, /valid false/);
});
test('e164Digits is strict about the ceiling and the plus', () => {
assert.equal(e164Digits('+441614960000'), '441614960000');
assert.equal(e164Digits('441614960000'), '');
assert.equal(e164Digits('+1234567890123456'), '');
});
FAQ
Why sweep the warning level as well as the error level?
Because several of the 132xx Dial attribute errors are logged at LogLevel=warning rather than error. A dashboard or a script filtered to errors alone will show a clean account while every leg in a campaign is being refused. Sweeping both levels and merging on the alert sid costs one extra paginated read.
The call shows as completed. How can the leg have failed?
The parent call did complete. Twilio refused the destination, the <Dial> ended without connecting anything, and control returned to your TwiML, which carried on to the action URL. Nothing about the parent call is abnormal, which is why counting call status never finds this.
What is the difference between unsupported and invalid?
Invalid means the number does not exist: an unassigned range, a country code that was never allocated, a lost digit. Unsupported means it exists and Twilio will not terminate on it, which covers premium rate, shared cost and special service allocations. Both raise 13224 and only the first is fixable by cleaning your data.
Why not normalise the number in the script before classifying it?
Because the punctuation is the finding. A destination that only becomes E.164 after the audit tidies it is a destination the application should have tidied and did not, and a report that quietly cleans its input will tell you the list is fine while the calls keep failing.
Can the script fix the numbers it finds?
It will not. It has a read-only key and the repair is not on Twilio's side anyway: the destination column in your own database is where the normalisation belongs, so every caller of that column gets it rather than the dial path alone.
Related field notes
- 13214: the caller ID passed through from the inbound leg
- A rising share of outbound calls end in failed
- 32009: the SIP endpoint is not registered
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 13224: Dial: Twilio does not support calling this number — Twilio Docs
- TwiML Voice: <Dial> — Twilio Docs
- Lookup v2 API — Twilio Docs
- Alert resource (Monitor) — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.