Diagnostic Twilio
60203: a resend button with no cooldown burns five sends
SMS took eleven seconds to arrive, so the user pressed Resend. Then again. Then once more, because the first three had not landed yet either — they had, all four of them, in a burst, several seconds after the fourth press. Now 60203 comes back, the button does nothing, and you have paid for four messages to deliver one code.
Read GET https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid} and look at send_code_attempts. It is an array of {channel, time, attempt_sid}, one entry per send. Five entries means the budget is gone and the next resend returns 60203; four while status is still pending means one tap away.
The count is the symptom. The spacing is the diagnosis: take the smallest gap between consecutive time values, and anything under about thirty seconds is not a person deciding the message has not arrived. It is a button with no cooldown, or a retry wrapper treating a slow send as a failed one.
The problem in plain words
Every send in that burst succeeded. Twilio accepted each one, a carrier delivered each one, and each one is on the invoice. The only failure in the whole sequence is the fifth press, and by then the money is spent and the user is locked out of the flow they were trying to complete. This is the rare Verify problem where the error at the end is the cheapest part of the incident.
The reason it survives is that a resend button is trivially correct in testing. One tester, one tap, one code, works. It only misbehaves under the condition that produces real resends — SMS latency of five to twenty seconds, which no local test reproduces and no staging environment has. The behaviour that breaks is impatience, and impatience is not in the test plan.
The second producer is worse because nobody pressed anything: a server-side retry around the start call. Verify accepts the request, the response is slow, the HTTP client times out at three seconds, the wrapper retries, and Verify sends a second code for the same verification. Three retries and the budget is nearly gone before the first message has even been delivered.
Why it happens
The send budget and the check budget are different limits with similar names. Five sends before 60203, five checks before 60202. They live on the same verification, they run down independently, and confusing them sends you looking at the wrong half of the client. The send count is visible in send_code_attempts; the check count is not exposed at all.
The budget clears on a successful check, not on time. That inverts the intuition. A user who never enters a code carries the exhausted budget for the whole ten-minute lifetime, which is exactly the user who is pressing Resend. The people who trip this are the people it takes longest to release.
The spacing separates a person from a loop, and only the spacing does. Four sends is four sends whether they arrived over two minutes or over four seconds. The first is a user in a bad coverage area, which is a product problem. The second is code firing on its own, which is a bug with a line number. The count alone cannot tell you which you have.
Every attempt is billed at international OTP rates. A resend loop is not just a broken screen, it is a multiplier on the most expensive message you send. Two sends per verification across a signup funnel is the entire cost of the funnel, twice, for no additional verified users.
The channel column hides a second cost. An escalation from sms to call is a legitimate design, and it still spends from the same five. A flow that auto-escalates after two failed SMS deliveries has a smaller budget than its author thinks, and reads as normal in every dashboard.
The fix, as a flow
The script measures the smallest gap between sends, not just how many there were: four over three minutes is a person in bad coverage, four over six seconds is code firing on its own.
How to fix it
Pick the verifications to look at
GET https://verify.twilio.com/v2/Attempts?VerifyServiceSid={VA...}&DateCreatedAfter={ISO8601} lists sends, and each carries a verification_sid. A verification SID appearing several times in that list is already a candidate before you fetch anything.
Read send_code_attempts on each verification
GET https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid}. The array holds one entry per send with its channel and time. Five entries is the exhausted state; four with status still pending is the one that is about to become a support ticket.
Measure the smallest gap between consecutive sends
This is the step that turns a count into a diagnosis. Sort the time values, take consecutive differences, keep the minimum. Under about thirty seconds nobody has had time to check an inbox and decide the message is missing, so something other than a person issued that send.
Divide total sends by verifications to get the billing number
One send per verification is the design. Anything above about 1.2 across a real window is a cost line, and it is the number to take to whoever owns the signup screen — more persuasive than any individual record, and it comes out of the same data.
Put a cooldown on the button and a guard on the retry
Thirty to sixty seconds of disabled button with a visible countdown, and hard-stop the control at three presses. Make the start call non-retryable on timeout, because a slow response from Verify usually means the message is already on its way. A stuck verification can be closed with POST https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid} and Status=canceled.
How to check it worked
Re-run over the same window. Sends per verification should sit near one, and no verification should show a gap under the cooldown.
python3 twilio_verify_send_attempts_audit.py --hours 24
# 412 verification(s), 431 send(s), 1.05 per verification, 0 under the cooldown
The full code
Attempts to find the verifications, then one GET each to read send_code_attempts — read-only, so an API Key with read access is all it needs. The classifier is pure and takes the verification as the API returns it, because the interesting work is arithmetic on a list of timestamps and that is far easier to trust with tests around it than with a screenshot of a log.
"""Report Verify verifications that burned their send budget on resends.
Five sends per verification, then 60203. A resend button with no cooldown, or a
retry wrapper treating a slow start call as a failed one, spends them in seconds
and bills every message.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed.
"""
import argparse
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_verify_send_attempts_audit")
VERIFY = "https://verify.twilio.com/v2"
# Fixed by the platform: the sixth send returns 60203, and the budget clears on a
# successful check rather than on a timer.
MAX_SENDS = 5
# Below this, nobody has had time to look at an inbox and decide the message is
# missing, so a person did not issue that send.
COOLDOWN_SECONDS = 30
def parse_time(value):
"""Parse a Verify timestamp into an aware datetime, or None.
fromisoformat did not accept a trailing Z until 3.11 and every timestamp
Verify returns has one.
"""
s = str(value or "").strip()
if not s:
return None
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def gaps_seconds(send_code_attempts):
"""Seconds between consecutive sends, oldest first.
Entries with an unreadable time drop out rather than poisoning the list: a
verification with three good timestamps and one bad one still has two gaps
worth reading.
"""
times = sorted(t for t in (parse_time(a.get("time"))
for a in (send_code_attempts or []))
if t is not None)
return [(times[i + 1] - times[i]).total_seconds() for i in range(len(times) - 1)]
def verdict(verification, cooldown=COOLDOWN_SECONDS, max_sends=MAX_SENDS):
"""Classify one verification by how its send budget was spent.
Pure, so the spacing arithmetic can be tested without a network. Returns
(state, detail).
"""
sends = verification.get("send_code_attempts") or []
status = str(verification.get("status") or "").strip().lower()
n = len(sends)
gaps = gaps_seconds(sends)
fastest = min(gaps) if gaps else None
channels = ", ".join(str(a.get("channel") or "?") for a in sends) or "none"
tail = " %d send(s): %s." % (n, channels)
if fastest is not None:
tail += " Fastest gap %ds." % int(fastest)
if n >= max_sends:
return ("burned",
"the %d send budget is spent, so the next resend returns 60203. "
"It clears on a successful check, not on a timer, and the user "
"pressing resend is the one who has not checked." % max_sends
+ tail)
if n >= max_sends - 1 and status == "pending":
return ("one-left",
"one send from 60203 while the verification is still open."
+ tail)
if fastest is not None and fastest < cooldown:
return ("no-cooldown",
"two sends %ds apart, inside the %ds a person needs to check an "
"inbox and decide nothing arrived: something resent on its own."
% (int(fastest), cooldown) + tail)
if n <= 1:
return ("ok", "one send, which is the design." if n else "no sends recorded.")
return ("ok", "resends are spaced like a person pressing a button." + tail)
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)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()
def page(session, url, field, **params):
"""Walk a Verify v2 list. Paging lives in meta.next_page_url."""
out = []
while url:
body = get(session, url, **params) or {}
out.extend(body.get(field, []))
url, params = (body.get("meta") or {}).get("next_page_url"), {}
return out
def verification_sids(session, service_sid, since, limit):
"""Distinct verification SIDs seen in the attempts list for a window.
There is no list endpoint for verifications; the attempts list is the only
read-only way to learn which ones existed.
"""
seen, out = set(), []
for attempt in page(session, VERIFY + "/Attempts", "attempts",
VerifyServiceSid=service_sid, DateCreatedAfter=since,
PageSize=100):
sid = attempt.get("verification_sid")
if sid and sid not in seen:
seen.add(sid)
out.append(sid)
if len(out) >= limit:
break
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--service", action="append", default=[],
help="Verify Service SID; repeatable. Default: every service")
ap.add_argument("--hours", type=int, default=24,
help="how far back to look for verifications")
ap.add_argument("--max-verifications", type=int, default=500,
help="stop after this many per service")
ap.add_argument("--cooldown", type=float, default=COOLDOWN_SECONDS,
help="seconds below which a gap is not a human resend")
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)
since = (datetime.now(timezone.utc) - timedelta(hours=args.hours)
).strftime("%Y-%m-%dT%H:%M:%SZ")
if args.service:
services = [{"sid": s, "friendly_name": s} for s in args.service]
else:
services = page(session, VERIFY + "/Services", "services", PageSize=50)
if not services:
log.info("no Verify services on this account")
return 0
inspected = total_sends = bad = 0
for svc in services:
sid = svc.get("sid")
for ve in verification_sids(session, sid, since, args.max_verifications):
body = get(session, "%s/Services/%s/Verifications/%s"
% (VERIFY, sid, ve))
if body is None:
# Soft deleted once approved, canceled or expired. The send
# budget of a verification that resolved is not a finding.
continue
inspected += 1
total_sends += len(body.get("send_code_attempts") or [])
state, detail = verdict(body, args.cooldown)
if state == "ok":
continue
bad += 1
log.warning("%-12s %s %s", state, ve, detail)
if state in ("burned", "one-left"):
log.warning(" repair: POST %s/Services/%s/Verifications/%s with "
"Status=canceled, then start a fresh verification",
VERIFY, sid, ve)
log.warning(" and put a %ds cooldown on the resend control, with a "
"hard stop at three presses", int(args.cooldown))
if not inspected:
log.info("no verifications in the last %d hour(s)", args.hours)
return 0
per = float(total_sends) / inspected
log.info("%d verification(s), %d send(s), %.2f per verification, %d over the "
"budget or under the cooldown", inspected, total_sends, per, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Verify verifications that burned their send budget on resends.
*
* Five sends per verification, then 60203. A resend button with no cooldown, or
* a retry wrapper treating a slow start call as a failed one, spends them in
* seconds and bills every message.
*
* 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 VERIFY = 'https://verify.twilio.com/v2';
// Fixed by the platform: the sixth send returns 60203, and the budget clears on
// a successful check rather than on a timer.
export const MAX_SENDS = 5;
// Below this, nobody has had time to look at an inbox and decide the message is
// missing, so a person did not issue that send.
export const COOLDOWN_SECONDS = 30;
/** Parse a Verify timestamp into epoch milliseconds, or null. */
export function parseTime(value) {
const s = String(value ?? '').trim();
if (!s) return null;
const ms = Date.parse(s);
return Number.isFinite(ms) ? ms : null;
}
/**
* Seconds between consecutive sends, oldest first. Entries with an unreadable
* time drop out rather than poisoning the list.
*/
export function gapsSeconds(sendCodeAttempts) {
const times = (sendCodeAttempts ?? [])
.map((a) => parseTime(a.time))
.filter((t) => t !== null)
.sort((a, b) => a - b);
const out = [];
for (let i = 0; i + 1 < times.length; i += 1) {
out.push((times[i + 1] - times[i]) / 1000);
}
return out;
}
/**
* Classify one verification by how its send budget was spent. Pure, so the
* spacing arithmetic can be tested without a network. Returns [state, detail].
*/
export function verdict(verification, cooldown = COOLDOWN_SECONDS,
maxSends = MAX_SENDS) {
const sends = verification.send_code_attempts ?? [];
const status = String(verification.status ?? '').trim().toLowerCase();
const n = sends.length;
const gaps = gapsSeconds(sends);
const fastest = gaps.length ? Math.min(...gaps) : null;
const channels = sends.map((a) => String(a.channel ?? '?')).join(', ') || 'none';
let tail = ` ${n} send(s): ${channels}.`;
if (fastest !== null) tail += ` Fastest gap ${Math.trunc(fastest)}s.`;
if (n >= maxSends) {
return ['burned',
`the ${maxSends} send budget is spent, so the next resend returns 60203. ` +
'It clears on a successful check, not on a timer, and the user pressing ' +
`resend is the one who has not checked.${tail}`];
}
if (n >= maxSends - 1 && status === 'pending') {
return ['one-left',
`one send from 60203 while the verification is still open.${tail}`];
}
if (fastest !== null && fastest < cooldown) {
return ['no-cooldown',
`two sends ${Math.trunc(fastest)}s apart, inside the ${cooldown}s a ` +
'person needs to check an inbox and decide nothing arrived: something ' +
`resent on its own.${tail}`];
}
if (n <= 1) {
return ['ok', n ? 'one send, which is the design.' : 'no sends recorded.'];
}
return ['ok', `resends are spaced like a person pressing a button.${tail}`];
}
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.status === 404) return null;
if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
return res.json();
}
/** Walk a Verify v2 list. Paging lives in meta.next_page_url. */
async function page(auth, url, field, params = {}) {
const out = [];
let next = url;
let p = params;
while (next) {
const body = (await get(auth, next, p)) ?? {};
out.push(...(body[field] ?? []));
next = body.meta?.next_page_url ?? null;
p = {};
}
return out;
}
/** Distinct verification SIDs seen in the attempts list for a window. */
export async function verificationSids(auth, serviceSid, since, limit) {
const seen = new Set();
const attempts = await page(auth, `${VERIFY}/Attempts`, 'attempts', {
VerifyServiceSid: serviceSid, DateCreatedAfter: since, PageSize: 100,
});
for (const attempt of attempts) {
if (attempt.verification_sid) seen.add(attempt.verification_sid);
if (seen.size >= limit) break;
}
return [...seen];
}
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 hoursArg = process.argv.indexOf('--hours');
const hours = hoursArg === -1 ? 24 : Number(process.argv[hoursArg + 1]) || 24;
const since = `${new Date(Date.now() - hours * 3600 * 1000)
.toISOString().slice(0, 19)}Z`;
const services = await page(auth, `${VERIFY}/Services`, 'services', { PageSize: 50 });
if (services.length === 0) {
console.log('no Verify services on this account');
return;
}
let inspected = 0;
let totalSends = 0;
let bad = 0;
for (const svc of services) {
for (const ve of await verificationSids(auth, svc.sid, since, 500)) {
const body = await get(auth, `${VERIFY}/Services/${svc.sid}/Verifications/${ve}`);
// Soft deleted once approved, canceled or expired. The send budget of a
// verification that resolved is not a finding.
if (body === null) continue;
inspected += 1;
totalSends += (body.send_code_attempts ?? []).length;
const [state, detail] = verdict(body);
if (state === 'ok') continue;
bad += 1;
console.warn(`${state.padEnd(12)} ${ve} ${detail}`);
if (state === 'burned' || state === 'one-left') {
console.warn(` repair: POST ${VERIFY}/Services/${svc.sid}/Verifications/` +
`${ve} with Status=canceled, then start a fresh verification`);
}
console.warn(` and put a ${COOLDOWN_SECONDS}s cooldown on the resend ` +
'control, with a hard stop at three presses');
}
}
if (inspected === 0) {
console.log(`no verifications in the last ${hours} hour(s)`);
return;
}
const per = totalSends / inspected;
console.log(`${inspected} verification(s), ${totalSends} send(s), ` +
`${per.toFixed(2)} per verification, ${bad} over the budget or ` +
'under the cooldown');
process.exitCode = bad ? 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 count is the easy half. The tests that earn their place are the spacing ones: three sends four seconds apart has to come back as a machine even though the budget is not spent, and three sends two minutes apart has to come back clean even though it is the same count. The last test pins that a broken timestamp costs you one gap rather than the whole verification.
from twilio_verify_send_attempts_audit import gaps_seconds, verdict
def at(second, channel="sms"):
return {"channel": channel,
"time": "2026-03-04T12:%02d:%02dZ" % divmod(second, 60)}
def test_five_sends_is_the_exhausted_budget():
state, detail = verdict({"send_code_attempts": [at(s * 40) for s in range(5)],
"status": "pending"})
assert state == "burned"
assert "60203" in detail
def test_four_sends_while_pending_is_one_tap_away():
state, detail = verdict({"send_code_attempts": [at(s * 40) for s in range(4)],
"status": "pending"})
assert state == "one-left"
assert "still open" in detail
def test_three_sends_seconds_apart_is_a_machine_not_a_person():
state, detail = verdict({"send_code_attempts": [at(0), at(4), at(9)],
"status": "pending"})
assert state == "no-cooldown"
assert "Fastest gap 4s" in detail
def test_the_same_count_spaced_like_a_human_is_fine():
state, _ = verdict({"send_code_attempts": [at(0), at(45), at(95)],
"status": "pending"})
assert state == "ok"
def test_a_channel_escalation_still_spends_from_the_same_budget():
state, detail = verdict({"send_code_attempts": [at(0), at(60, "call")],
"status": "pending"})
assert state == "ok"
assert "sms, call" in detail
def test_one_send_is_the_design():
assert verdict({"send_code_attempts": [at(0)], "status": "pending"})[0] == "ok"
assert verdict({"status": "pending"})[0] == "ok"
def test_an_unreadable_timestamp_costs_one_gap_not_the_verification():
sends = [at(0), {"channel": "sms", "time": "whenever"}, at(4)]
assert gaps_seconds(sends) == [4.0]
assert verdict({"send_code_attempts": sends, "status": "pending"})[0] == "no-cooldown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { gapsSeconds, verdict } from './twilio-verify-send-attempts-audit.mjs';
const BASE = Date.parse('2026-03-04T12:00:00Z');
const at = (second, channel = 'sms') => ({
channel,
time: `${new Date(BASE + second * 1000).toISOString().slice(0, 19)}Z`,
});
test('five sends is the exhausted budget', () => {
const sends = [0, 40, 80, 120, 160].map((s) => at(s));
const [state, detail] = verdict({ send_code_attempts: sends, status: 'pending' });
assert.equal(state, 'burned');
assert.match(detail, /60203/);
});
test('four sends while pending is one tap away', () => {
const sends = [at(0), at(40), at(80), at(120)];
const [state, detail] = verdict({ send_code_attempts: sends, status: 'pending' });
assert.equal(state, 'one-left');
assert.match(detail, /still open/);
});
test('three sends seconds apart is a machine not a person', () => {
const [state, detail] = verdict({
send_code_attempts: [at(0), at(4), at(9)], status: 'pending' });
assert.equal(state, 'no-cooldown');
assert.match(detail, /Fastest gap 4s/);
});
test('the same count spaced like a human is fine', () => {
const [state] = verdict({
send_code_attempts: [at(0), at(45), at(95)], status: 'pending' });
assert.equal(state, 'ok');
});
test('a channel escalation still spends from the same budget', () => {
const [state, detail] = verdict({
send_code_attempts: [at(0), at(60, 'call')], status: 'pending' });
assert.equal(state, 'ok');
assert.match(detail, /sms, call/);
});
test('one send is the design', () => {
assert.equal(verdict({ send_code_attempts: [at(0)], status: 'pending' })[0], 'ok');
assert.equal(verdict({ status: 'pending' })[0], 'ok');
});
test('an unreadable timestamp costs one gap not the verification', () => {
const sends = [at(0), { channel: 'sms', time: 'whenever' }, at(4)];
assert.deepEqual(gapsSeconds(sends), [4]);
assert.equal(
verdict({ send_code_attempts: sends, status: 'pending' })[0], 'no-cooldown');
});
FAQ
How is 60203 different from 60202?
60203 is the send budget: five codes issued for one verification. 60202 is the check budget: five guesses at the code. They sit on the same verification and run down independently, and mixing them up sends you to the wrong half of the client, because the send count is visible in the API and the check count is not.
Does waiting clear the send budget?
No. It clears on a successful check, or when the verification expires and you start a new one. Waiting is exactly what the user who tripped it will not do, which is why the flow needs a path to a fresh verification rather than a longer spinner.
Why judge the spacing rather than just the count?
Because four sends over three minutes is a person in bad coverage and four sends over six seconds is code. The count is identical and the fix is completely different: one is a product decision about escalation, the other is a line in a handler that should not be there.
Is a channel escalation from SMS to voice a problem?
No, it is a good design, but it spends from the same five. A flow that escalates automatically after two SMS attempts has three sends left before 60203, not five, and the report names the channels so that arithmetic is visible rather than assumed.
Can the script cancel the stuck verifications it finds?
It will not. Canceling is a write against a live authentication attempt, and this script holds a credential to an account that can spend money. It prints the exact call, with the Service SID and the verification SID, for a human to run.
Related field notes
- A verification that burned all five checks
- A Verify Service with zero rate limits configured
- Verify sending SMS to a line that cannot receive it
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Error 60203: max send attempts reached — Twilio Docs
- Verification resource — Twilio Docs
- Verify Service resource — 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.