Diagnostic Twilio
the trial account's three verified numbers are spent
It works on your phone. It has always worked on your phone. A colleague joins the project, adds their number to the test list, and gets nothing at all — 21608, on a number that is switched on, in coverage, and perfectly capable of receiving messages from everyone else. Nothing changed in the code between the two runs. What changed is that the account had already spent the three verifications it was ever going to get.
Read GET /2010-04-01/Accounts/{AccountSid}/OutgoingCallerIds.json and count the entries. On an account where GET /2010-04-01/Accounts/{AccountSid}.json reports type as Trial, a count at or near three means the pool is spent: three verifications is the lifetime allowance for the account, not a rolling limit.
Then compare each phone_number against the to values your application actually sends to, from GET /2010-04-01/Accounts/{AccountSid}/Messages.json. Any destination missing from that list is a 21608 waiting to happen, and comparing the two is the only way to see it before somebody reports it.
The problem in plain words
The rule is unusual enough that people do not believe it the first time. A trial account may verify three unique numbers, ever. Not three at a time, not three per month: three, across the whole life of the account, and deleting a verified caller ID does not give the slot back. So the pool is a countdown that only ever goes one way, and it is spent by exactly the ordinary things — your phone, a second phone for testing inbound, a personal number used once to check something on a Sunday.
By the time the fourth number is needed, the account has been working for weeks. That is what makes the diagnosis go wrong: the failure is new, so it is investigated as though something recently broke. It presents as one person's phone not receiving messages, which points the investigation at that phone, that carrier, that number's opt-out state. The account-level constraint that actually caused it is invisible from anywhere near the failing send.
Worse, verification on a trial account can only be done by SMS. If the number that needs to be added is a landline, a desk phone or a VoIP number that cannot receive SMS, no slot in the world helps, and the answer is the same as it is for the exhausted pool: this account has to be upgraded.
Why it happens
Three is a lifetime cap, and deletion does not refund it. The tempting fix — delete the number you no longer test with, free a slot — does not work. You lose the verification and gain nothing, so the account is left in a worse state than before. It is worth knowing this before you start tidying.
The error names the destination, not the account. 21608 is about the To number being unverified, so it reads as a per-recipient problem. Everything about the message that failed looks fine, and the thing that is actually full is a list on the account that nobody has looked at.
The verified list and the destination list are never compared. Both are one GET away. Nothing joins them for you, and until they are joined the report is either a count with no meaning or a list of numbers with no indication which of them matter. The join is the whole check.
Verification is SMS-only on trial. A number that cannot receive SMS cannot be verified on a trial account regardless of how many slots remain, which quietly rules out testing against landlines and some VoIP numbers until the account is upgraded.
The real repair is not about slots at all. Upgrading removes the verified-number restriction entirely, so the correct move on an account that has hit this is to stop managing the pool and go and add a payment method. That the account never left trial is the larger finding; this is the specific wall you hit first.
The fix, as a flow
The script joins the verified list to the destinations actually used, because a count on its own is a number between zero and three with no way of knowing whether it was enough.
How to fix it
Confirm the restriction applies at all
GET /2010-04-01/Accounts/{AccountSid}.json and read type. On a Full account the verified caller ID list still exists — it is used for outbound voice caller ID — but it no longer gates who you can message, so counting it proves nothing. Check the account type before you interpret the count.
List the verified numbers
GET /2010-04-01/Accounts/{AccountSid}/OutgoingCallerIds.json?PageSize=50, following next_page_uri. Read phone_number and friendly_name on each. The count is the first half of the answer; on a trial account, three is the ceiling.
Collect the destinations the application actually uses
Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and collect the distinct to values on outbound rows. This is the other half. Without it you have a number between zero and three and no idea whether it is enough.
Compare them in E.164, not as strings
+1 (415) 555-0100 and +14155550100 are the same phone and different strings. Strip everything that is not a digit or a leading + on both sides before comparing, or the report will confidently tell you a verified number is unverified.
Upgrade rather than manage the pool
Console → Billing → Upgrade. Adding a payment method removes the verified-number restriction completely, which is the only repair that scales past the third tester. Do not delete caller IDs hoping to free a slot; the quota does not come back and you will have lost a working verification for nothing.
How to check it worked
Re-run the script. After an upgrade it should report not-trial and exit zero; every destination is reachable and the pool no longer gates anything.
python3 twilio_verified_caller_ids_audit.py --days 30
# not-trial type is Full: the verified caller ID list no longer gates messaging.
The full code
Three GETs: the account, the verified caller IDs, and the message window that says which destinations are actually in use. The two pure pieces are the E.164 normaliser and the verdict, and the normaliser is the one that quietly decides whether the whole report is true, because the two lists arrive formatted differently often enough to matter.
"""Report whether a Twilio trial account has spent its verified-number quota.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is an upgrade in the Console, so
it is printed for a human to run.
"""
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_verified_caller_ids_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
# Verifications a trial account gets over its entire lifetime. Deleting a
# verified caller ID does not return a slot, so this is a countdown rather than
# a capacity.
TRIAL_VERIFICATION_QUOTA = 3
UNVERIFIED_ERROR = 21608
def e164(value):
"""Reduce a phone number to a comparable form.
The verified caller ID list and the message list are populated by different
paths and are not always formatted the same way, so "+1 (415) 555-0100" and
"+14155550100" both turn up for the same phone. Comparing the raw strings
reports verified numbers as unverified, which is the one mistake that makes
this report worse than not running it.
"""
digits = "".join(c for c in str(value or "") if c.isdigit())
return ("+" + digits) if digits else ""
def verdict(account, caller_ids, destinations):
"""Classify the verified-number pool against the traffic. Pure, so every
state can be exercised without a network.
Returns (state, detail).
"""
kind = str(account.get("type") or "").strip().lower()
if kind and kind != "trial":
return ("not-trial",
"type is %s: the verified caller ID list no longer gates "
"messaging." % (account.get("type") or kind))
verified = {e164(c.get("phone_number")) for c in caller_ids}
verified.discard("")
wanted = {e164(d) for d in destinations}
wanted.discard("")
missing = sorted(wanted - verified)
left = TRIAL_VERIFICATION_QUOTA - len(verified)
if len(verified) >= TRIAL_VERIFICATION_QUOTA:
return ("spent",
"%d verified number(s) on a trial account: the lifetime quota of "
"%d is spent, and deleting one does not return a slot. %d "
"destination(s) in the window cannot be reached and get %d."
% (len(verified), TRIAL_VERIFICATION_QUOTA, len(missing),
UNVERIFIED_ERROR))
if missing:
return ("unverified",
"%d destination(s) in the window are not verified and get %d. "
"%d slot(s) left, and they are the last %d this account will "
"ever have."
% (len(missing), UNVERIFIED_ERROR, left, left))
return ("ok",
"%d verified number(s), every destination in the window covered, %d "
"slot(s) left for the lifetime of the account."
% (len(verified), left))
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 paged(session, url, field, params, limit):
"""Walk a 2010-04-01 list resource. next_page_uri is a path, not a URL."""
out = []
while url and len(out) < limit:
page = get(session, url, **params)
out.extend(page.get(field, []))
nxt = page.get("next_page_uri")
url, params = (HOST + nxt) if nxt else None, {}
return out[:limit]
def destinations_used(messages):
"""Distinct outbound destinations in the window, plus the ones already
refused for being unverified."""
used, refused = set(), set()
for m in messages:
if str(m.get("direction") or "outbound").startswith("inbound"):
continue
to = str(m.get("to") or "").strip()
if not to:
continue
used.add(to)
if str(m.get("error_code") or "").strip() == str(UNVERIFIED_ERROR):
refused.add(to)
return used, refused
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="how far back to read destinations from the Messages list")
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)
acct = get(session, "%s/Accounts/%s.json" % (BASE, account))
caller_ids = paged(session,
"%s/Accounts/%s/OutgoingCallerIds.json" % (BASE, account),
"outgoing_caller_ids", {"PageSize": 50}, 200)
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
messages = paged(session, "%s/Accounts/%s/Messages.json" % (BASE, account),
"messages", {"PageSize": 1000, "DateSent>=": since},
args.max_messages)
used, refused = destinations_used(messages)
state, detail = verdict(acct, caller_ids, used)
log.info("verified: %s", ", ".join(sorted(
str(c.get("phone_number")) for c in caller_ids)) or "none")
line = "%-11s %s" % (state, detail)
if state in ("not-trial", "ok"):
log.info(line)
return 0
log.warning(line)
for number in sorted(refused):
log.warning(" %s already failed with %d in this window",
number, UNVERIFIED_ERROR)
log.warning(" repair: Console -> Billing -> Upgrade. That removes the "
"verified-number restriction entirely. Do not delete caller IDs "
"to free slots: the quota is not restored.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report whether a Twilio trial account has spent its verified-number quota.
*
* 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`;
// Verifications a trial account gets over its entire lifetime. Deleting a
// verified caller ID does not return a slot.
const TRIAL_VERIFICATION_QUOTA = 3;
const UNVERIFIED_ERROR = 21608;
/**
* Reduce a phone number to a comparable form. The two lists this script joins
* are not always formatted the same way, and comparing raw strings reports
* verified numbers as unverified.
*/
export function e164(value) {
const digits = String(value ?? '').replace(/\D/g, '');
return digits ? `+${digits}` : '';
}
/**
* Classify the verified-number pool against the traffic. Pure, so every state
* can be exercised without a network. Returns [state, detail].
*/
export function verdict(account, callerIds, destinations) {
const kind = String(account.type ?? '').trim().toLowerCase();
if (kind && kind !== 'trial') {
return ['not-trial',
`type is ${account.type ?? kind}: the verified caller ID list no longer ` +
'gates messaging.'];
}
const verified = new Set(callerIds.map((c) => e164(c.phone_number)).filter(Boolean));
const wanted = new Set([...destinations].map(e164).filter(Boolean));
const missing = [...wanted].filter((n) => !verified.has(n)).sort();
const left = TRIAL_VERIFICATION_QUOTA - verified.size;
if (verified.size >= TRIAL_VERIFICATION_QUOTA) {
return ['spent',
`${verified.size} verified number(s) on a trial account: the lifetime ` +
`quota of ${TRIAL_VERIFICATION_QUOTA} is spent, and deleting one does ` +
`not return a slot. ${missing.length} destination(s) in the window ` +
`cannot be reached and get ${UNVERIFIED_ERROR}.`];
}
if (missing.length) {
return ['unverified',
`${missing.length} destination(s) in the window are not verified and get ` +
`${UNVERIFIED_ERROR}. ${left} slot(s) left, and they are the last ` +
`${left} this account will ever have.`];
}
return ['ok',
`${verified.size} verified number(s), every destination in the window ` +
`covered, ${left} slot(s) left for the lifetime of the account.`];
}
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 paged(auth, url, field, params, limit) {
const out = [];
let next = url;
let query = params;
while (next && out.length < limit) {
const page = await get(auth, next, query);
out.push(...(page[field] ?? []));
next = page.next_page_uri ? HOST + page.next_page_uri : null;
query = {};
}
return out.slice(0, limit);
}
/** Distinct outbound destinations, plus those already refused as unverified. */
export function destinationsUsed(messages) {
const used = new Set();
const refused = new Set();
for (const m of messages) {
if (String(m.direction ?? 'outbound').startsWith('inbound')) continue;
const to = String(m.to ?? '').trim();
if (!to) continue;
used.add(to);
if (String(m.error_code ?? '').trim() === String(UNVERIFIED_ERROR)) refused.add(to);
}
return { used, refused };
}
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 acct = await get(auth, `${BASE}/Accounts/${account}.json`);
const callerIds = await paged(
auth, `${BASE}/Accounts/${account}/OutgoingCallerIds.json`,
'outgoing_caller_ids', { PageSize: 50 }, 200);
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const messages = await paged(
auth, `${BASE}/Accounts/${account}/Messages.json`, 'messages',
{ PageSize: 1000, 'DateSent>=': since }, 20000);
const { used, refused } = destinationsUsed(messages);
const [state, detail] = verdict(acct, callerIds, used);
const list = callerIds.map((c) => String(c.phone_number)).sort().join(', ');
console.log(`verified: ${list || 'none'}`);
const line = `${state.padEnd(11)} ${detail}`;
if (state === 'not-trial' || state === 'ok') {
console.log(line);
return;
}
console.warn(line);
for (const number of [...refused].sort()) {
console.warn(` ${number} already failed with ${UNVERIFIED_ERROR} in this window`);
}
console.warn(' repair: Console -> Billing -> Upgrade. That removes the ' +
'verified-number restriction entirely. Do not delete caller IDs ' +
'to free slots: the quota is not restored.');
process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing credentials and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The formatting test is the one that earns its place: the two lists being joined come from different resources and arrive punctuated differently, so a report that compares raw strings will tell you a verified number is unverified and send somebody to burn a slot they do not have. The rest pin the ordering, in particular that a full pool outranks a tidy-looking destination match.
from twilio_verified_caller_ids_audit import destinations_used, e164, verdict
TRIAL = {"sid": "AC1", "type": "Trial"}
FULL = {"sid": "AC1", "type": "Full"}
def cid(number):
return {"phone_number": number}
def test_an_upgraded_account_is_not_gated_by_the_list():
state, detail = verdict(FULL, [cid("+14155550100")], {"+14155550999"})
assert state == "not-trial"
assert "no longer gates" in detail
def test_three_verified_numbers_is_the_lifetime_quota():
state, detail = verdict(
TRIAL, [cid("+14155550100"), cid("+14155550101"), cid("+14155550102")],
{"+14155550100", "+14155550999"})
assert state == "spent"
assert "does not return a slot" in detail
def test_an_unverified_destination_with_slots_left_says_how_many_remain():
state, detail = verdict(TRIAL, [cid("+14155550100")], {"+14155550999"})
assert state == "unverified"
assert "2 slot(s) left" in detail
def test_formatting_differences_are_not_reported_as_unverified():
state, _ = verdict(TRIAL, [cid("+1 (415) 555-0100")], {"+14155550100"})
assert state == "ok"
def test_everything_covered_and_slots_left_passes():
state, detail = verdict(TRIAL, [cid("+14155550100")], {"+14155550100"})
assert state == "ok"
assert "2 slot(s) left" in detail
def test_e164_keeps_only_digits():
assert e164("+1 (415) 555-0100") == "+14155550100"
assert e164("") == ""
assert e164(None) == ""
def test_inbound_rows_are_not_destinations_and_21608s_are_collected():
used, refused = destinations_used([
{"direction": "inbound", "to": "+14155550100"},
{"direction": "outbound-api", "to": "+14155550101"},
{"direction": "outbound-api", "to": "+14155550999", "error_code": "21608"},
])
assert used == {"+14155550101", "+14155550999"}
assert refused == {"+14155550999"}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { destinationsUsed, e164, verdict } from './twilio-verified-caller-ids-audit.mjs';
const TRIAL = { sid: 'AC1', type: 'Trial' };
const FULL = { sid: 'AC1', type: 'Full' };
const cid = (phone_number) => ({ phone_number });
test('an upgraded account is not gated by the list', () => {
const [state, detail] = verdict(FULL, [cid('+14155550100')], new Set(['+14155550999']));
assert.equal(state, 'not-trial');
assert.match(detail, /no longer gates/);
});
test('three verified numbers is the lifetime quota', () => {
const [state, detail] = verdict(
TRIAL, [cid('+14155550100'), cid('+14155550101'), cid('+14155550102')],
new Set(['+14155550100', '+14155550999']));
assert.equal(state, 'spent');
assert.match(detail, /does not return a slot/);
});
test('an unverified destination with slots left says how many remain', () => {
const [state, detail] = verdict(TRIAL, [cid('+14155550100')], new Set(['+14155550999']));
assert.equal(state, 'unverified');
assert.match(detail, /2 slot\(s\) left/);
});
test('formatting differences are not reported as unverified', () => {
const [state] = verdict(TRIAL, [cid('+1 (415) 555-0100')], new Set(['+14155550100']));
assert.equal(state, 'ok');
});
test('everything covered and slots left passes', () => {
const [state, detail] = verdict(TRIAL, [cid('+14155550100')], new Set(['+14155550100']));
assert.equal(state, 'ok');
assert.match(detail, /2 slot\(s\) left/);
});
test('e164 keeps only digits', () => {
assert.equal(e164('+1 (415) 555-0100'), '+14155550100');
assert.equal(e164(''), '');
assert.equal(e164(null), '');
});
test('inbound rows are not destinations and 21608s are collected', () => {
const { used, refused } = destinationsUsed([
{ direction: 'inbound', to: '+14155550100' },
{ direction: 'outbound-api', to: '+14155550101' },
{ direction: 'outbound-api', to: '+14155550999', error_code: '21608' },
]);
assert.deepEqual([...used].sort(), ['+14155550101', '+14155550999']);
assert.deepEqual([...refused], ['+14155550999']);
});
FAQ
Can I delete a verified caller ID to free a slot?
No. The quota counts verifications performed over the account's lifetime, not entries currently in the list, so deleting one loses you a working verification and gains you nothing. This is the single most common wrong move on this problem, which is why the script says it in the repair line rather than leaving it implied.
Why does the error blame the destination number?
Because that is what 21608 describes: the To number is not verified for this account. The wording is accurate and the framing is misleading, since the reason it cannot be verified is an account-level quota rather than anything about the number. That mismatch is why the investigation usually starts on the recipient's handset.
Does this apply to voice calls too?
Yes. A trial account can only call verified numbers as well as message them, and it is the same list. The list also does a second, unrelated job on paid accounts, where it holds the external numbers you are allowed to present as caller ID, which is why finding entries there on a Full account means nothing about messaging.
What if the number I need to add cannot receive SMS?
Then it cannot be verified on a trial account at all, because trial verification is SMS-only. A landline or an SMS-incapable VoIP number needs the account upgraded first. It is worth checking this before spending one of the remaining slots on an attempt that cannot succeed.
Is running out of slots really the problem, or is being on trial the problem?
Being on trial is the problem; this is where it first bites. Managing three slots carefully is effort spent on a constraint that disappears the moment a payment method is added, so treat a spent pool as a prompt to upgrade rather than as a puzzle about which tester matters most.
Related field notes
- A production integration still on a trial account
- A trial account rejecting multi-segment messages
- An outbound call rejected for an invalid caller ID
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.
- OutgoingCallerId resource — Twilio Docs
- Error 21608: unverified number on a trial account — Twilio Docs
- Account resource — Twilio Docs
- Message resource — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.