Diagnostic Twilio
recycled numbers send OTPs to whoever owns them now
Every message says delivered. The password reset code, the appointment reminder, the balance alert — all of them accepted by the carrier, all of them handed to a handset. Just not the handset you think. The number was disconnected eleven weeks ago and reissued to somebody who has never heard of you, and your contact table has not noticed.
Pull GET https://messaging.twilio.com/v1/Deactivations?Date=YYYY-MM-DD for each day you want to cover. The response points you at a signed URL through redirect_to, valid for a couple of minutes, holding a newline-delimited list of numbers US carriers deactivated that day. It is free.
Then reconcile. Normalise both sides to the same E.164 form — your contact table almost certainly does not store them the way the feed does — intersect, and split the matches by whether you have already sent to them since the deactivation date. Those are the ones that reached a stranger.
The problem in plain words
There is no error code for this, because from the network's point of view nothing failed. The number is live, the handset is on, the message was delivered. Every metric you have says the send succeeded, and it did. The only thing that went wrong is the identity behind the number, and that is not a field in any API response.
What you get instead is a slow leak of consequences. Someone receives a verification code for an account they do not have, and either ignores it or uses it. Someone receives marketing they never consented to and reports it as spam. Complaint rates climb, and eventually the carriers start filtering your traffic with 30007 — at which point you have a delivery problem that looks like a content problem and is actually a data problem three months old.
Meanwhile your consent record still says yes. It was recorded honestly, by the previous owner, and it has silently transferred to a person who never gave it. That is the part that turns an operational nuisance into a compliance exposure.
Why it happens
Nothing tells you. The carrier does not signal reassignment on the message. Twilio publishes a daily feed precisely because there is no in-band way to learn this, and a feed only helps you if something is pulling it.
The reconciliation fails on formatting, silently. The feed is E.164. Contact tables hold (415) 555-0100, 415-555-0100, +1 415 555 0100 and a stray one with a trailing space. Intersect the raw strings and you match nothing, the report says zero, and everybody concludes the problem does not apply to them.
The download URL expires almost immediately. redirect_to is signed and short-lived, on the order of a couple of minutes. Fetching it a day later, or logging it for a colleague to run, gets you a 403 and a confusing afternoon.
Send the signed URL your Twilio credentials and it may refuse you. The redirect target is object storage, not the Twilio API. The signature is the authorisation. An HTTP client that helpfully attaches basic auth to every request, including redirects, is the reason this works on one machine and not another.
The fix, as a flow
The script normalises both sides before comparing, because the feed is E.164 and a contact table is not: intersecting the raw strings matches nothing and prints a clean report.
How to fix it
Pull the feed one day at a time
GET https://messaging.twilio.com/v1/Deactivations?Date=YYYY-MM-DD with your read credential. Handle both shapes: a JSON body carrying redirect_to, and a redirect response whose Location header carries the same URL. Days outside the retention window return a 404, which is information rather than an error.
Fetch the signed URL without your credentials
The target is object storage and the signature is the authorisation. Use a bare request, not the authenticated session, and do it immediately — the URL is valid for about two minutes. The body is newline-delimited E.164 numbers.
Normalise both sides before comparing
Strip everything that is not a digit or a leading plus, then add the default country code to a bare national number. Do it to the feed and to your contacts with the same function. This is the step that decides whether the whole audit works, and it is the step that fails quietly when it does not.
Split the matches by whether you have already sent
A match you have not messaged since the deactivation date is a suppression job. A match you have messaged since is an incident: at least one message reached the new owner, and if any of them was a verification code you have an access-control problem rather than a marketing one.
Suppress, re-verify, and run it daily
Suppress every match before the next send, and re-verify ownership rather than reusing the consent record you already have. Then schedule it. The feed is daily and free; a weekly pull means up to six days of messages going to people who never asked for them.
How to check it worked
Run yesterday's feed again after suppressing. Every match should come back as already suppressed and the incident count should be zero.
python3 twilio_deactivations_audit.py --days 7 --contacts contacts.json
# 4,812 deactivation(s) over 7 day(s), 3 match(es), 0 already messaged
The full code
One authenticated GET per day for the feed, one unauthenticated GET per signed URL, and no other network access: the contact list is a local file. The reconciliation — normalising, intersecting, and deciding which matches are incidents — is pure, which is what the tests exercise, because a normaliser that silently matches nothing is the failure mode of this whole exercise.
"""Reconcile Twilio's daily deactivation feed against your contact list.
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 json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_deactivations_audit")
MESSAGING = "https://messaging.twilio.com/v1"
def normalize(raw, default_cc="1"):
"""Reduce any phone number to one comparable E.164 string, or None.
The feed is E.164. Contact tables are not: they hold (415) 555-0100,
415-555-0100, +1 415 555 0100 and one with a trailing space. Comparing the
raw strings matches nothing, the report says zero findings, and everybody
concludes the problem does not apply to them. Pure, and tested, because this
function silently decides whether the audit works at all.
"""
text = str(raw or "").strip()
if not text:
return None
plus = text.startswith("+")
digits = "".join(c for c in text if c.isdigit())
if not digits:
return None
if not plus and len(digits) == 10:
digits = str(default_cc) + digits
elif not plus and len(digits) == 11 and digits.startswith(str(default_cc)):
pass
elif not plus and len(digits) < 10:
return None
return "+" + digits
def load_contacts(rows, default_cc="1"):
"""Normalise a contact list into number -> record. Pure.
Accepts plain strings or dicts carrying number, suppressed and last_sent_at.
"""
out = {}
for row in rows:
record = {"number": row} if isinstance(row, str) else dict(row)
key = normalize(record.get("number"), default_cc)
if key:
record["number"] = key
out[key] = record
return out
def reconcile(deactivations, contacts):
"""Intersect the feed with the contact list. Pure.
deactivations: number -> deactivation date (YYYY-MM-DD).
contacts: number -> record, both already normalised.
"""
matches = []
for number, on in deactivations.items():
record = contacts.get(number)
if record is None:
continue
matches.append({
"number": number,
"deactivated_on": on,
"last_sent_at": record.get("last_sent_at"),
"suppressed": bool(record.get("suppressed")),
"label": record.get("label") or record.get("name") or "",
})
return sorted(matches, key=lambda m: m["number"])
def verdict(match):
"""Classify one match. Pure. Returns (state, detail).
Dates are compared as ISO strings on the first ten characters, so a full
timestamp and a bare date compare correctly against each other.
"""
on = str(match.get("deactivated_on") or "")[:10]
sent = str(match.get("last_sent_at") or "")[:10]
if match.get("suppressed"):
return ("suppressed",
"already suppressed. Keep the record: it is the evidence that "
"consent for this number ended on %s." % on)
if sent and on and sent >= on:
return ("misdelivered",
"deactivated %s and you sent to it on %s. Those messages "
"reached whoever owns the number now. If any of them carried a "
"verification code, treat it as an access-control incident."
% (on, sent))
return ("at-risk",
"deactivated %s and still active in your list. The next send goes "
"to a stranger and the consent record you hold is the previous "
"owner's." % on)
def get(session, url, **params):
r = session.get(url, params=params, timeout=30, allow_redirects=False)
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)
return r
def feed_for(session, day):
"""Numbers deactivated on one day, or an empty list.
The API answers with a short-lived signed URL, either as redirect_to in a
JSON body or as a Location header on a redirect. The signature is the
authorisation on that URL, so it is fetched with a bare request: an HTTP
client that attaches basic auth to the redirect too is why this works on one
machine and not another.
"""
r = get(session, "%s/Deactivations" % MESSAGING, Date=day)
if r.status_code == 404:
log.info("no deactivation feed published for %s", day)
return []
target = r.headers.get("Location")
if not target:
try:
target = (r.json() or {}).get("redirect_to")
except ValueError:
target = None
if not target:
log.warning("no redirect_to for %s (status %d)", day, r.status_code)
return []
body = requests.get(target, timeout=60)
body.raise_for_status()
return [line.strip() for line in body.text.splitlines() if line.strip()]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=7,
help="how many days of the feed to pull, ending yesterday")
ap.add_argument("--contacts", required=True,
help="JSON file: a list of numbers, or of objects with "
"number, suppressed and last_sent_at")
ap.add_argument("--country-code", default="1",
help="country code to assume for bare national numbers")
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
with open(args.contacts, encoding="utf-8") as fh:
contacts = load_contacts(json.load(fh), args.country_code)
log.info("%d contact(s) after normalisation", len(contacts))
session = requests.Session()
session.auth = (key, secret)
deactivations = {}
for offset in range(1, args.days + 1):
day = (dt.date.today() - dt.timedelta(days=offset)).isoformat()
for raw in feed_for(session, day):
number = normalize(raw, args.country_code)
if number and number not in deactivations:
deactivations[number] = day
matches = reconcile(deactivations, contacts)
incidents = 0
for match in matches:
state, detail = verdict(match)
line = "%-13s %s %s" % (state, match["number"], detail)
if state == "suppressed":
log.info(line)
continue
if state == "misdelivered":
incidents += 1
log.warning(line)
log.warning(" repair: suppress %s in your contact table now, and "
"re-verify ownership before you send to it again. Do not "
"carry the old consent record onto a recycled number.",
match["number"])
log.info("%d deactivation(s) over %d day(s), %d match(es), %d already "
"messaged", len(deactivations), args.days, len(matches), incidents)
return 1 if matches else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Reconcile Twilio's daily deactivation feed against your contact list.
*
* 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.
*/
import { readFile } from 'node:fs/promises';
const MESSAGING = 'https://messaging.twilio.com/v1';
/**
* Reduce any phone number to one comparable E.164 string, or null.
*
* The feed is E.164. Contact tables are not: they hold (415) 555-0100,
* 415-555-0100, +1 415 555 0100 and one with a trailing space. Comparing the
* raw strings matches nothing, the report says zero findings, and everybody
* concludes the problem does not apply to them. Pure, and tested, because this
* function silently decides whether the audit works at all.
*/
export function normalize(raw, defaultCc = '1') {
const text = String(raw ?? '').trim();
if (!text) return null;
const plus = text.startsWith('+');
let digits = text.replace(/[^0-9]/g, '');
if (!digits) return null;
if (!plus && digits.length === 10) digits = String(defaultCc) + digits;
else if (!plus && digits.length === 11 && digits.startsWith(String(defaultCc))) {
// already national plus country code
} else if (!plus && digits.length < 10) return null;
return `+${digits}`;
}
/**
* Normalise a contact list into a Map of number to record. Pure. Accepts plain
* strings or objects carrying number, suppressed and last_sent_at.
*/
export function loadContacts(rows, defaultCc = '1') {
const out = new Map();
for (const row of rows) {
const record = typeof row === 'string' ? { number: row } : { ...row };
const key = normalize(record.number, defaultCc);
if (key) {
record.number = key;
out.set(key, record);
}
}
return out;
}
/** Intersect the feed with the contact list. Pure. Both already normalised. */
export function reconcile(deactivations, contacts) {
const matches = [];
for (const [number, on] of deactivations) {
const record = contacts.get(number);
if (!record) continue;
matches.push({
number,
deactivated_on: on,
last_sent_at: record.last_sent_at ?? null,
suppressed: Boolean(record.suppressed),
label: record.label ?? record.name ?? '',
});
}
return matches.sort((a, b) => (a.number < b.number ? -1 : 1));
}
/**
* Classify one match. Pure. Returns [state, detail]. Dates are compared as ISO
* strings on the first ten characters, so a full timestamp and a bare date
* compare correctly against each other.
*/
export function verdict(match) {
const on = String(match.deactivated_on ?? '').slice(0, 10);
const sent = String(match.last_sent_at ?? '').slice(0, 10);
if (match.suppressed) {
return ['suppressed',
'already suppressed. Keep the record: it is the evidence that consent ' +
`for this number ended on ${on}.`];
}
if (sent && on && sent >= on) {
return ['misdelivered',
`deactivated ${on} and you sent to it on ${sent}. Those messages reached ` +
'whoever owns the number now. If any of them carried a verification code, ' +
'treat it as an access-control incident.'];
}
return ['at-risk',
`deactivated ${on} and still active in your list. The next send goes to a ` +
"stranger and the consent record you hold is the previous owner's."];
}
function authHeader(key, secret) {
return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}
/**
* Numbers deactivated on one day, or an empty array. The API answers with a
* short-lived signed URL, either as redirect_to in a JSON body or as a Location
* header on a redirect. The signature is the authorisation on that URL, so it
* is fetched without the Twilio credentials.
*/
async function feedFor(auth, day) {
const u = new URL(`${MESSAGING}/Deactivations`);
u.searchParams.set('Date', day);
const res = await fetch(u, { headers: { Authorization: auth }, redirect: 'manual' });
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.status === 404) {
console.log(`no deactivation feed published for ${day}`);
return [];
}
let target = res.headers.get('location');
if (!target) {
try { target = (await res.json())?.redirect_to ?? null; } catch { target = null; }
}
if (!target) {
console.warn(`no redirect_to for ${day} (status ${res.status})`);
return [];
}
const body = await fetch(target);
if (!body.ok) throw new Error(`${body.status} fetching the signed feed for ${day}`);
return (await body.text()).split('\n').map((l) => l.trim()).filter(Boolean);
}
function argOf(name, fallback) {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : process.argv[i + 1];
}
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 path = argOf('--contacts', null);
if (!path) {
console.error('--contacts is required: a JSON file of numbers or records');
process.exitCode = 2;
return;
}
const days = Number(argOf('--days', 7));
const cc = String(argOf('--country-code', '1'));
const auth = authHeader(key, secret);
const contacts = loadContacts(JSON.parse(await readFile(path, 'utf-8')), cc);
console.log(`${contacts.size} contact(s) after normalisation`);
const deactivations = new Map();
for (let offset = 1; offset <= days; offset += 1) {
const day = new Date(Date.now() - offset * 86400000).toISOString().slice(0, 10);
for (const raw of await feedFor(auth, day)) {
const number = normalize(raw, cc);
if (number && !deactivations.has(number)) deactivations.set(number, day);
}
}
const matches = reconcile(deactivations, contacts);
let incidents = 0;
for (const match of matches) {
const [state, detail] = verdict(match);
const line = `${state.padEnd(13)} ${match.number} ${detail}`;
if (state === 'suppressed') { console.log(line); continue; }
if (state === 'misdelivered') incidents += 1;
console.warn(line);
console.warn(` repair: suppress ${match.number} in your contact table now, ` +
'and re-verify ownership before you send to it again. Do not ' +
'carry the old consent record onto a recycled number.');
}
console.log(`${deactivations.size} deactivation(s) over ${days} day(s), ` +
`${matches.length} match(es), ${incidents} already messaged`);
process.exitCode = matches.length ? 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 normaliser gets the most tests, because it is the function that decides whether this audit finds anything at all. A version that returns the raw string matches nothing against an E.164 feed, prints a clean report, and is indistinguishable from an account with no problem. The rest pin the split between a number to suppress and a message already sent to a stranger.
from twilio_deactivations_audit import load_contacts, normalize, reconcile, verdict
def test_every_common_contact_format_normalises_to_one_key():
for raw in ["+14155550100", "(415) 555-0100", "415-555-0100",
" +1 415 555 0100 ", "1 (415) 555 0100"]:
assert normalize(raw) == "+14155550100"
def test_a_non_us_number_keeps_its_own_country_code():
assert normalize("+44 20 7946 0100") == "+442079460100"
def test_junk_and_short_numbers_are_dropped_rather_than_guessed():
assert normalize("") is None
assert normalize(None) is None
assert normalize("not a number") is None
assert normalize("5550100") is None
def test_reconcile_matches_across_different_formats():
# The whole point: the feed is E.164 and the contact table is not.
contacts = load_contacts([
{"number": "(415) 555-0100", "last_sent_at": None},
{"number": "415-555-0199"},
])
deactivations = {"+14155550100": "2026-08-01"}
matches = reconcile(deactivations, contacts)
assert [m["number"] for m in matches] == ["+14155550100"]
assert matches[0]["deactivated_on"] == "2026-08-01"
def test_sending_after_the_deactivation_date_is_an_incident():
state, detail = verdict({"number": "+14155550100",
"deactivated_on": "2026-08-01",
"last_sent_at": "2026-08-14T09:12:00Z"})
assert state == "misdelivered"
assert "access-control incident" in detail
def test_a_send_before_the_deactivation_is_only_at_risk():
state, _ = verdict({"number": "+14155550100",
"deactivated_on": "2026-08-01",
"last_sent_at": "2026-07-30"})
assert state == "at-risk"
def test_a_match_with_no_sends_is_still_at_risk():
state, detail = verdict({"number": "+14155550100",
"deactivated_on": "2026-08-01",
"last_sent_at": None})
assert state == "at-risk"
assert "consent record" in detail
def test_an_already_suppressed_match_is_not_reported_as_a_problem():
state, _ = verdict({"number": "+14155550100", "deactivated_on": "2026-08-01",
"last_sent_at": "2026-08-14", "suppressed": True})
assert state == "suppressed"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadContacts, normalize, reconcile, verdict,
} from './twilio-deactivations-audit.mjs';
test('every common contact format normalises to one key', () => {
for (const raw of ['+14155550100', '(415) 555-0100', '415-555-0100',
' +1 415 555 0100 ', '1 (415) 555 0100']) {
assert.equal(normalize(raw), '+14155550100');
}
});
test('a non us number keeps its own country code', () => {
assert.equal(normalize('+44 20 7946 0100'), '+442079460100');
});
test('junk and short numbers are dropped rather than guessed', () => {
assert.equal(normalize(''), null);
assert.equal(normalize(null), null);
assert.equal(normalize('not a number'), null);
assert.equal(normalize('5550100'), null);
});
test('reconcile matches across different formats', () => {
const contacts = loadContacts([
{ number: '(415) 555-0100', last_sent_at: null },
{ number: '415-555-0199' },
]);
const deactivations = new Map([['+14155550100', '2026-08-01']]);
const matches = reconcile(deactivations, contacts);
assert.deepEqual(matches.map((m) => m.number), ['+14155550100']);
assert.equal(matches[0].deactivated_on, '2026-08-01');
});
test('sending after the deactivation date is an incident', () => {
const [state, detail] = verdict({
number: '+14155550100',
deactivated_on: '2026-08-01',
last_sent_at: '2026-08-14T09:12:00Z',
});
assert.equal(state, 'misdelivered');
assert.match(detail, /access-control incident/);
});
test('a send before the deactivation is only at risk', () => {
const [state] = verdict({
number: '+14155550100', deactivated_on: '2026-08-01', last_sent_at: '2026-07-30',
});
assert.equal(state, 'at-risk');
});
test('a match with no sends is still at risk', () => {
const [state, detail] = verdict({
number: '+14155550100', deactivated_on: '2026-08-01', last_sent_at: null,
});
assert.equal(state, 'at-risk');
assert.match(detail, /consent record/);
});
test('an already suppressed match is not reported as a problem', () => {
const [state] = verdict({
number: '+14155550100',
deactivated_on: '2026-08-01',
last_sent_at: '2026-08-14',
suppressed: true,
});
assert.equal(state, 'suppressed');
});
FAQ
How often should the feed run?
Daily. The feed is published per day and it is free, so the only cost of running it every morning is a handful of requests. A weekly pull leaves up to six days during which you are still sending verification codes to numbers that changed hands, and those are the sends that hurt most.
Why does the download URL stop working?
Because it is signed and short-lived, on the order of a couple of minutes. Fetch it in the same run that asked for it. If you are pasting it into a terminal or handing it to a colleague, it will have expired by the time it is used, and the 403 you get back looks like a permissions problem rather than an expiry.
Why fetch the signed URL without my Twilio credentials?
Because the target is object storage rather than the Twilio API, and the signature in the URL is the authorisation. An HTTP client configured with basic auth for every request will attach it to the redirect too, and some storage backends reject a request that carries both. Use a bare request for that one fetch.
Does this apply outside the United States?
The Twilio feed covers US carrier deactivations. The underlying behaviour is not unique to the US, but the data source is, so treat this as a US control and handle other markets with re-verification on a schedule instead. The script's country-code option exists so your non-US contacts normalise correctly rather than being silently dropped.
What is the connection to carrier filtering?
Complaints. A stranger who receives your marketing or your OTPs reports it, and enough of that damages the sender reputation the carriers score you on. Weeks later the symptom is 30007 filtering on traffic that looks perfectly clean, and the actual cause is a contact list that was never reconciled.
Related field notes
- Sends to STOP'd recipients that keep bouncing
- Carrier filtering that drops SMS silently
- SMS sent to landlines that can never receive it
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.
- Deactivations resource — Twilio Docs
- Message resource — Twilio Docs
- Error 30007: message filtered — Twilio Docs
- API keys — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.