Skip to content

Diagnostic Twilio

an invalid or uncovered number: 21211 on send, 60600 on Lookup

The contact table has been filling up since before anyone thought about E.164. Some rows are (555) 010-9999, some are 07700 900123, some are fine. Twilio does not guess: a number that is not strictly E.164 comes back 21211 at send time, one send at a time, forever, and each of those failures is a customer who was supposed to hear from you.

Read-only key Python and Node.js Tests included
A bunch of wires that are connected to a server
Photo by Lightsaber Collection on Unsplash
The short answer

GET https://lookups.twilio.com/v2/PhoneNumbers/{E164} returns valid as a boolean and validation_errors[] naming why: TOO_SHORT, TOO_LONG, INVALID_BUT_POSSIBLE, INVALID_COUNTRY_CODE, INVALID_LENGTH, NOT_A_NUMBER. It also returns the normalised phone_number, country_code and calling_country_code.

Two other outcomes are not valid == false. A number Lookup has no record of returns HTTP 404. A number that is well formed but sits in a range with no carrier behind it produces 60600, unprovisioned or out of coverage. And a number that is valid can still be stored in a form your code will send verbatim, which is why the normalised phone_number is worth comparing against what you have.

The problem in plain words

Bad numbers do not fail in a batch you can look at. They fail one at a time, months apart, inside whatever job happened to be sending, and each failure is a single 21211 against a single row. Nothing aggregates them, because a request that is rejected at validation frequently never creates a Message record at all — there is no row in Messages.json with a status of failed, just an API call that returned 400 and an exception your worker swallowed or retried.

So the shape of the problem is invisible. You cannot answer "how many of our contacts are unsendable" from the Twilio console, because Twilio has never been asked about the ones you have not tried yet. The only way to know is to ask about all of them at once, before the sending code does it for you one row at a time.

Number typed in2016no E.164 anywhereStored verbatimbrackets, spaces,no plusSend attemptedone row, one job21211 atrequest timeoften no MessagerowRetried andswallowednothing aggregatesit
There is no batch to look at. Each row fails alone, months apart, inside whatever job happened to be sending that day.

Why it happens

Twilio requires strict E.164 and does no fuzzy parsing. There is no country hint, no default region, no stripping of punctuation on your behalf. +1 and fifteen digits maximum, or 21211. That strictness is correct — guessing a country for an ambiguous string is how a message goes to the wrong continent — but it means every legacy format in your database is a failure waiting for its turn.

Valid and reachable are different questions. valid is a statement about the shape of the number against the numbering plan of its country. 60600 is a statement about whether any carrier has that number. A well-formed number in an unallocated range passes the first test and fails the second, and only the second one costs you a delivery.

A number can be valid and still stored wrong. +1 555 010 9999 with spaces, or a leading 00 instead of +, may well resolve. What you send is what you have in the row, so the normalised phone_number Lookup returns is the value that belongs in the database, not the one you happen to have.

The obvious failures do not need an API call. A string with letters in it, or no +, or twenty digits, is unsendable before Twilio is involved. Checking the shape locally first means the paid Lookup is spent on the numbers where the answer is genuinely unknown.

The fix, as a flow

The script screens the shape locally before spending a Lookup, because a string with letters in it is unsendable before Twilio is involved and the paid request belongs on the numbers where the answer is unknown.

Local shape check, then Lookupvalid, validation_errors,phone_numberValid and normalisedleave the row aloneValid, stored differentlywrite back Twilio's formvalid is falsecorrect or quarantine404 or 60600unreachable, not misformatted
Valid and reachable are different questions, and a number can be both and still be stored in a form your code will send verbatim.

How to fix it

Decide what the input set is, and make it the whole set

The useful run is over your stored contacts, not over what you have already sent. Feed the script a file of one number per line. Where that is not to hand, the fallback is the distinct to values from GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>={date}, which at least covers the numbers in active use.

Screen the shape locally before spending a Lookup

No leading +, characters that are not digits, fewer than eight digits or more than fifteen: all decided without a request. These are the rows that were exported from a system that never stored E.164, and they are usually the bulk of the finding.

Look up the rest and read validation_errors, not just valid

valid == false tells you it will fail; validation_errors[] tells you what to do about it. TOO_SHORT on a set of numbers sharing a country code is a truncation bug in an import. INVALID_COUNTRY_CODE across a batch is usually a national number with a + bolted on the front.

Separate malformed from unallocated

HTTP 404 means Lookup has no record of the number. 60600 means it is a plausible number that is unprovisioned or out of coverage. Neither is a formatting mistake, so neither is fixed by re-parsing the string — those rows are dead and should be quarantined rather than corrected.

Write back the normalised form and quarantine the rest

For every row where Lookup returned valid with a phone_number different from what you hold, store Twilio's version. For the invalid ones, mark the row rather than deleting it: a human needs to see the original string to know whether it is recoverable. Then validate at the input layer, so the set stops growing.

How to check it worked

Re-run the script over the same input. Every number should report ok, and the invalid count should be zero.

python3 twilio_lookup_validity_audit.py --file contacts.txt
# 1284 number(s), 0 unsendable

The full code

One Lookup GET per number that survives the local shape check, plus one paginated GET over recent messages when no file is given. The classifier is pure and takes the HTTP status alongside the body, because three of the six outcomes here are not distinguishable from the JSON alone: a 404, a 60600 and a valid false are three different rows in the report and three different repairs.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_lookup_validity_audit.py
"""Report stored phone numbers that Twilio cannot send to.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. Nothing is written back to your database and
nothing is changed on the account; the corrections are printed for you to apply.
"""
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_lookup_validity_audit")

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
LOOKUPS = "https://lookups.twilio.com/v2/PhoneNumbers"

VALIDATION = {
    "TOO_SHORT": "too few digits for the country code it starts with",
    "TOO_LONG": "too many digits for the country code it starts with",
    "INVALID_BUT_POSSIBLE": "the right length, but not a range that country has allocated",
    "INVALID_COUNTRY_CODE": "the leading digits are not a country calling code",
    "INVALID_LENGTH": "the wrong length for any range in that country",
    "NOT_A_NUMBER": "not parseable as a phone number at all",
}


def shape(raw):
    """Judge a stored string against E.164 without spending a Lookup.

    Pure. Returns a reason when the number cannot possibly be sent to, or None
    when the answer needs Twilio. These are the rows exported from a system that
    never stored E.164, and they are usually most of the finding.
    """
    s = str(raw or "").strip()
    if not s:
        return "empty"
    if not s.startswith("+"):
        return ("no leading +, so this is national format or a + stripped by an "
                "export; Twilio does no fuzzy parsing and will return 21211")
    digits = s[1:]
    if not digits.isdigit():
        return ("non-digit characters after the +: spaces, dashes or brackets "
                "survived the import")
    if len(digits) < 8:
        return "%d digits: shorter than any E.164 number" % len(digits)
    if len(digits) > 15:
        return "%d digits: E.164 allows at most 15" % len(digits)
    return None


def explain(errors):
    """Turn validation_errors[] into something a person can act on. Pure."""
    named = [VALIDATION.get(e, str(e)) for e in (errors or [])]
    return "; ".join(named) if named else "no reason given"


def classify(raw, status, body):
    """Classify one number from the Lookup response. Pure, so every outcome can
    be tested without a network.

    `status` is the HTTP status and `body` the parsed JSON, because three of the
    outcomes are not distinguishable from the JSON alone. Returns (state, detail).
    """
    local = shape(raw)
    if local:
        return ("not-e164", local)

    body = body or {}
    if status == 404:
        return ("not-found",
                "Lookup has no record of this number: it is not a formatting "
                "mistake, so re-parsing the string will not recover it")
    if status >= 400:
        code = body.get("code")
        if code == 60600:
            return ("uncovered",
                    "60600 unprovisioned or out of coverage: a plausible number "
                    "that no carrier has behind it")
        return ("lookup-error",
                "HTTP %s from Lookup, code %s: retry before treating the row as "
                "bad" % (status, code))

    if body.get("valid") is False:
        return ("invalid",
                "valid is false: %s" % explain(body.get("validation_errors")))

    normalised = str(body.get("phone_number") or "").strip()
    if normalised and normalised != str(raw).strip():
        return ("renormalise",
                "valid, but stored as %s where Twilio normalises it to %s; you "
                "send what is in the row" % (str(raw).strip(), normalised))

    return ("ok", "valid and stored in the form Twilio returns")


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, e164):
    """One Lookup. Returns (status, body); 4xx bodies carry the error code."""
    r = session.get("%s/%s" % (LOOKUPS, e164), timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Twilio: the API key needs read access to Lookup"
                         % r.status_code)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, {}


def recent_destinations(session, account, since, limit):
    """Distinct `to` values from recent messages, for when no file is given."""
    url = "%s/Accounts/%s/Messages.json" % (BASE, account)
    params = {"PageSize": 100, "DateSent>": since}
    seen, out = set(), []
    while url and len(out) < limit:
        page = get(session, url, **params)
        for m in page.get("messages", []):
            to = str(m.get("to") or "").strip()
            if to and to not in seen:
                seen.add(to)
                out.append(to)
        nxt = page.get("next_page_uri")
        url, params = (HOST + nxt) if nxt else None, {}
    return out[:limit]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--file", help="one phone number per line")
    ap.add_argument("--days", type=int, default=30,
                    help="window for the message fallback when no file is given")
    ap.add_argument("--max-numbers", type=int, default=500)
    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)

    if args.file:
        with open(args.file, encoding="utf-8") as fh:
            numbers = [ln.strip() for ln in fh if ln.strip()][:args.max_numbers]
    else:
        since = (dt.datetime.now(dt.timezone.utc)
                 - dt.timedelta(days=args.days)).strftime("%Y-%m-%d")
        log.info("no --file given: falling back to distinct destinations from the "
                 "last %d days of messages", args.days)
        numbers = recent_destinations(session, account, since, args.max_numbers)

    if not numbers:
        log.info("no numbers to check")
        return 0

    bad = 0
    for raw in numbers:
        if shape(raw):
            state, detail = classify(raw, 0, None)
        else:
            status, body = lookup(session, raw)
            state, detail = classify(raw, status, body)
        line = "%-13s %s  %s" % (state, raw, detail)
        if state == "ok":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state == "renormalise":
            log.warning("  repair: store Twilio's normalised phone_number on this row")
        elif state in ("not-found", "uncovered"):
            log.warning("  repair: quarantine this row; it is unreachable, not misformatted")
        elif state != "lookup-error":
            log.warning("  repair: correct the stored string to E.164, then "
                        "validate with Lookup at the input layer")

    log.info("%d number(s), %d unsendable", len(numbers), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-lookup-validity-audit.mjs
/**
 * Report stored phone numbers that Twilio cannot send to.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. Nothing is written back to your
 * database and nothing is changed on the account.
 */
import { readFileSync } from 'node:fs';

const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const LOOKUPS = 'https://lookups.twilio.com/v2/PhoneNumbers';

const VALIDATION = {
  TOO_SHORT: 'too few digits for the country code it starts with',
  TOO_LONG: 'too many digits for the country code it starts with',
  INVALID_BUT_POSSIBLE: 'the right length, but not a range that country has allocated',
  INVALID_COUNTRY_CODE: 'the leading digits are not a country calling code',
  INVALID_LENGTH: 'the wrong length for any range in that country',
  NOT_A_NUMBER: 'not parseable as a phone number at all',
};

/**
 * Judge a stored string against E.164 without spending a Lookup. Pure. Returns
 * a reason, or null when the answer needs Twilio.
 */
export function shape(raw) {
  const s = String(raw ?? '').trim();
  if (!s) return 'empty';
  if (!s.startsWith('+')) {
    return 'no leading +, so this is national format or a + stripped by an ' +
           'export; Twilio does no fuzzy parsing and will return 21211';
  }
  const digits = s.slice(1);
  if (!/^[0-9]+$/.test(digits)) {
    return 'non-digit characters after the +: spaces, dashes or brackets survived the import';
  }
  if (digits.length < 8) return `${digits.length} digits: shorter than any E.164 number`;
  if (digits.length > 15) return `${digits.length} digits: E.164 allows at most 15`;
  return null;
}

/** Turn validation_errors[] into something a person can act on. Pure. */
export function explain(errors) {
  const named = (errors ?? []).map((e) => VALIDATION[e] ?? String(e));
  return named.length ? named.join('; ') : 'no reason given';
}

/**
 * Classify one number from the Lookup response. Pure, so every outcome can be
 * tested without a network. Returns [state, detail].
 */
export function classify(raw, status, body) {
  const local = shape(raw);
  if (local) return ['not-e164', local];

  const b = body ?? {};
  if (status === 404) {
    return ['not-found',
      'Lookup has no record of this number: it is not a formatting mistake, ' +
      'so re-parsing the string will not recover it'];
  }
  if (status >= 400) {
    if (b.code === 60600) {
      return ['uncovered',
        '60600 unprovisioned or out of coverage: a plausible number that no ' +
        'carrier has behind it'];
    }
    return ['lookup-error',
      `HTTP ${status} from Lookup, code ${b.code}: retry before treating the row as bad`];
  }

  if (b.valid === false) {
    return ['invalid', `valid is false: ${explain(b.validation_errors)}`];
  }

  const normalised = String(b.phone_number ?? '').trim();
  if (normalised && normalised !== String(raw).trim()) {
    return ['renormalise',
      `valid, but stored as ${String(raw).trim()} where Twilio normalises it ` +
      `to ${normalised}; you send what is in the row`];
  }

  return ['ok', 'valid and stored in the form Twilio returns'];
}

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, e164) {
  const res = await fetch(`${LOOKUPS}/${encodeURIComponent(e164)}`,
                          { headers: { Authorization: auth } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Twilio: the API key needs read access to Lookup`);
  }
  try {
    return [res.status, await res.json()];
  } catch {
    return [res.status, {}];
  }
}

export async function recentDestinations(auth, account, since, limit) {
  let url = `${BASE}/Accounts/${account}/Messages.json`;
  let params = { PageSize: 100, 'DateSent>': since };
  const seen = new Set();
  while (url && seen.size < limit) {
    const page = await get(auth, url, params);
    for (const m of page.messages ?? []) {
      const to = String(m.to ?? '').trim();
      if (to) seen.add(to);
    }
    url = page.next_page_uri ? HOST + page.next_page_uri : null;
    params = {};
  }
  return [...seen].slice(0, limit);
}

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 fileArg = process.argv.indexOf('--file');
  const days = Number(process.env.DAYS ?? 30);
  const max = 500;

  let numbers;
  if (fileArg !== -1 && process.argv[fileArg + 1]) {
    numbers = readFileSync(process.argv[fileArg + 1], 'utf8')
      .split('\n').map((l) => l.trim()).filter(Boolean).slice(0, max);
  } else {
    const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
    console.log(`no --file given: falling back to distinct destinations from the ` +
                `last ${days} days of messages`);
    numbers = await recentDestinations(auth, account, since, max);
  }

  if (numbers.length === 0) {
    console.log('no numbers to check');
    return;
  }

  let bad = 0;
  for (const raw of numbers) {
    let state; let detail;
    if (shape(raw)) {
      [state, detail] = classify(raw, 0, null);
    } else {
      const [status, body] = await lookup(auth, raw);
      [state, detail] = classify(raw, status, body);
    }
    const line = `${state.padEnd(13)} ${raw}  ${detail}`;
    if (state === 'ok') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (state === 'renormalise') {
      console.warn("  repair: store Twilio's normalised phone_number on this row");
    } else if (state === 'not-found' || state === 'uncovered') {
      console.warn('  repair: quarantine this row; it is unreachable, not misformatted');
    } else if (state !== 'lookup-error') {
      console.warn('  repair: correct the stored string to E.164, then validate ' +
                   'with Lookup at the input layer');
    }
  }

  console.log(`${numbers.length} number(s), ${bad} unsendable`);
  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 decides whether the report is useful is the valid number stored in the wrong form. It is not an error, Lookup returns 200 and valid true, and the send will still fail if your code passes the row through verbatim. The other one worth pinning is the 404: a number Lookup has never heard of is a different repair from a number that was typed wrong, and folding them together sends someone off to fix a string that was never the problem.

test_twilio_lookup_validity_audit.py
from twilio_lookup_validity_audit import classify, explain, shape

VALID = {"valid": True, "phone_number": "+15550109999", "country_code": "US"}


def test_national_format_is_caught_without_a_lookup():
    state, detail = classify("(555) 010-9999", 0, None)
    assert state == "not-e164"
    assert "21211" in detail


def test_punctuation_after_the_plus_is_still_not_e164():
    assert shape("+1 555 010 9999") is not None
    assert classify("+1 555 010 9999", 0, None)[0] == "not-e164"


def test_valid_false_reports_the_validation_error_in_words():
    state, detail = classify("+15550109", 200,
                             {"valid": False, "validation_errors": ["TOO_SHORT"]})
    assert state == "invalid"
    assert "too few digits" in detail


def test_a_valid_number_stored_in_another_form_is_its_own_finding():
    # 200, valid true, and the send still fails: you send what is in the row.
    assert classify("+1-555-010-9999", 200, VALID)[0] == "not-e164"
    assert classify("+15550109999 ", 200, VALID)[0] == "ok"


def test_normalised_difference_is_reported_rather_than_passed():
    state, detail = classify("+15550109998", 200, VALID)
    assert state == "renormalise"
    assert "+15550109999" in detail


def test_404_and_60600_are_different_rows():
    assert classify("+15550109999", 404, {"code": 20404})[0] == "not-found"
    assert classify("+15550109999", 400, {"code": 60600})[0] == "uncovered"
    assert classify("+15550109999", 429, {"code": 20429})[0] == "lookup-error"


def test_unknown_validation_codes_survive_the_translation():
    assert explain(["SOMETHING_NEW"]) == "SOMETHING_NEW"
    assert explain([]) == "no reason given"
twilio-lookup-validity-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, explain, shape } from './twilio-lookup-validity-audit.mjs';

const VALID = { valid: true, phone_number: '+15550109999', country_code: 'US' };

test('national format is caught without a lookup', () => {
  const [state, detail] = classify('(555) 010-9999', 0, null);
  assert.equal(state, 'not-e164');
  assert.match(detail, /21211/);
});

test('punctuation after the plus is still not e164', () => {
  assert.notEqual(shape('+1 555 010 9999'), null);
  assert.equal(classify('+1 555 010 9999', 0, null)[0], 'not-e164');
});

test('valid false reports the validation error in words', () => {
  const [state, detail] = classify('+15550109', 200,
    { valid: false, validation_errors: ['TOO_SHORT'] });
  assert.equal(state, 'invalid');
  assert.match(detail, /too few digits/);
});

test('a valid number stored in another form is its own finding', () => {
  assert.equal(classify('+1-555-010-9999', 200, VALID)[0], 'not-e164');
  assert.equal(classify('+15550109999 ', 200, VALID)[0], 'ok');
});

test('normalised difference is reported rather than passed', () => {
  const [state, detail] = classify('+15550109998', 200, VALID);
  assert.equal(state, 'renormalise');
  assert.match(detail, /\+15550109999/);
});

test('404 and 60600 are different rows', () => {
  assert.equal(classify('+15550109999', 404, { code: 20404 })[0], 'not-found');
  assert.equal(classify('+15550109999', 400, { code: 60600 })[0], 'uncovered');
  assert.equal(classify('+15550109999', 429, { code: 20429 })[0], 'lookup-error');
});

test('unknown validation codes survive the translation', () => {
  assert.equal(explain(['SOMETHING_NEW']), 'SOMETHING_NEW');
  assert.equal(explain([]), 'no reason given');
});

FAQ

Why does Twilio not just parse the number for me?

Because it would have to guess a country, and guessing wrong sends the message to a real person on another continent. E.164 is unambiguous by construction: the country calling code is part of the number. The strictness is the feature; the cost is that every legacy format in your database is a failure waiting for its turn.

What is the difference between valid false and 60600?

valid is a statement about shape: does this string fit the numbering plan of the country its leading digits name. 60600 is a statement about reality: is there a carrier behind it. A well-formed number in a range nobody was ever assigned passes the first and fails the second, and only the second costs you a delivery.

Why check the shape locally when Lookup would tell me?

Because Lookup is a paid request and a string with letters in it does not need one. Screening locally spends the requests on the numbers where the answer is genuinely unknown, which matters when the input is a contact table rather than a handful of rows.

A number came back valid and the send still failed. Why?

Most often because what you sent is not what Lookup was asked about. Lookup normalises the number in its response; if your row holds a different string with the same meaning, that is the string your code sends. Comparing the returned phone_number against the stored value is the check that catches it.

Should the script write the corrected numbers back?

No, and not only because this section never writes. A bulk update over a contact table from a script holding a messaging credential is the wrong shape of change: the invalid rows need a person to look at the original string and decide whether it is recoverable at all.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.