Skip to content

Diagnostic Twilio

error 30005 is permanent: the carrier has no such number

The same number fails every campaign. Every time, undelivered, error_code 30005, and every time the retry queue picks it up again because the handler that wrote it treated 30005 the way it treats 30003. It is not the same thing. The carrier is not saying the handset was busy; it is saying it has never heard of that number.

Read-only key Python and Node.js Tests included
A person sitting in a chair with a laptop and a credit card
Photo by SumUp on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and collect the distinct to values on rows where error_code is 30005. Any recipient with two or more 30005s on separate days is permanently dead: delete it from the sending list.

Two guards keep that rule honest. Count the same recipient's delivered messages in the window — a number that later delivered was reassigned and must not be deleted. And parse the day properly: the Messages list returns RFC 2822 timestamps, so slicing the first ten characters gives you Fri, 21 A and collapses every failure onto one day.

The problem in plain words

30005 is the cheapest problem on this list to fix and the easiest to leave running for years, because it costs a fraction of a cent at a time. A number gets disconnected, or was mistyped at signup, or was never real. Your list keeps it. Every campaign sends to it, every send is accepted, priced and marked undelivered, and nothing anywhere says stop.

The retry loop is what turns it from a rounding error into a real one. Most delivery handlers are written around 30003, where retrying is the correct answer, and 30005 arrives through the same code path with the same shape: undelivered, an error code, a Message SID. So it is retried on the same schedule, indefinitely, against a number the carrier has already said does not exist. Multiply by a list that has been accumulating dead numbers since the product launched and the cost stops being invisible — and the delivery rate you report to the business has a permanent floor in it that nobody can explain.

Numberdisconnectedcarrier drops therecordCampaign sendscontact listunchangedUndelivered30005same shape as30003Handler retriesswitched on statusNever succeedsbilled every cycle
Nothing in the payload marks 30005 as permanent. It arrives through the same field, in the same shape, as the error that is worth retrying.

Why it happens

Nothing in the payload says permanent. A status callback carrying 30005 looks exactly like one carrying 30003: same fields, same shape, same status. The permanence lives in the docs for the code, not in the message, so a handler that switches on status rather than on error_code cannot tell them apart.

The Messages list cannot be queried by error. No ErrorCode parameter, no Status parameter — only To, From, DateSent and paging. The only way to find your 30005s is to page the window and filter client-side.

Twilio's timestamps are not ISO. date_sent comes back as Fri, 21 Aug 2026 19:14:22 +0000. The distinct-day count is the entire rule that separates a dead number from a single anomaly, and the obvious ten-character slice destroys it silently: every failure lands on the same fake day and every dead number reads as a one-off.

Numbers get reassigned. US carriers reissue disconnected numbers to new subscribers. A number that 30005'd in March can deliver in August, to a different person. Deleting on the strength of an old failure is how a real customer stops receiving anything, which is why the delivered count has to be part of the verdict.

The fix, as a flow

The distinct day count is the whole rule, and it rests on parsing an RFC 2822 timestamp: a ten character slice of it reads the same for every message ever sent, so every dead number looks like a one off.

30005 rows, grouped byrecipientdistinct days and delivered countDelivered later tooreassigned, keep itOne failure onlyconfirm with Lookup firstRepeats inside a dayyour queue is retryingFailed on separate dayscarrier has no such number
A delivery anywhere in the window outranks every failure: carriers reissue disconnected numbers to new subscribers.

How to fix it

Page the Messages list over a bounded window

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri, with a day window and a hard message cap. Make the window at least a fortnight: the rule needs failures on separate days, and a two-day window cannot produce that evidence.

Filter on error_code 30005 and nothing else

Coerce error_code to an integer first; it is null on healthy rows and sometimes a string on failed ones. Do not fold 30003 or 30006 into the same bucket. They are different failures with different repairs, and pooling them is how a transient handset ends up deleted.

Reduce each timestamp to a real calendar day

date_sent is RFC 2822. Parse the month name and rebuild YYYY-MM-DD, then dedupe. Two failures on the same afternoon are one retry loop; two failures three weeks apart are a carrier telling you the same thing twice, which is the finding.

Count deliveries to the same number before condemning it

For every recipient with a 30005, count messages to it with status delivered in the same window. Any delivery at all overrides the failures: the number is live now, whoever owns it, and it stays on the list.

Delete, do not requeue, and validate at capture time

Remove the confirmed numbers from your own contact table — there is no Twilio-side list to update. Then close the source: GET https://lookups.twilio.com/v2/PhoneNumbers/{E164}?Fields=line_type_intelligence at signup rejects invalid and non-mobile numbers before they ever reach a campaign.

How to check it worked

Re-run over the same window after the deletions. Nothing should be in the dead or retry-loop state; a handful of suspect rows is normal on any live list.

python3 twilio_dead_number_audit.py --days 30
# 30005 on 14 recipient(s) over 30 day(s), 0 confirmed dead

The full code

One paginated GET over the Messages list, and an API Key with read access is all it can use. Three pure functions carry the note: the error-code coercion, the RFC 2822 day parser, and the verdict. The day parser looks like plumbing and is not — it is the function the whole permanence rule rests on, and it is the one most likely to be written as a ten-character slice and never questioned.

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_dead_number_audit.py
"""Report phone numbers that Twilio reports as unknown to the carrier (30005).

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_dead_number_audit")

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

UNKNOWN_HANDSET = 30005

MONTHS = {"jan": "01", "feb": "02", "mar": "03", "apr": "04", "may": "05",
          "jun": "06", "jul": "07", "aug": "08", "sep": "09", "oct": "10",
          "nov": "11", "dec": "12"}


def error_code(message):
    """Read error_code as an integer, or None.

    Null on healthy messages, a number on failed ones, and a string often enough
    that comparing the raw value against 30005 quietly reports nothing.
    """
    raw = message.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def day(value):
    """Reduce a Twilio timestamp to YYYY-MM-DD. Pure, and load-bearing.

    The Messages list returns RFC 2822 dates like "Fri, 21 Aug 2026 19:14:22
    +0000", not ISO ones. The obvious value[:10] slice yields "Fri, 21 A", so
    every failure collapses onto one identical fake day and the distinct-day
    rule - which is the only thing separating a dead number from an anomaly -
    silently stops working. ISO strings are accepted too, because scheduled
    messages and exports can hand you either.
    """
    s = str(value or "").strip()
    if not s:
        return None
    if "," in s:
        parts = s.replace(",", " ").split()
        if len(parts) >= 4 and parts[2][:3].lower() in MONTHS:
            try:
                return "%s-%s-%02d" % (parts[3], MONTHS[parts[2][:3].lower()],
                                       int(parts[1]))
            except (TypeError, ValueError):
                return None
        return None
    return s[:10] if len(s) >= 10 else None


def by_recipient(messages):
    """Bucket 30005 by destination number, with the delivered count alongside.

    Pure, so the grouping can be tested without a network. Recipients with no
    30005 are dropped at the end; they are tracked along the way only so that a
    failing number's deliveries are counted, which is the guard against deleting
    a number that was reassigned to somebody real.
    """
    out = {}
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        to = m.get("to") or "unknown recipient"
        row = out.setdefault(to, {"dead": 0, "delivered": 0, "days": [], "sids": []})
        if str(m.get("status") or "").lower() == "delivered":
            row["delivered"] += 1
        if error_code(m) == UNKNOWN_HANDSET:
            row["dead"] += 1
            d = day(m.get("date_sent") or m.get("date_created"))
            if d and d not in row["days"]:
                row["days"].append(d)
            if len(row["sids"]) < 3:
                row["sids"].append(m.get("sid"))
    for row in out.values():
        row["days"].sort()
    return {k: v for k, v in out.items() if v["dead"]}


def verdict(row):
    """Classify one recipient. Pure, so the permanence rule is testable.

    Returns (state, detail).
    """
    dead = int(row.get("dead") or 0)
    delivered = int(row.get("delivered") or 0)
    days = list(row.get("days") or [])

    if not dead:
        return ("clean", "no 30005 on this number")

    if delivered:
        return ("recovered",
                "%d unknown-handset failures but %d delivered in the same window. "
                "30005 is permanent for a number, not for a person: carriers "
                "reissue disconnected numbers. Keep this one." % (dead, delivered))

    if dead >= 2 and len(days) >= 2:
        return ("dead",
                "%d failures on %d separate days (%s). The carrier does not have "
                "this number. Delete it from the list: no retry can ever succeed "
                "and every attempt is billed."
                % (dead, len(days), ", ".join(days)))

    if dead >= 2:
        return ("retry-loop",
                "%d failures, all on %s. Something is retrying a permanent "
                "failure inside a single day. 30005 is not 30003 - waiting "
                "changes nothing, and each attempt costs."
                % (dead, days[0] if days else "one day"))

    return ("suspect",
            "one 30005. Permanent by definition, but one row is one row: confirm "
            "with Lookup line type intelligence before deleting a customer record.")


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 Status or ErrorCode filter exists on this resource,
    so the date window and the page 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 main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="how far back to read the Messages list; the rule needs "
                         "failures on separate days, so keep this wide")
    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)

    since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    messages = list_messages(session, account, since, args.max_messages)
    if not messages:
        log.info("no messages sent since %s", since)
        return 0

    rows = by_recipient(messages)
    if not rows:
        log.info("no 30005 in %d message(s) since %s", len(messages), since)
        return 0

    confirmed = 0
    for to, row in sorted(rows.items()):
        state, detail = verdict(row)
        line = "%-11s %s  %s" % (state, to, detail)
        if state in ("recovered", "suspect"):
            log.info(line)
            continue
        confirmed += 1
        log.warning(line)
        log.warning("  message sids: %s", ", ".join(str(s) for s in row["sids"]))
        log.warning("  repair: delete %s from your own contact table - Twilio has "
                    "no list to update - and gate new signups with GET "
                    "https://lookups.twilio.com/v2/PhoneNumbers/%s"
                    "?Fields=line_type_intelligence", to, to)

    log.info("30005 on %d recipient(s) over %d day(s), %d confirmed dead",
             len(rows), args.days, confirmed)
    return 1 if confirmed else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-dead-number-audit.mjs
/**
 * Report phone numbers that Twilio reports as unknown to the carrier (30005).
 *
 * 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 UNKNOWN_HANDSET = 30005;

const MONTHS = {
  jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06',
  jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12',
};

/**
 * Read error_code as a number, or null. Null on healthy messages, a number on
 * failed ones, and a string often enough that comparing the raw value against
 * 30005 quietly reports 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;
}

/**
 * Reduce a Twilio timestamp to YYYY-MM-DD. Pure, and load-bearing.
 *
 * The Messages list returns RFC 2822 dates like "Fri, 21 Aug 2026 19:14:22
 * +0000". Slicing the first ten characters yields "Fri, 21 A", so every failure
 * collapses onto one fake day and the distinct-day rule stops working without
 * ever raising anything. ISO strings are accepted too.
 */
export function day(value) {
  const s = String(value ?? '').trim();
  if (!s) return null;
  if (s.includes(',')) {
    const parts = s.replace(/,/g, ' ').split(/\s+/).filter(Boolean);
    const month = parts.length >= 4 ? MONTHS[parts[2].slice(0, 3).toLowerCase()] : null;
    if (!month) return null;
    const dd = Number(parts[1]);
    if (!Number.isFinite(dd)) return null;
    return `${parts[3]}-${month}-${String(dd).padStart(2, '0')}`;
  }
  return s.length >= 10 ? s.slice(0, 10) : null;
}

/**
 * Bucket 30005 by destination number, with the delivered count alongside. Pure,
 * so the grouping can be tested without a network. Recipients with no 30005 are
 * dropped at the end; they are tracked only so a failing number's deliveries are
 * counted, which is the guard against deleting a reassigned number.
 */
export function byRecipient(messages) {
  const out = new Map();
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    const to = m.to || 'unknown recipient';
    if (!out.has(to)) out.set(to, { dead: 0, delivered: 0, days: [], sids: [] });
    const row = out.get(to);
    if (String(m.status ?? '').toLowerCase() === 'delivered') row.delivered += 1;
    if (errorCode(m) === UNKNOWN_HANDSET) {
      row.dead += 1;
      const d = day(m.date_sent || m.date_created);
      if (d && !row.days.includes(d)) row.days.push(d);
      if (row.sids.length < 3) row.sids.push(m.sid);
    }
  }
  for (const [to, row] of [...out]) {
    if (!row.dead) out.delete(to);
    else row.days.sort();
  }
  return out;
}

/**
 * Classify one recipient. Pure, so the permanence rule is testable.
 * Returns [state, detail].
 */
export function verdict(row) {
  const dead = Number(row.dead ?? 0);
  const delivered = Number(row.delivered ?? 0);
  const days = [...(row.days ?? [])];

  if (!dead) return ['clean', 'no 30005 on this number'];

  if (delivered) {
    return ['recovered',
      `${dead} unknown-handset failures but ${delivered} delivered in the same ` +
      'window. 30005 is permanent for a number, not for a person: carriers ' +
      'reissue disconnected numbers. Keep this one.'];
  }

  if (dead >= 2 && days.length >= 2) {
    return ['dead',
      `${dead} failures on ${days.length} separate days (${days.join(', ')}). ` +
      'The carrier does not have this number. Delete it from the list: no retry ' +
      'can ever succeed and every attempt is billed.'];
  }

  if (dead >= 2) {
    return ['retry-loop',
      `${dead} failures, all on ${days[0] ?? 'one day'}. Something is retrying a ` +
      'permanent failure inside a single day. 30005 is not 30003 - waiting ' +
      'changes nothing, and each attempt costs.'];
  }

  return ['suspect',
    'one 30005. Permanent by definition, but one row is one row: confirm with ' +
    'Lookup line type intelligence before deleting a customer record.'];
}

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 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 since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);

  const messages = await listMessages(auth, account, since);
  if (messages.length === 0) {
    console.log(`no messages sent since ${since}`);
    return;
  }

  const rows = byRecipient(messages);
  if (rows.size === 0) {
    console.log(`no 30005 in ${messages.length} message(s) since ${since}`);
    return;
  }

  let confirmed = 0;
  for (const [to, row] of [...rows.entries()].sort()) {
    const [state, detail] = verdict(row);
    const line = `${state.padEnd(11)} ${to}  ${detail}`;
    if (state === 'recovered' || state === 'suspect') { console.log(line); continue; }
    confirmed += 1;
    console.warn(line);
    console.warn(`  message sids: ${row.sids.join(', ')}`);
    console.warn(`  repair: delete ${to} from your own contact table - Twilio has ` +
                 'no list to update - and gate new signups with GET ' +
                 `https://lookups.twilio.com/v2/PhoneNumbers/${to}` +
                 '?Fields=line_type_intelligence');
  }

  console.log(`30005 on ${rows.size} recipient(s) over ${days} day(s), ` +
              `${confirmed} confirmed dead`);
  process.exitCode = confirmed ? 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 day parser gets its own cases first, because it is where this silently breaks: an RFC 2822 timestamp sliced to ten characters produces the same string for every message ever sent, and every dead number then reads as a single anomaly. After that, the two cases that decide whether a contact is deleted: two failures on separate days is dead, and any delivery at all in the window overrides the failures completely.

test_twilio_dead_number_audit.py
from twilio_dead_number_audit import by_recipient, day, error_code, verdict


def dead(sid, to="+15557770001", when="Fri, 21 Aug 2026 19:14:22 +0000"):
    return {"sid": sid, "to": to, "from": "+15550001111", "status": "undelivered",
            "error_code": 30005, "date_sent": when, "direction": "outbound-api"}


def test_error_code_reads_strings_and_numbers_the_same():
    assert error_code({"error_code": 30005}) == 30005
    assert error_code({"error_code": "30005"}) == 30005
    assert error_code({"error_code": None}) is None
    assert error_code({}) is None


def test_day_parses_the_rfc_2822_form_the_messages_list_actually_returns():
    # A ten-character slice of this gives "Fri, 21 A" for every message ever
    # sent, which is what makes the distinct-day rule fail silently.
    assert day("Fri, 21 Aug 2026 19:14:22 +0000") == "2026-08-21"
    assert day("Mon, 03 Aug 2026 01:02:03 +0000") == "2026-08-03"


def test_day_also_accepts_an_iso_timestamp():
    assert day("2026-08-21T19:14:22Z") == "2026-08-21"


def test_day_returns_none_rather_than_a_wrong_answer():
    assert day(None) is None
    assert day("") is None
    assert day("Fri, 21 Xxx 2026 19:14:22 +0000") is None


def test_by_recipient_dedupes_days_and_keeps_them_sorted():
    rows = by_recipient([
        dead("SM1", when="Fri, 21 Aug 2026 19:14:22 +0000"),
        dead("SM2", when="Fri, 21 Aug 2026 22:00:00 +0000"),
        dead("SM3", when="Mon, 03 Aug 2026 08:00:00 +0000"),
    ])
    assert rows["+15557770001"]["days"] == ["2026-08-03", "2026-08-21"]
    assert rows["+15557770001"]["dead"] == 3


def test_by_recipient_drops_numbers_with_no_30005_and_ignores_inbound():
    rows = by_recipient([
        {"sid": "SM1", "to": "+15557770002", "status": "delivered",
         "error_code": None, "direction": "outbound-api"},
        {"sid": "SM2", "to": "+15557770003", "direction": "inbound",
         "status": "received"},
    ])
    assert rows == {}


def test_two_failures_on_separate_days_is_a_dead_number():
    state, detail = verdict({"dead": 2, "delivered": 0,
                             "days": ["2026-08-03", "2026-08-21"]})
    assert state == "dead"
    assert "Delete it" in detail


def test_a_delivery_in_the_window_overrides_the_failures():
    # Carriers reissue disconnected numbers. Deleting on the strength of an old
    # 30005 is how a live customer stops hearing from you.
    state, detail = verdict({"dead": 3, "delivered": 1,
                             "days": ["2026-08-03", "2026-08-21"]})
    assert state == "recovered"
    assert "Keep this one" in detail


def test_repeats_inside_one_day_are_a_retry_loop_not_evidence():
    state, detail = verdict({"dead": 5, "delivered": 0, "days": ["2026-08-21"]})
    assert state == "retry-loop"
    assert "30005 is not 30003" in detail


def test_a_single_failure_is_only_a_suspect():
    state, detail = verdict({"dead": 1, "delivered": 0, "days": ["2026-08-21"]})
    assert state == "suspect"
    assert "Lookup" in detail


def test_no_failures_at_all_is_clean():
    state, _ = verdict({"dead": 0, "delivered": 4, "days": []})
    assert state == "clean"
twilio-dead-number-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { byRecipient, day, errorCode, verdict }
  from './twilio-dead-number-audit.mjs';

const dead = (sid, to = '+15557770001', when = 'Fri, 21 Aug 2026 19:14:22 +0000') => ({
  sid, to, from: '+15550001111', status: 'undelivered', error_code: 30005,
  date_sent: when, direction: 'outbound-api',
});

test('error code reads strings and numbers the same', () => {
  assert.equal(errorCode({ error_code: 30005 }), 30005);
  assert.equal(errorCode({ error_code: '30005' }), 30005);
  assert.equal(errorCode({ error_code: null }), null);
  assert.equal(errorCode({}), null);
});

test('day parses the RFC 2822 form the Messages list actually returns', () => {
  // A ten-character slice gives "Fri, 21 A" for every message ever sent, which
  // is what makes the distinct-day rule fail silently.
  assert.equal(day('Fri, 21 Aug 2026 19:14:22 +0000'), '2026-08-21');
  assert.equal(day('Mon, 03 Aug 2026 01:02:03 +0000'), '2026-08-03');
});

test('day also accepts an ISO timestamp', () => {
  assert.equal(day('2026-08-21T19:14:22Z'), '2026-08-21');
});

test('day returns null rather than a wrong answer', () => {
  assert.equal(day(null), null);
  assert.equal(day(''), null);
  assert.equal(day('Fri, 21 Xxx 2026 19:14:22 +0000'), null);
});

test('byRecipient dedupes days and keeps them sorted', () => {
  const rows = byRecipient([
    dead('SM1', '+15557770001', 'Fri, 21 Aug 2026 19:14:22 +0000'),
    dead('SM2', '+15557770001', 'Fri, 21 Aug 2026 22:00:00 +0000'),
    dead('SM3', '+15557770001', 'Mon, 03 Aug 2026 08:00:00 +0000'),
  ]);
  assert.deepEqual(rows.get('+15557770001').days, ['2026-08-03', '2026-08-21']);
  assert.equal(rows.get('+15557770001').dead, 3);
});

test('byRecipient drops numbers with no 30005 and ignores inbound', () => {
  const rows = byRecipient([
    { sid: 'SM1', to: '+15557770002', status: 'delivered', error_code: null,
      direction: 'outbound-api' },
    { sid: 'SM2', to: '+15557770003', direction: 'inbound', status: 'received' },
  ]);
  assert.equal(rows.size, 0);
});

test('two failures on separate days is a dead number', () => {
  const [state, detail] = verdict({ dead: 2, delivered: 0,
                                    days: ['2026-08-03', '2026-08-21'] });
  assert.equal(state, 'dead');
  assert.match(detail, /Delete it/);
});

test('a delivery in the window overrides the failures', () => {
  const [state, detail] = verdict({ dead: 3, delivered: 1,
                                    days: ['2026-08-03', '2026-08-21'] });
  assert.equal(state, 'recovered');
  assert.match(detail, /Keep this one/);
});

test('repeats inside one day are a retry loop, not evidence', () => {
  const [state, detail] = verdict({ dead: 5, delivered: 0, days: ['2026-08-21'] });
  assert.equal(state, 'retry-loop');
  assert.match(detail, /30005 is not 30003/);
});

test('a single failure is only a suspect', () => {
  const [state, detail] = verdict({ dead: 1, delivered: 0, days: ['2026-08-21'] });
  assert.equal(state, 'suspect');
  assert.match(detail, /Lookup/);
});

test('no failures at all is clean', () => {
  const [state] = verdict({ dead: 0, delivered: 4, days: [] });
  assert.equal(state, 'clean');
});

FAQ

Should I ever retry a message that failed with 30005?

No. The carrier is saying it has no record of that number, and that answer does not change because you asked again ten minutes later. Every retry is accepted, priced and marked undelivered. The only thing worth doing is confirming the number with Lookup and removing it.

How do I tell 30005 apart from 30003 in my delivery handler?

Switch on error_code, not on status. Both arrive as undelivered with an error code attached and are indistinguishable from the status alone. 30003 is transient and worth one retry; 30005 is permanent and worth a deletion. A handler written for one and reused for the other is the usual cause of the retry loop.

Why does the script want failures on two different days?

Because several failures inside one day usually mean your own queue retried, which is one carrier answer counted five times. Two failures weeks apart are two independent answers. The window has to be wide enough to produce that evidence, which is why the default here is 30 days rather than 7.

Could a number that returned 30005 start working again?

Yes, and that is why the delivered count is part of the verdict. US carriers reissue disconnected numbers to new subscribers, so a number that was unknown in March can be a real handset in August. Any delivery in the window overrides the failures and the contact is kept. Treat the new owner as a new contact, though, not as the old one.

Where do I delete the number, on Twilio or in my database?

In your database. Twilio has no list of dead numbers to update and no resource to write, which is also why this script only reads. It prints which numbers to remove and the Lookup call to gate future signups with.

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.