Diagnostic Twilio
a production integration is still running on a trial account
The integration works. It has worked for weeks, on the developer's phone and on the tester's phone, and every message arrived. Then it goes in front of real users and most of them get nothing at all, while the few that do get a message find it starts with the words Sent from your Twilio trial account. Nobody chose this. The account was made for a spike, the spike became the product, and no one step in that sequence was the moment to upgrade.
Read GET /2010-04-01/Accounts/{AccountSid}.json and check type. Trial means the account can only message numbers that appear in its verified caller IDs — at most three, for the lifetime of the account — and prefixes every outbound body.
Being on trial is not itself a fault; a development account should be. What makes it a finding is traffic. Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD, count the distinct to values, and count the rows whose error_code is 21608. More than three destinations, or any 21608 at all, and this is a production integration on a trial account.
The problem in plain words
A trial account is not a smaller version of a paid one. It is a different set of rules, and the rules are ones that testing does not exercise. You may only send to numbers you have verified; the verification is by SMS only; you get three verified numbers for the whole life of the account. During development you have one phone, maybe two, and both are verified within the first hour. Every constraint the trial imposes is satisfied by accident, and stays satisfied right up until somebody who is not you is meant to receive a message.
The failure at that point is not subtle, but it is misattributed almost every time. Sends to unverified numbers come back 21608, which reads like a problem with the destination rather than with the account, so the investigation starts on the recipient's phone, their carrier, their opt-out status. Meanwhile the messages that do get through carry a Twilio-branded prefix into a customer conversation, which is a different and more expensive kind of wrong.
The reason this survives to launch is that nothing about the trial announces itself in the shape of the code. The same SDK, the same credentials, the same endpoints, the same 201 responses. One field on one resource says Trial, and nothing reads it.
Why it happens
Nothing forces the upgrade at the right moment. You upgrade by adding a payment method in the Console, which is a billing decision made by a person who is usually not the person writing the integration. Between the two of them is a gap that a working staging environment fits comfortably inside.
The verified-number rule reads like a rate limit and is not one. Three is a lifetime cap on the account, not a concurrent one, and deleting a verified caller ID does not return the slot. That specific dead end has its own note, because the way it usually surfaces is one teammate's phone failing while the original developer's still works.
The other trial limits fire at different times. The message-length cap rejects real templates with 30044 while short test messages sail through, which is a separate failure with a separate error code. Finding one trial limit does not mean you have found them all; finding type: Trial means you have.
The prefix is a product problem hiding in an infrastructure setting. Every outbound message on a trial account is prepended with Twilio's trial notice. Nothing in your code did that, nothing in your code can stop it, and it is going out over your brand in front of your customers.
Upgrading does not automatically end every 21608. The verified-number restriction goes away with the upgrade. If 21608 continues on an upgraded account, the remaining path is a Primary Compliance Profile under Trust Hub, which is a form rather than a code change and is worth knowing about before you are staring at it during a launch.
The fix, as a flow
The script counts distinct destinations rather than messages, because volume can be one tester pressing a button while four destinations on a trial account is already more than it can ever reach.
How to fix it
Read the account type
GET /2010-04-01/Accounts/{AccountSid}.json and look at type. This is one field and it is decisive. Do it for every account SID your services use, including the subaccounts, because a parent that has been upgraded says nothing about a child created before the payment method was added.
Measure how much real traffic is pointed at it
Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri, and collect the distinct to values on outbound rows. A trial account cannot ever have more than three verified destinations, so a distinct count above three is proof that the integration is aimed at people it cannot reach.
Count the 21608s rather than assuming them
The list has no ErrorCode filter, so read error_code on each row and keep the 21608s. These are the sends that were refused for being unverified. One of them is a tester; a run of them is a launch that is already failing.
Upgrade in the Console, then re-read type
Console → Billing → Upgrade, which means adding a payment method. There is no API call for this and there should not be. The confirmation is that type reads Full on the next fetch, and the same run should show new destinations landing without 21608.
Keep the check, and point it at every account
The useful version of this runs against the whole list of account SIDs your platform touches, on a schedule. New subaccounts, new environments and new demo accounts arrive as trials, and the one that matters is always the one somebody quietly started sending real traffic through.
How to check it worked
Re-run the script after upgrading. type should read Full, and the run should exit zero regardless of how many destinations it counts.
python3 twilio_trial_account_audit.py --days 7
# upgraded AC0123 type is Full: no verified-number restriction and no trial prefix.
The full code
One GET for the account and one paginated GET over the message window. The classifier is pure and takes the account dict plus the message rows, because the judgement worth testing is the one about traffic: trial with three destinations and nothing failing is a development account doing its job, and trial with forty is an outage that has not been reported yet.
"""Report whether production traffic is running on a Twilio trial account.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. Upgrading is a Console step behind a payment
method, so the repair 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_trial_account_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
# A trial account may verify three numbers over its entire lifetime, and may only
# message numbers on that list. More distinct destinations than this is proof the
# integration is aimed at people the account cannot reach.
TRIAL_VERIFIED_CAP = 3
# Sending to a number that is not a verified caller ID on a trial account.
UNVERIFIED_ERROR = 21608
def outbound_profile(messages):
"""Reduce message rows to the two numbers the verdict needs.
Pure and separate from the verdict so the reduction can be tested on its
own: which destinations were attempted, and how many sends were refused for
being unverified.
"""
destinations = set()
refused = 0
for m in messages:
if str(m.get("direction") or "outbound").startswith("inbound"):
continue
to = str(m.get("to") or "").strip()
if to:
destinations.add(to)
if str(m.get("error_code") or "").strip() == str(UNVERIFIED_ERROR):
refused += 1
return destinations, refused
def verdict(account, destinations, refused=0, days=7):
"""Classify one account against the traffic aimed at it. Pure, so all four
states can be exercised without a network.
Returns (state, detail). Being on trial is not the finding on its own: a
development account should be on trial. Traffic is the finding.
"""
kind = str(account.get("type") or "").strip().lower()
if not kind:
return ("unknown",
"the Account resource carried no type field, so whether this is "
"a trial account is not established. Fetch it again.")
if kind != "trial":
return ("upgraded",
"type is %s: no verified-number restriction and no trial prefix."
% (account.get("type") or kind))
if refused:
return ("trial-blocked",
"type is Trial and %d send(s) in the last %d days were refused "
"with %d. Those recipients got nothing, and the ones that did "
"get through carried Twilio's trial prefix."
% (refused, days, UNVERIFIED_ERROR))
if len(destinations) > TRIAL_VERIFIED_CAP:
return ("trial-in-production",
"type is Trial with %d distinct destination(s) in the last %d "
"days. A trial account can verify %d numbers for its entire "
"lifetime, so most of these can never be delivered to."
% (len(destinations), days, TRIAL_VERIFIED_CAP))
return ("trial-idle",
"type is Trial with %d distinct destination(s) in the last %d days: "
"consistent with a development account. Upgrade before it sees real "
"recipients, not after." % (len(destinations), days))
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 ErrorCode filter exists on this resource, so the
21608s are found by reading error_code on every row in the window."""
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=7,
help="how far back to read 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))
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
messages = list_messages(session, account, since, args.max_messages)
destinations, refused = outbound_profile(messages)
state, detail = verdict(acct, destinations, refused, args.days)
line = "%-20s %s %s" % (state, acct.get("sid", "?"), detail)
if state == "upgraded":
log.info(line)
return 0
log.warning(line)
if state == "trial-idle":
log.warning(" repair: Console -> Billing -> Upgrade before launch. "
"There is no API call for this, by design.")
return 1
log.warning(" repair: Console -> Billing -> Upgrade (add a payment "
"method). That removes the verified-number restriction and the "
"trial prefix on every outbound body.")
log.warning(" if 21608 continues after upgrading, submit a Primary "
"Compliance Profile under Console -> Compliance -> Trust Hub.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report whether production traffic is running on a Twilio trial account.
*
* 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`;
// A trial account may verify three numbers over its entire lifetime, and may
// only message numbers on that list.
const TRIAL_VERIFIED_CAP = 3;
// Sending to a number that is not a verified caller ID on a trial account.
const UNVERIFIED_ERROR = 21608;
/**
* Reduce message rows to the two numbers the verdict needs: which destinations
* were attempted, and how many sends were refused for being unverified.
*/
export function outboundProfile(messages) {
const destinations = new Set();
let refused = 0;
for (const m of messages) {
if (String(m.direction ?? 'outbound').startsWith('inbound')) continue;
const to = String(m.to ?? '').trim();
if (to) destinations.add(to);
if (String(m.error_code ?? '').trim() === String(UNVERIFIED_ERROR)) refused += 1;
}
return { destinations, refused };
}
/**
* Classify one account against the traffic aimed at it. Pure, so all four
* states can be exercised without a network. Returns [state, detail].
*/
export function verdict(account, destinations, refused = 0, days = 7) {
const kind = String(account.type ?? '').trim().toLowerCase();
const count = destinations instanceof Set ? destinations.size : destinations.length;
if (!kind) {
return ['unknown',
'the Account resource carried no type field, so whether this is a trial ' +
'account is not established. Fetch it again.'];
}
if (kind !== 'trial') {
return ['upgraded',
`type is ${account.type ?? kind}: no verified-number restriction and no ` +
'trial prefix.'];
}
if (refused) {
return ['trial-blocked',
`type is Trial and ${refused} send(s) in the last ${days} days were ` +
`refused with ${UNVERIFIED_ERROR}. Those recipients got nothing, and the ` +
"ones that did get through carried Twilio's trial prefix."];
}
if (count > TRIAL_VERIFIED_CAP) {
return ['trial-in-production',
`type is Trial with ${count} distinct destination(s) in the last ${days} ` +
`days. A trial account can verify ${TRIAL_VERIFIED_CAP} numbers for its ` +
'entire lifetime, so most of these can never be delivered to.'];
}
return ['trial-idle',
`type is Trial with ${count} distinct destination(s) in the last ${days} ` +
'days: consistent with a development account. Upgrade before it sees real ' +
'recipients, not after.'];
}
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] : 7) || 7;
const acct = await get(auth, `${BASE}/Accounts/${account}.json`);
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
const messages = await listMessages(auth, account, since);
const { destinations, refused } = outboundProfile(messages);
const [state, detail] = verdict(acct, destinations, refused, days);
const line = `${state.padEnd(20)} ${acct.sid ?? '?'} ${detail}`;
if (state === 'upgraded') {
console.log(line);
return;
}
console.warn(line);
if (state === 'trial-idle') {
console.warn(' repair: Console -> Billing -> Upgrade before launch. There ' +
'is no API call for this, by design.');
} else {
console.warn(' repair: Console -> Billing -> Upgrade (add a payment ' +
'method). That removes the verified-number restriction and the ' +
'trial prefix on every outbound body.');
console.warn(' if 21608 continues after upgrading, submit a Primary ' +
'Compliance Profile under Console -> Compliance -> Trust Hub.');
}
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 distinction being pinned is the one the whole note rests on: trial is not automatically a finding. A trial account with two destinations and no failures is a development account, and reporting it as an incident trains people to ignore the report. The other cases are a single 21608 outranking a healthy-looking destination count, and inbound rows not inflating that count.
from twilio_trial_account_audit import outbound_profile, verdict
TRIAL = {"sid": "AC1", "type": "Trial"}
FULL = {"sid": "AC1", "type": "Full"}
def test_a_full_account_is_never_a_finding():
state, detail = verdict(FULL, {"+14155550100"})
assert state == "upgraded"
assert "prefix" in detail
def test_trial_with_a_handful_of_testers_is_not_reported_as_an_incident():
state, _ = verdict(TRIAL, {"+14155550100", "+14155550101"})
assert state == "trial-idle"
def test_more_destinations_than_the_lifetime_cap_is_production_traffic():
dests = {"+1415555010%d" % i for i in range(6)}
state, detail = verdict(TRIAL, dests, days=7)
assert state == "trial-in-production"
assert "6 distinct" in detail
def test_one_21608_outranks_a_small_destination_count():
state, detail = verdict(TRIAL, {"+14155550100"}, refused=1)
assert state == "trial-blocked"
assert "21608" in detail
def test_a_missing_type_field_is_not_read_as_upgraded():
assert verdict({"sid": "AC1"}, set())[0] == "unknown"
def test_type_is_compared_case_insensitively():
assert verdict({"sid": "AC1", "type": "trial"}, set())[0] == "trial-idle"
def test_inbound_rows_do_not_count_as_destinations():
dests, refused = outbound_profile([
{"direction": "inbound", "to": "+14155550100"},
{"direction": "outbound-api", "to": "+14155550101"},
{"direction": "outbound-api", "to": "+14155550102", "error_code": 21608},
])
assert dests == {"+14155550101", "+14155550102"}
assert refused == 1
def test_error_codes_are_compared_as_strings_or_integers():
_, refused = outbound_profile([
{"to": "+1", "error_code": "21608"},
{"to": "+2", "error_code": 21608},
{"to": "+3", "error_code": 30044},
])
assert refused == 2
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { outboundProfile, verdict } from './twilio-trial-account-audit.mjs';
const TRIAL = { sid: 'AC1', type: 'Trial' };
const FULL = { sid: 'AC1', type: 'Full' };
test('a full account is never a finding', () => {
const [state, detail] = verdict(FULL, new Set(['+14155550100']));
assert.equal(state, 'upgraded');
assert.match(detail, /prefix/);
});
test('trial with a handful of testers is not reported as an incident', () => {
const dests = new Set(['+14155550100', '+14155550101']);
assert.equal(verdict(TRIAL, dests)[0], 'trial-idle');
});
test('more destinations than the lifetime cap is production traffic', () => {
const dests = new Set(Array.from({ length: 6 }, (_, i) => `+1415555010${i}`));
const [state, detail] = verdict(TRIAL, dests, 0, 7);
assert.equal(state, 'trial-in-production');
assert.match(detail, /6 distinct/);
});
test('one 21608 outranks a small destination count', () => {
const [state, detail] = verdict(TRIAL, new Set(['+14155550100']), 1);
assert.equal(state, 'trial-blocked');
assert.match(detail, /21608/);
});
test('a missing type field is not read as upgraded', () => {
assert.equal(verdict({ sid: 'AC1' }, new Set())[0], 'unknown');
});
test('type is compared case insensitively', () => {
assert.equal(verdict({ sid: 'AC1', type: 'trial' }, new Set())[0], 'trial-idle');
});
test('inbound rows do not count as destinations', () => {
const { destinations, refused } = outboundProfile([
{ direction: 'inbound', to: '+14155550100' },
{ direction: 'outbound-api', to: '+14155550101' },
{ direction: 'outbound-api', to: '+14155550102', error_code: 21608 },
]);
assert.deepEqual([...destinations].sort(), ['+14155550101', '+14155550102']);
assert.equal(refused, 1);
});
test('error codes are compared as strings or integers', () => {
const { refused } = outboundProfile([
{ to: '+1', error_code: '21608' },
{ to: '+2', error_code: 21608 },
{ to: '+3', error_code: 30044 },
]);
assert.equal(refused, 2);
});
FAQ
Is being on a trial account a problem by itself?
No, and a report that says so is a report people learn to close. A development account should be on trial: it costs nothing and it cannot spend. The finding is trial plus traffic, which is why the script counts distinct destinations and 21608s before it decides how loudly to complain.
Why does the distinct-destination count matter more than the message count?
Because volume can be one tester hammering a button, and that is fine. A trial account can only ever reach three verified numbers, so more than three distinct destinations means somebody has pointed the integration at a population it cannot serve. That is the shape of a production launch, whatever the total send count says.
What exactly does the trial prefix do to my messages?
Twilio prepends its trial notice to the body of every outbound message on a trial account. You cannot disable it, it consumes characters from the segment budget, and it goes out in front of your customers under your sender. It is the reason this note is not purely about deliverability.
We upgraded and we are still getting 21608. What now?
Upgrading removes the trial verified-number restriction, so a continuing 21608 is a different cause on the same code. The next step is a Primary Compliance Profile under Console then Compliance then Trust Hub. It is paperwork rather than a code change, and it takes time, which is why it is worth starting before launch day rather than during it.
How is this different from the trial message-length note?
That one is a single limit firing: 30044 on a body that exceeds the trial length cap, which is why short test messages send and real templates do not. This one is the account-level state that produces that limit and several others at once. If you find a 30044 on a trial account, fixing the template treats a symptom; reading type tells you how many other symptoms are queued up behind it.
Related field notes
- The trial account's three verified numbers are spent
- A trial account rejecting multi-segment messages
- The account itself is suspended or closed
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.
- Account resource — Twilio Docs
- Error 21608: unverified number on a trial account — Twilio Docs
- Message resource — Twilio Docs
- Error 30044: trial account message length exceeded — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.