Diagnostic Twilio
the account itself is suspended, so every send fails with 20005
Everything stops at once. Messages, calls, number purchases, Verify starts — all of them come back 403 with 20005: Account not active, and the queued backlog dies behind them with 30002. The Console still loads, your dashboard still draws, and the API still answers questions about the account. It just will not do anything for it any more.
Read GET /2010-04-01/Accounts/{AccountSid}.json and look at one field: status. Anything other than active is the whole answer. suspended is recoverable, closed is not.
Then page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD and count rows whose error_code is 30002. That number tells you what the suspension already cost, and it is the only signal left once the status has flipped back to active.
The problem in plain words
A suspension is not a degraded mode. It is a hard stop on everything the account can do, applied between one request and the next, and the first thing most teams see is not an alert but a customer saying they never got their code. The error is 20005 on anything you try to create and 30002 on anything that was already queued, and neither is a code your application is likely to have a branch for, because neither has ever fired before.
What makes it awkward to diagnose from inside the application is that reads keep working. The credential is valid, the account resource comes back, the message list comes back, the phone numbers are all still there. Every health check that asks "can I reach Twilio" answers yes. The one field that says otherwise is not on any of those responses; it is on the account itself, and nothing fetches the account.
The version of this that hurts most is the one you find late. A balance suspension can clear itself minutes after somebody tops up, so by the time anyone investigates, status reads active again and the only evidence left is a block of 30002 rows in the message list with a start and an end.
Why it happens
Balance is the usual cause, and it is a threshold, not a warning. The account spends down, crosses zero, and is suspended. There is no soft landing and no grace period on the way through. Whether you saw it coming depends entirely on whether somebody set a usage trigger, which is its own note, and on how close to the floor the balance was allowed to sit, which is another.
A policy or ToS review is the cause you cannot fix with a card. Content review, a spike that looks like pumping, an unresolved compliance item: these disable the project rather than the payment, and the repair is a ticket rather than a top-up. The API reports the same suspended either way, which is why the script tells you to check the balance before you assume you know which one you have.
closed is terminal and reads almost the same. One word apart in the same field, and completely different consequences: a closed account cannot be reopened, so the numbers on it are gone and the work is standing up a new account and porting what can be ported. A report that lumps closed in with suspended is telling you to go and add funds to something that will never take them.
Suspension cascades downward, and this note only looks upward. A suspended parent takes its subaccounts with it. If the SID you are checking is a child, its own status can read active while the parent's does not, so the script says plainly which of the two you are pointed at. Finding the reverse case — one suspended tenant under a healthy parent — is a different read with different credentials.
The fix, as a flow
The script reads one field and then counts what the suspension already cost, because a balance suspension can clear itself within minutes and the block of 30002 rows is the only evidence it leaves behind.
How to fix it
Fetch the account and read status
GET /2010-04-01/Accounts/{AccountSid}.json. The response carries sid, owner_account_sid, friendly_name, status and type. status is the finding. Compare it case-insensitively and treat any value that is not active as a failure, rather than matching only the two you have heard of.
Work out whether you are looking at a parent or a child
owner_account_sid on a top-level account is the account's own sid. On a subaccount it is the parent's. This costs nothing to check and it changes the repair completely: a subaccount's suspension may be its own or may be inherited, and reactivating it is done with the parent's credentials.
Count the 30002s to date the outage
Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. There is no ErrorCode filter on this resource, so read error_code on each row yourself and keep the 30002s. The first and last date_sent among them bound the outage.
Separate a balance suspension from a policy one before you act
If the balance is at or below zero, the repair is Console → Billing → add funds, and reactivation follows within roughly five to ten minutes rather than instantly. If the balance is healthy and the account is still suspended, adding more money changes nothing and you need a ticket at help.twilio.com. Guessing wrong costs you the time it takes to find out.
Re-run it on a schedule, not just during the incident
This is one GET. Running it every few minutes from somewhere that is not the affected account turns a customer report into an alert, and running it after the fact is how you find the block of 30002s that a five-minute suspension left behind three weeks ago.
How to check it worked
Re-run the script. status should read active and the 30002 count over your window should be zero.
python3 twilio_account_status_audit.py --days 7
# active AC0123 status is active, and no message in the last 7 days failed with 30002.
The full code
One GET for the account, and one paginated GET over the message window to date the damage. The classifier is pure and takes the account dict plus the 30002 count, because the interesting decisions — closed outranks suspended, and an active account with 30002s in the window is still a finding — are decisions about two values, not about HTTP.
"""Report whether the Twilio account behind this credential is still active.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. Nothing here can lift a suspension anyway;
the repair is printed for a human to run in the Console.
"""
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_account_status_audit")
HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
# The error stamped on messages that were queued when the account stopped being
# active. Requests made after the suspension are refused outright with 20005 and
# never become a Message row at all, so this is the only one the list can show.
SUSPENDED_ERROR = 30002
def scope(account):
"""Whether this SID is the top-level account or one of its subaccounts.
owner_account_sid on a parent account is that account's own sid; on a
subaccount it is the parent's. The distinction changes the repair, because a
subaccount is reactivated with the parent's credentials and may have been
suspended by the parent rather than on its own account.
"""
sid = str(account.get("sid") or "").strip()
owner = str(account.get("owner_account_sid") or "").strip()
if sid and owner and sid != owner:
return "subaccount"
return "account"
def verdict(account, failed=0, days=7):
"""Classify one Account resource. Pure, so every state can be exercised
without a network.
Returns (state, detail). Order matters: closed is terminal and has to
outrank suspended, and an account that reads active today is still a finding
if the window behind it is full of 30002s.
"""
status = str(account.get("status") or "").strip().lower()
if not status:
return ("unknown",
"the Account resource carried no status field. Do not read that "
"as healthy: fetch it again before deciding anything.")
if status == "closed":
return ("closed",
"status is closed. This is terminal. The account cannot be "
"reopened, its numbers are not coming back, and the work is a "
"new account rather than a payment.")
if status == "suspended":
return ("suspended",
"status is suspended: every send, call and number purchase is "
"refused with 20005, and anything already queued fails with "
"%d. Check the balance before assuming it is a billing "
"suspension." % SUSPENDED_ERROR)
if status != "active":
return ("not-active",
"status is %r, which is not active. Everything the account does "
"is refused with 20005 until it is." % status)
if failed:
return ("recently-suspended",
"status is active now, but %d message(s) in the last %d days "
"failed with %d. The account was not active while those were "
"queued, and nothing recorded when that started or ended except "
"these rows." % (failed, days, SUSPENDED_ERROR))
return ("active",
"status is active, and no message in the last %d days failed with "
"%d." % (days, SUSPENDED_ERROR))
def get(session, url, **params):
r = session.get(url, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Twilio: check TWILIO_ACCOUNT_SID and that the "
"API key belongs to that account with read access")
if r.status_code == 403:
# A 403 on a read is worth reporting rather than swallowing: 20005 here
# means the account is not active and is refusing on its own behalf,
# which is the answer this script was run to get.
raise SystemExit("403 from Twilio at %s. If the body carries 20005 the "
"account is not active, which is this finding." % url)
r.raise_for_status()
return r.json()
def fetch_account(session, account):
return get(session, "%s/Accounts/%s.json" % (BASE, account))
def list_messages(session, account, since, limit):
"""Page Messages.json. The resource has no ErrorCode filter, so the date
window and the page cap are the only levers on how much this reads."""
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 suspended_rows(messages):
"""Message rows stamped with the account-suspended error, oldest first."""
rows = [m for m in messages
if str(m.get("error_code") or "").strip() == str(SUSPENDED_ERROR)]
return sorted(rows, key=lambda m: str(m.get("date_sent") or ""))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=7,
help="how far back to read the Messages list for 30002")
ap.add_argument("--max-messages", type=int, default=20000,
help="stop paging after this many messages")
ap.add_argument("--skip-messages", action="store_true",
help="read the account status only, for a fast health check")
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 = fetch_account(session, account)
failed = []
if not args.skip_messages:
since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
failed = suspended_rows(list_messages(session, account, since,
args.max_messages))
state, detail = verdict(acct, len(failed), args.days)
line = "%-18s %s %s" % (state, acct.get("sid", "?"), detail)
if state == "active":
log.info(line)
return 0
log.warning(line)
if scope(acct) == "subaccount":
log.warning(" this SID is a subaccount of %s. A suspended parent takes "
"its children with it, so read the parent's status too.",
acct.get("owner_account_sid"))
if failed:
log.warning(" first 30002 at %s, last at %s",
failed[0].get("date_sent"), failed[-1].get("date_sent"))
if state == "closed":
log.warning(" repair: none by API or Console. A closed account is not "
"reopened; open a ticket at help.twilio.com to recover what "
"can be recovered, and expect to stand up a new account.")
else:
log.warning(" repair: Console -> Billing. If the balance is at or below "
"zero, add funds and allow five to ten minutes for "
"reactivation. If the balance is healthy, this is a policy "
"review and only a ticket at help.twilio.com clears it.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report whether the Twilio account behind this credential is still active.
*
* 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`;
// The error stamped on messages that were queued when the account stopped being
// active. Requests made after the suspension never become a Message row at all.
const SUSPENDED_ERROR = 30002;
/**
* Whether this SID is the top-level account or one of its subaccounts.
* owner_account_sid on a parent is the account's own sid; on a child it is the
* parent's, and the repair differs.
*/
export function scope(account) {
const sid = String(account.sid ?? '').trim();
const owner = String(account.owner_account_sid ?? '').trim();
return sid && owner && sid !== owner ? 'subaccount' : 'account';
}
/**
* Classify one Account resource. Pure, so every state can be exercised without
* a network. Returns [state, detail].
*/
export function verdict(account, failed = 0, days = 7) {
const status = String(account.status ?? '').trim().toLowerCase();
if (!status) {
return ['unknown',
'the Account resource carried no status field. Do not read that as ' +
'healthy: fetch it again before deciding anything.'];
}
if (status === 'closed') {
return ['closed',
'status is closed. This is terminal. The account cannot be reopened, its ' +
'numbers are not coming back, and the work is a new account rather than ' +
'a payment.'];
}
if (status === 'suspended') {
return ['suspended',
'status is suspended: every send, call and number purchase is refused ' +
`with 20005, and anything already queued fails with ${SUSPENDED_ERROR}. ` +
'Check the balance before assuming it is a billing suspension.'];
}
if (status !== 'active') {
return ['not-active',
`status is "${status}", which is not active. Everything the account does ` +
'is refused with 20005 until it is.'];
}
if (failed) {
return ['recently-suspended',
`status is active now, but ${failed} message(s) in the last ${days} days ` +
`failed with ${SUSPENDED_ERROR}. The account was not active while those ` +
'were queued, and nothing recorded when that started or ended except ' +
'these rows.'];
}
return ['active',
`status is active, and no message in the last ${days} days failed with ` +
`${SUSPENDED_ERROR}.`];
}
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) {
throw new Error('401 from Twilio: check TWILIO_ACCOUNT_SID and that the ' +
'API key belongs to that account with read access');
}
if (res.status === 403) {
throw new Error(`403 from Twilio at ${u.pathname}. If the body carries ` +
'20005 the account is not active, which is this finding.');
}
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);
}
/** Message rows stamped with the account-suspended error, oldest first. */
export function suspendedRows(messages) {
return messages
.filter((m) => String(m.error_code ?? '').trim() === String(SUSPENDED_ERROR))
.sort((a, b) => String(a.date_sent ?? '').localeCompare(String(b.date_sent ?? '')));
}
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`);
let failed = [];
if (!process.argv.includes('--skip-messages')) {
const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
failed = suspendedRows(await listMessages(auth, account, since));
}
const [state, detail] = verdict(acct, failed.length, days);
const line = `${state.padEnd(18)} ${acct.sid ?? '?'} ${detail}`;
if (state === 'active') {
console.log(line);
return;
}
console.warn(line);
if (scope(acct) === 'subaccount') {
console.warn(` this SID is a subaccount of ${acct.owner_account_sid}. A ` +
'suspended parent takes its children with it, so read the ' +
"parent's status too.");
}
if (failed.length) {
console.warn(` first 30002 at ${failed[0].date_sent}, last at ` +
`${failed[failed.length - 1].date_sent}`);
}
if (state === 'closed') {
console.warn(' repair: none by API or Console. A closed account is not ' +
'reopened; open a ticket at help.twilio.com to recover what can ' +
'be recovered, and expect to stand up a new account.');
} else {
console.warn(' repair: Console -> Billing. If the balance is at or below ' +
'zero, add funds and allow five to ten minutes for ' +
'reactivation. If the balance is healthy, this is a policy ' +
'review and only a ticket at help.twilio.com clears it.');
}
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 cases that matter are the two that a naive check gets wrong: closed read as just another kind of suspended, and an account reading active while the window behind it is full of 30002. The parent/child test is here too, because owner_account_sid equalling sid is the only thing separating a top-level account from a tenant.
from twilio_account_status_audit import scope, suspended_rows, verdict
def test_suspended_status_is_the_finding():
state, detail = verdict({"sid": "AC1", "status": "suspended"})
assert state == "suspended"
assert "20005" in detail
def test_closed_outranks_suspended_and_says_it_is_terminal():
state, detail = verdict({"sid": "AC1", "status": "closed"})
assert state == "closed"
assert "terminal" in detail
def test_status_is_compared_case_insensitively():
assert verdict({"sid": "AC1", "status": "Suspended"})[0] == "suspended"
def test_an_unfamiliar_status_is_not_read_as_healthy():
state, _ = verdict({"sid": "AC1", "status": "pending-closure"})
assert state == "not-active"
def test_a_missing_status_field_is_not_read_as_healthy():
assert verdict({"sid": "AC1"})[0] == "unknown"
def test_active_with_30002_in_the_window_is_still_a_finding():
state, detail = verdict({"sid": "AC1", "status": "active"}, failed=41, days=7)
assert state == "recently-suspended"
assert "41" in detail
def test_active_and_clean_passes():
assert verdict({"sid": "AC1", "status": "active"}, failed=0)[0] == "active"
def test_owner_account_sid_separates_a_parent_from_a_tenant():
assert scope({"sid": "AC1", "owner_account_sid": "AC1"}) == "account"
assert scope({"sid": "AC2", "owner_account_sid": "AC1"}) == "subaccount"
def test_suspended_rows_filters_by_error_code_and_sorts_oldest_first():
rows = suspended_rows([
{"error_code": 30002, "date_sent": "2024-05-02"},
{"error_code": 30007, "date_sent": "2024-05-01"},
{"error_code": "30002", "date_sent": "2024-05-01"},
{"error_code": None, "date_sent": "2024-05-03"},
])
assert [r["date_sent"] for r in rows] == ["2024-05-01", "2024-05-02"]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scope, suspendedRows, verdict } from './twilio-account-status-audit.mjs';
test('suspended status is the finding', () => {
const [state, detail] = verdict({ sid: 'AC1', status: 'suspended' });
assert.equal(state, 'suspended');
assert.match(detail, /20005/);
});
test('closed outranks suspended and says it is terminal', () => {
const [state, detail] = verdict({ sid: 'AC1', status: 'closed' });
assert.equal(state, 'closed');
assert.match(detail, /terminal/);
});
test('status is compared case insensitively', () => {
assert.equal(verdict({ sid: 'AC1', status: 'Suspended' })[0], 'suspended');
});
test('an unfamiliar status is not read as healthy', () => {
assert.equal(verdict({ sid: 'AC1', status: 'pending-closure' })[0], 'not-active');
});
test('a missing status field is not read as healthy', () => {
assert.equal(verdict({ sid: 'AC1' })[0], 'unknown');
});
test('active with 30002 in the window is still a finding', () => {
const [state, detail] = verdict({ sid: 'AC1', status: 'active' }, 41, 7);
assert.equal(state, 'recently-suspended');
assert.match(detail, /41/);
});
test('active and clean passes', () => {
assert.equal(verdict({ sid: 'AC1', status: 'active' }, 0)[0], 'active');
});
test('owner_account_sid separates a parent from a tenant', () => {
assert.equal(scope({ sid: 'AC1', owner_account_sid: 'AC1' }), 'account');
assert.equal(scope({ sid: 'AC2', owner_account_sid: 'AC1' }), 'subaccount');
});
test('suspendedRows filters by error code and sorts oldest first', () => {
const rows = suspendedRows([
{ error_code: 30002, date_sent: '2024-05-02' },
{ error_code: 30007, date_sent: '2024-05-01' },
{ error_code: '30002', date_sent: '2024-05-01' },
{ error_code: null, date_sent: '2024-05-03' },
]);
assert.deepEqual(rows.map((r) => r.date_sent), ['2024-05-01', '2024-05-02']);
});
FAQ
Why does the API still answer if the account is suspended?
Because reads are not the thing being suspended. The Console loads, the account resource returns, the message list returns. What stops is creating anything: messages, calls, number purchases and Verify starts all come back 403 with 20005. That asymmetry is exactly why a health check that only proves it can reach Twilio will report a suspended account as fine.
What is the difference between 20005 and 30002 here?
20005 is the request being refused at the door, so no Message row is created and nothing appears in the message list. 30002 is stamped on messages that were already accepted and queued when the account stopped being active. Only the second one leaves a trace you can read afterwards, which is why the script counts those rather than trying to find the requests that were rejected.
Can the script reactivate the account?
No, and there is no read-only way to do it either. A balance suspension clears when funds are added in the Console and reactivation follows within about five to ten minutes; a policy suspension clears only through a ticket. Both are deliberate human steps, and neither is something a credential in a cron job should be able to do.
Is closed really unrecoverable?
Yes. A closed account is not reopened, which is why the script gives it a separate state rather than folding it into suspended. The practical consequences are the numbers: they are released rather than held, so the recovery plan is a new account and, where the numbers matter, a conversation with support before anything else is decided.
My subaccount reads active but nothing works. Why?
Because a suspension on the parent cascades to its children without changing what the child's own status field says. If owner_account_sid on the account you fetched is not equal to its own sid, you are looking at a child, and the next thing to read is the parent's status with the parent's credentials. The reverse case, one suspended child under a healthy parent, is a separate note.
Related field notes
- One suspended tenant under a healthy parent
- A balance one busy hour from a suspension
- No spend alarm on an account that can spend
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 20005: account not active — Twilio Docs
- Error 30002: account suspended — Twilio Docs
- Subaccounts — Twilio Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.