Diagnostic Twilio
phone numbers with no traffic still bill every month
Nothing is broken. The invoice creeps up while message volume stays flat, and when somebody finally asks what the forty-one numbers on the account are for, the honest answer is that nobody knows. There is no error to search for, no alert to acknowledge — just a line item that has been quietly compounding since the last time anyone looked.
Page GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json, then for each number ask Messages.json?From=, Messages.json?To=, Calls.json?From= and Calls.json?To= over a 90-day window. A number with nothing on any of the four is idle.
Idle on its own is not a decision. Take the monthly spend from GET /2010-04-01/Accounts/{AccountSid}/Usage/Records/Monthly.json?Category=phonenumbers, divide by the number count, and report each idle number as an annual figure. "Release this and stop paying $13.80 a year" is something a person will act on; "this number looks unused" is not.
The problem in plain words
A phone number is the one Twilio resource that charges you for existing. Messages cost when they send, calls cost when they connect, but a number costs whether or not anything ever touches it. Buy one to reproduce a bug on a Thursday afternoon and it will still be on the invoice three years later, having carried four test messages in its entire life.
The reason this survives every cost review is that the invoice aggregates. It says phonenumbers and a total. It does not say which numbers, and it certainly does not say which of them carried no traffic. To get from the total to a decision you have to join billing against usage per number, and no single Twilio response does that join for you.
The cost is not only money. Every idle number is a number that has to be registered for A2P if it is ever used, a number that shows up in your Trust Hub surface, and a number that somebody can still send from if a credential leaks. Numbers you cannot account for are numbers you cannot secure.
Why it happens
No response carries both facts. IncomingPhoneNumbers.json knows the number and its capabilities but not its price and not its traffic. Usage/Records/Monthly.json knows the spend for the whole phonenumbers category but not which numbers it covers. Messages and Calls know the traffic but only if you ask per number. The audit is three resources or it is nothing.
Outbound-only checks miss the useful half. A number that never sends may still be the one printed on the invoices, taking inbound calls all day. Checking From= alone reports it as idle and somebody releases a working support line. The check has to be four queries per number, not one.
Volume is not the same as value, and the interesting case is in between. A number with three messages in ninety days is not idle, so a boolean check clears it. Divide its rent by its traffic and it worked out at more than four dollars a message. That number belongs in the report with a figure attached, not in the silent majority.
Nobody releases a number they are unsure about. Release is free and Twilio will hold it for a short recovery window, but the fear of killing a live line beats a vague suspicion every time. An exact annual cost and an exact "zero messages, zero calls, ninety days" is what turns the suspicion into a ticket.
The fix, as a flow
The script joins three resources, because no single one shows the finding: the numbers come from the account API, the spend from the monthly usage record, and the traffic from four small queries per number.
How to fix it
List every number on the account
GET /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers.json?PageSize=100, following next_page_uri until it is absent. Keep sid, phone_number and friendly_name: the friendly name is often the only surviving hint about why the number was bought.
Derive a monthly rate you can defend
GET /2010-04-01/Accounts/{AccountSid}/Usage/Records/Monthly.json?Category=phonenumbers returns one record per month with a price. Take the most recent, divide by the number count, and you have an average rate per number per month. It is an average: a toll-free number costs more than a local one, so this under-reports the expensive ones. Pass the real figure with --monthly-cost when you know it.
Ask four questions per number, not one
Messages.json?From={E164}&DateSent>={since}, Messages.json?To={E164}&DateSent>={since}, Calls.json?From={E164}&StartTime>={since} and Calls.json?To={E164}&StartTime>={since}. One page each is enough — a number with fifty messages in the window is not idle and the exact count changes nothing.
Turn the counts into money
Zero on all four is idle, and its cost for the year is the number you report. Traffic in only one direction is a separate finding, because an inbound-only number is usually deliberate. A handful of messages is a third: divide the window's rent by the traffic and print the cost per message.
Release, then re-run
The repair is a delete against /2010-04-01/Accounts/{AccountSid}/IncomingPhoneNumbers/{PNSid}.json. Releasing is free and recoverable for a short window, so the risk is low and the saving is immediate. Put the audit on a quarterly schedule; the numbers bought for the next incident will accumulate exactly like these did.
How to check it worked
Run it again after releasing. The idle count and the annual total should both fall by what you released.
python3 twilio_idle_numbers_audit.py --days 90
# 41 number(s), 0 idle, $0.00/year in rent for numbers with no traffic
The full code
One paginated GET for the numbers, one for the monthly usage record, and four small GETs per number for traffic. Read access is all it needs and all it should have. The interesting part — how activity, rent and a threshold combine into a verdict and an annual figure — is a pure function, so the arithmetic is visible and the tests do not need an account.
"""Report Twilio phone numbers carrying no traffic, priced per year.
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_idle_numbers_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
# One page of traffic settles the question. A number with this many messages in
# the window is in use, and the exact figure would not change the verdict.
PROBE = 50
def monthly_rate(records, number_count, override=None):
"""Dollars per number per month.
IncomingPhoneNumbers carries no price, so the rate has to come from the
monthly usage record for the phonenumbers category divided by the numbers on
the account. That is an average and it under-reports toll-free and short
codes, which cost more than a local number. Pass --monthly-cost when you
know the real figure.
Usage prices arrive as strings and the sign convention differs between usage
records and the balance resource, so take the magnitude.
"""
if override is not None:
return max(0.0, float(override))
rows = [r for r in records
if str(r.get("category") or "") == "phonenumbers"]
if not rows or not number_count:
return 0.0
latest = max(rows, key=lambda r: str(r.get("start_date") or ""))
try:
price = abs(float(latest.get("price") or 0))
except (TypeError, ValueError):
return 0.0
return price / float(number_count)
def verdict(activity, rate, window_days=90, min_traffic=5, flag_above=24.0):
"""Classify one number by what it carried against what it costs.
activity: counts keyed outbound_messages, inbound_messages, outbound_calls,
inbound_calls. rate: dollars per month. Pure, so the thresholds and the
arithmetic are visible and testable rather than buried in a request loop.
Returns (state, detail, annual_cost).
"""
out = (int(activity.get("outbound_messages") or 0)
+ int(activity.get("outbound_calls") or 0))
inb = (int(activity.get("inbound_messages") or 0)
+ int(activity.get("inbound_calls") or 0))
annual = max(0.0, float(rate)) * 12.0
window_cost = max(0.0, float(rate)) * (float(window_days) / 30.44)
if out == 0 and inb == 0:
if annual >= flag_above:
return ("idle-costly",
"no messages and no calls either way in %d days, and it is "
"one of the more expensive numbers on the account at $%.2f "
"a year. Release this one first."
% (window_days, annual),
annual)
return ("idle",
"no messages and no calls either way in %d days. $%.2f a year "
"for a number nothing touches." % (window_days, annual),
annual)
if out == 0:
return ("inbound-only",
"%d inbound event(s) in %d days and nothing outbound. Often "
"deliberate, so confirm before releasing: $%.2f a year."
% (inb, window_days, annual),
annual)
total = out + inb
if total < min_traffic:
per = window_cost / total if total else window_cost
return ("trickle",
"%d event(s) in %d days at $%.2f of rent, which is $%.2f per "
"message or call. Cheaper to fold this traffic onto a number "
"you already keep." % (total, window_days, window_cost, per),
annual)
return ("active",
"%d outbound and %d inbound event(s) in %d days"
% (out, inb, window_days),
annual)
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_numbers(session, account, limit):
"""Page IncomingPhoneNumbers. next_page_uri is a path, not an absolute URL."""
url = "%s/Accounts/%s/IncomingPhoneNumbers.json" % (BASE, account)
params = {"PageSize": 100}
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get("incoming_phone_numbers", []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def activity_for(session, account, e164, since):
"""Four small reads: messages and calls, each direction."""
msgs = "%s/Accounts/%s/Messages.json" % (BASE, account)
calls = "%s/Accounts/%s/Calls.json" % (BASE, account)
params = {"PageSize": PROBE}
out = {}
out["outbound_messages"] = len(get(session, msgs, **dict(
params, **{"From": e164, "DateSent>": since})).get("messages", []))
out["inbound_messages"] = len(get(session, msgs, **dict(
params, **{"To": e164, "DateSent>": since})).get("messages", []))
out["outbound_calls"] = len(get(session, calls, **dict(
params, **{"From": e164, "StartTime>": since})).get("calls", []))
out["inbound_calls"] = len(get(session, calls, **dict(
params, **{"To": e164, "StartTime>": since})).get("calls", []))
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90,
help="traffic window, in days")
ap.add_argument("--max-numbers", type=int, default=200,
help="stop after this many numbers; each costs four reads")
ap.add_argument("--monthly-cost", type=float, default=None,
help="dollars per number per month, overriding the average")
ap.add_argument("--min-traffic", type=int, default=5,
help="fewer events than this in the window reads as a trickle")
ap.add_argument("--flag-above", type=float, default=24.0,
help="annual dollars above which an idle number is urgent")
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)
numbers = list_numbers(session, account, args.max_numbers)
if not numbers:
log.info("no phone numbers on this account")
return 0
usage = get(session, "%s/Accounts/%s/Usage/Records/Monthly.json"
% (BASE, account), Category="phonenumbers")
rate = monthly_rate(usage.get("usage_records", []), len(numbers),
args.monthly_cost)
log.info("%d number(s) at about $%.2f each per month", len(numbers), rate)
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
idle, wasted = 0, 0.0
for n in numbers:
e164 = n.get("phone_number", "?")
state, detail, annual = verdict(
activity_for(session, account, e164, since), rate,
args.days, args.min_traffic, args.flag_above)
label = n.get("friendly_name") or e164
line = "%-13s %s (%s) %s" % (state, e164, label, detail)
if state == "active":
log.info(line)
continue
log.warning(line)
if state.startswith("idle"):
idle += 1
wasted += annual
log.warning(" repair: release it with a delete on %s/Accounts/%s"
"/IncomingPhoneNumbers/%s.json. Release is free and "
"recoverable for a short window.", BASE, account,
n.get("sid"))
log.info("%d number(s), %d idle, $%.2f/year in rent for numbers with no "
"traffic", len(numbers), idle, wasted)
return 1 if idle else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Twilio phone numbers carrying no traffic, priced per year.
*
* 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`;
// One page of traffic settles the question. A number with this many messages in
// the window is in use, and the exact figure would not change the verdict.
const PROBE = 50;
/**
* Dollars per number per month. IncomingPhoneNumbers carries no price, so the
* rate comes from the monthly usage record for the phonenumbers category
* divided by the numbers on the account. That is an average and it
* under-reports toll-free and short codes. Prices arrive as strings and the
* sign convention differs between resources, so take the magnitude.
*/
export function monthlyRate(records, numberCount, override = null) {
if (override !== null && override !== undefined) return Math.max(0, Number(override));
const rows = records.filter((r) => String(r.category ?? '') === 'phonenumbers');
if (!rows.length || !numberCount) return 0;
const latest = rows.reduce((a, b) =>
String(b.start_date ?? '') > String(a.start_date ?? '') ? b : a);
const price = Math.abs(Number(latest.price ?? 0));
if (!Number.isFinite(price)) return 0;
return price / Number(numberCount);
}
/**
* Classify one number by what it carried against what it costs. Pure, so the
* thresholds and the arithmetic are visible and testable.
* Returns [state, detail, annualCost].
*/
export function verdict(activity, rate, windowDays = 90, minTraffic = 5, flagAbove = 24) {
const out = Number(activity.outbound_messages ?? 0) + Number(activity.outbound_calls ?? 0);
const inb = Number(activity.inbound_messages ?? 0) + Number(activity.inbound_calls ?? 0);
const annual = Math.max(0, Number(rate)) * 12;
const windowCost = Math.max(0, Number(rate)) * (Number(windowDays) / 30.44);
if (out === 0 && inb === 0) {
if (annual >= flagAbove) {
return ['idle-costly',
`no messages and no calls either way in ${windowDays} days, and it is ` +
`one of the more expensive numbers on the account at $${annual.toFixed(2)} ` +
'a year. Release this one first.', annual];
}
return ['idle',
`no messages and no calls either way in ${windowDays} days. ` +
`$${annual.toFixed(2)} a year for a number nothing touches.`, annual];
}
if (out === 0) {
return ['inbound-only',
`${inb} inbound event(s) in ${windowDays} days and nothing outbound. Often ` +
`deliberate, so confirm before releasing: $${annual.toFixed(2)} a year.`, annual];
}
const total = out + inb;
if (total < minTraffic) {
const per = total ? windowCost / total : windowCost;
return ['trickle',
`${total} event(s) in ${windowDays} days at $${windowCost.toFixed(2)} of ` +
`rent, which is $${per.toFixed(2)} per message or call. Cheaper to fold ` +
'this traffic onto a number you already keep.', annual];
}
return ['active',
`${out} outbound and ${inb} inbound event(s) in ${windowDays} days`, annual];
}
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 listNumbers(auth, account, limit = 200) {
let url = `${BASE}/Accounts/${account}/IncomingPhoneNumbers.json`;
let params = { PageSize: 100 };
const out = [];
while (url && out.length < limit) {
const page = await get(auth, url, params);
out.push(...(page.incoming_phone_numbers ?? []));
url = page.next_page_uri ? HOST + page.next_page_uri : null;
params = {};
}
return out.slice(0, limit);
}
async function activityFor(auth, account, e164, since) {
const msgs = `${BASE}/Accounts/${account}/Messages.json`;
const calls = `${BASE}/Accounts/${account}/Calls.json`;
const p = { PageSize: PROBE };
const om = await get(auth, msgs, { ...p, From: e164, 'DateSent>': since });
const im = await get(auth, msgs, { ...p, To: e164, 'DateSent>': since });
const oc = await get(auth, calls, { ...p, From: e164, 'StartTime>': since });
const ic = await get(auth, calls, { ...p, To: e164, 'StartTime>': since });
return {
outbound_messages: (om.messages ?? []).length,
inbound_messages: (im.messages ?? []).length,
outbound_calls: (oc.calls ?? []).length,
inbound_calls: (ic.calls ?? []).length,
};
}
function flag(name, fallback) {
const i = process.argv.indexOf(name);
return i === -1 ? fallback : Number(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 auth = authHeader(key, secret);
const days = flag('--days', 90);
const minTraffic = flag('--min-traffic', 5);
const flagAbove = flag('--flag-above', 24);
const override = process.argv.includes('--monthly-cost')
? flag('--monthly-cost', null) : null;
const numbers = await listNumbers(auth, account);
if (numbers.length === 0) {
console.log('no phone numbers on this account');
return;
}
const usage = await get(auth, `${BASE}/Accounts/${account}/Usage/Records/Monthly.json`,
{ Category: 'phonenumbers' });
const rate = monthlyRate(usage.usage_records ?? [], numbers.length, override);
console.log(`${numbers.length} number(s) at about $${rate.toFixed(2)} each per month`);
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
let idle = 0;
let wasted = 0;
for (const n of numbers) {
const e164 = n.phone_number ?? '?';
const act = await activityFor(auth, account, e164, since);
const [state, detail, annual] = verdict(act, rate, days, minTraffic, flagAbove);
const label = n.friendly_name || e164;
const line = `${state.padEnd(13)} ${e164} (${label}) ${detail}`;
if (state === 'active') { console.log(line); continue; }
console.warn(line);
if (state.startsWith('idle')) {
idle += 1;
wasted += annual;
console.warn(` repair: release it with a delete on ${BASE}/Accounts/` +
`${account}/IncomingPhoneNumbers/${n.sid}.json. Release is ` +
'free and recoverable for a short window.');
}
}
console.log(`${numbers.length} number(s), ${idle} idle, $${wasted.toFixed(2)}` +
'/year in rent for numbers with no traffic');
process.exitCode = idle ? 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 cases worth pinning are the ones that change what somebody does: an idle number over the urgency threshold, a number that only ever receives, and the trickle case where the rent per message is the whole finding. The rate helper gets its own tests because it takes the newest month out of several and has to survive a price that arrives as a signed string.
from twilio_idle_numbers_audit import monthly_rate, verdict
NOTHING = {"outbound_messages": 0, "inbound_messages": 0,
"outbound_calls": 0, "inbound_calls": 0}
def test_silent_number_is_idle_and_priced_for_the_year():
state, detail, annual = verdict(NOTHING, 1.15)
assert state == "idle"
assert round(annual, 2) == 13.80
assert "13.80" in detail
def test_expensive_idle_number_is_escalated():
# A toll-free number rents for more, so it is the one to release first.
state, _, annual = verdict(NOTHING, 2.15, flag_above=24.0)
assert state == "idle-costly"
assert annual > 24.0
def test_inbound_only_number_is_not_reported_as_idle():
# Checking From= alone is how somebody releases a working support line.
act = dict(NOTHING, inbound_calls=31)
state, detail, _ = verdict(act, 1.15)
assert state == "inbound-only"
assert "confirm before releasing" in detail
def test_a_handful_of_messages_reports_cost_per_message():
act = dict(NOTHING, outbound_messages=3)
state, detail, _ = verdict(act, 1.15, window_days=90, min_traffic=5)
assert state == "trickle"
assert "per message or call" in detail
def test_busy_number_is_active():
act = dict(NOTHING, outbound_messages=50, inbound_messages=12)
state, _, _ = verdict(act, 1.15)
assert state == "active"
def test_monthly_rate_uses_the_newest_month_and_divides_by_the_numbers():
records = [
{"category": "phonenumbers", "start_date": "2026-06-01", "price": "23.00"},
{"category": "phonenumbers", "start_date": "2026-07-01", "price": "46.00"},
]
assert monthly_rate(records, 40) == 1.15
def test_monthly_rate_takes_the_magnitude_of_a_signed_price():
records = [{"category": "phonenumbers", "start_date": "2026-07-01",
"price": "-46.00"}]
assert monthly_rate(records, 40) == 1.15
def test_monthly_rate_override_wins_and_survives_an_empty_account():
assert monthly_rate([], 0, override=2.0) == 2.0
assert monthly_rate([], 0) == 0.0
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { monthlyRate, verdict } from './twilio-idle-numbers-audit.mjs';
const NOTHING = {
outbound_messages: 0, inbound_messages: 0, outbound_calls: 0, inbound_calls: 0,
};
test('silent number is idle and priced for the year', () => {
const [state, detail, annual] = verdict(NOTHING, 1.15);
assert.equal(state, 'idle');
assert.equal(Number(annual.toFixed(2)), 13.80);
assert.match(detail, /13\.80/);
});
test('expensive idle number is escalated', () => {
const [state, , annual] = verdict(NOTHING, 2.15, 90, 5, 24);
assert.equal(state, 'idle-costly');
assert.ok(annual > 24);
});
test('inbound only number is not reported as idle', () => {
const [state, detail] = verdict({ ...NOTHING, inbound_calls: 31 }, 1.15);
assert.equal(state, 'inbound-only');
assert.match(detail, /confirm before releasing/);
});
test('a handful of messages reports cost per message', () => {
const [state, detail] = verdict({ ...NOTHING, outbound_messages: 3 }, 1.15, 90, 5);
assert.equal(state, 'trickle');
assert.match(detail, /per message or call/);
});
test('busy number is active', () => {
const [state] = verdict(
{ ...NOTHING, outbound_messages: 50, inbound_messages: 12 }, 1.15);
assert.equal(state, 'active');
});
test('monthlyRate uses the newest month and divides by the numbers', () => {
const records = [
{ category: 'phonenumbers', start_date: '2026-06-01', price: '23.00' },
{ category: 'phonenumbers', start_date: '2026-07-01', price: '46.00' },
];
assert.equal(monthlyRate(records, 40), 1.15);
});
test('monthlyRate takes the magnitude of a signed price', () => {
const records = [
{ category: 'phonenumbers', start_date: '2026-07-01', price: '-46.00' },
];
assert.equal(monthlyRate(records, 40), 1.15);
});
test('monthlyRate override wins and survives an empty account', () => {
assert.equal(monthlyRate([], 0, 2), 2);
assert.equal(monthlyRate([], 0), 0);
});
FAQ
Why not just read the price off the phone number?
Because IncomingPhoneNumbers does not carry one. The number resource knows the SID, the E.164 and the capabilities, and nothing about billing. The only read-only route to a price is the monthly usage record for the phonenumbers category, which is an account total, so the per-number figure this script prints is that total divided by the number count.
Is an average rate good enough to act on?
For a report that says which numbers to look at, yes. For a number you are about to defend in a budget meeting, no: an average under-reports toll-free and short codes and over-reports local ones. Pass --monthly-cost with the real rate for the class of number you care about and the figures become exact.
Why check inbound as well as outbound?
Because the most common false positive is a number that only ever receives. It is on the invoices, on the website, in the email signature, and it has never sent a message in its life. A From= check alone reports it as dead and somebody releases it. Four queries per number is the price of not doing that.
What is the trickle state for?
The number that is technically in use and not worth keeping. Three messages in ninety days is not idle, so a boolean check clears it, but dividing the rent by the traffic gives you a cost per message that is usually absurd. Fold that traffic onto a number you already keep.
Can I release a number and get it back?
For a short window, yes. Twilio holds a released number briefly and you can reclaim it from the console, after which it goes back into the general pool and may be reissued to somebody else. That is exactly the recycling problem this section covers elsewhere, so treat the recovery window as short and confirm before you release.
Related field notes
- Numbers still pointing at Twilio's demo TwiML
- A number with no fallback URL drops the call
- Recycled numbers send OTPs to a stranger
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.
- IncomingPhoneNumber resource — Twilio Docs
- Usage Record resource — Twilio Docs
- Manage unused resources — 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.