Skip to content

Diagnostic Twilio

60202: a verification that burned all five check attempts

The support ticket says the code does not work. It does work; the user is holding the right one. Somewhere between the keypad and your server the five checks that verification was allowed have already been spent, the verification has moved to max_attempts_reached, and every further attempt returns 60202 until it expires. The screen offers a Verify button that can no longer do anything.

Read-only key Python and Node.js Tests included
Futuristic circuit board with glowing green data beam.
Photo by Brecht Corbeel on Unsplash
The short answer

Read GET https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid} and look at status. max_attempts_reached means the five-check budget is gone and the verification is dead for the remainder of its ten-minute lifetime, regardless of what the user types next.

A 404 from that same call is not an error: Verify soft-deletes a verification once it is approved, canceled or expired, so a missing resource means it already resolved. Count the max_attempts_reached rate against the verifications you inspected — a handful is users mistyping, a few percent is a client firing checks it was never asked to fire.

The problem in plain words

Five is a generous budget for a person and no budget at all for a loop. A person types six digits, glances at the message again, corrects one, and submits twice. A form that calls the check endpoint on every input event submits five times before the user has finished the code, and each of those is a genuine failed check against a partial string. By the time the last digit lands the verification is already dead, and the failure the user sees is the only correct code they will ever type.

What makes it hard to see from the outside is that the 60202 lands on the request that should have succeeded. The four failures before it are invisible to the user, and in your logs they look like ordinary wrong-code attempts. So the bug reports say the code is wrong when the code was right, and reproducing it needs someone to type at the speed a real user types rather than pasting the value in.

The other producer is a double-firing handler: a form that submits on both click and submit, a React effect without a dependency array, a retry wrapper that repeats a check after a slow response even though the first one arrived. Those spend the budget two at a time, which turns a user with one typo into a user who is locked out.

User types codehandler fires perkeystrokePartial codescheckedfour real failuresLast digitarrivesthe code is rightBudget alreadygone60202 on a 429Retry loopbeginsnothing canapprove it
The failure lands on the one request that was correct. The four before it were genuine checks against a half typed code.

Why it happens

The budget belongs to the verification, and the user cannot see it. There is no counter in the response and nothing in the UI that says two of five. The state moves from pending to max_attempts_reached in one step, with no warning at four, so the first evidence anyone gets is the error that ends the session.

The error is a 429, which invites exactly the wrong reaction. Client code that sees 429 backs off and retries, because that is what 429 usually means. Here it means stop: the budget is not going to refill, and every retry is another logged failure against a verification that is already finished. Retrying a 60202 is a loop that can only produce more 60202.

The dead verification looks alive from the outside. Nothing expires visibly and nothing changes on the page. The input still accepts digits, the button still posts, and the only route out is to start a new verification — which is a thing the UI usually does not offer, because the resend button was designed for a code that failed to arrive rather than for one that arrived and then died.

A 404 on the lookup means resolved, not missing. Verify soft-deletes a verification when it is approved, canceled or expired. An audit that treats 404 as an error will report the healthy majority as broken and bury the finding. Reading the absence correctly is most of what makes this script trustworthy.

The rate is the diagnosis, not the individual record. One max_attempts_reached is a person having a bad morning. Two percent of all verifications ending that way is a client bug with a deploy date, and only counting tells you which of the two you have.

The fix, as a flow

The script reads the HTTP status alongside the body, because Verify soft deletes a verification once it resolves: a 404 here is the healthy answer and treating it as an error reports a working account as broken.

GET one VerificationHTTP status plus status field404 returnedsoft deleted, it resolvedpendingchecks still availableBurned, past its lifecounts towards the rateBurned, still livea user stuck on that screen
Same status, two responses: inside the ten minute window somebody is waiting, outside it the record is only evidence of a rate.

How to fix it

Collect candidate verifications from the attempts list

There is no list endpoint for verifications, so start from what there is: GET https://verify.twilio.com/v2/Attempts?VerifyServiceSid={VA...}&DateCreatedAfter={ISO8601}. Every attempt carries a verification_sid; the distinct set of those is the population to inspect.

Fetch each verification and read status

GET https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid}. max_attempts_reached is the finding. pending is still live. Anything else is the resource on its way out.

Treat 404 as resolved rather than as an error

Verify soft-deletes a verification once it is approved, canceled or expired, so the lookup returns 404 for the ones that went fine. Fold those into a resolved bucket. A script that raises on 404 reports a healthy account as an outage.

Split the burned ones by whether the clock has run out

Compare date_created against the ten-minute lifetime. Inside the window there is a person sitting in front of a broken screen right now, which is a page. Outside it, the record is evidence of a rate and nothing more. Same status, two different responses.

Fix the client, then give the user a way out

Debounce the check and submit only on a complete code. Treat 60202 as terminal rather than retryable, and replace the Verify button with "send a new code". Server-side, a stuck verification can be closed with POST https://verify.twilio.com/v2/Services/{ServiceSid}/Verifications/{VerificationSid} and Status=canceled, then a fresh one started.

How to check it worked

Re-run the script over the same window. The burned count should fall to the background rate of genuine typos, and nothing should be burned inside its lifetime.

python3 twilio_verify_check_attempts_audit.py --hours 24
# 412 verification(s) inspected, 3 burned (0.7%), 0 still inside their lifetime

The full code

One GET for the services, one paginated GET of the attempts to find which verifications existed, and one GET per verification — all read-only, so an API Key with read access is enough. The classifier is pure and takes the HTTP status alongside the body, because 404 is a verdict here rather than a failure and that decision deserves a test rather than a comment.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 117 Twilio fixes, free and open source.
twilio_verify_check_attempts_audit.py
"""Report Verify verifications that spent all five checks and died.

A verification allows five checks. A handler that fires on every keystroke, or
a form that submits twice, spends them before the user finishes typing, and the
verification moves to max_attempts_reached for the rest of its lifetime.

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_check_attempts_audit")

VERIFY = "https://verify.twilio.com/v2"

# Fixed by the platform: five checks per verification, and the verification lives
# ten minutes. Both numbers are the reason a burned one cannot be recovered.
MAX_CHECKS = 5
TTL_SECONDS = 600


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, so the swap has to happen here rather than in the
    caller.
    """
    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 age_seconds(value, now):
    dt = parse_time(value)
    return None if dt is None else (now - dt).total_seconds()


def verdict(http_status, verification, now, ttl_seconds=TTL_SECONDS):
    """Classify one verification lookup.

    `http_status` matters as much as the body: Verify soft-deletes a verification
    once it is approved, canceled or expired, so a 404 means it resolved rather
    than that anything went wrong. Pure, so that rule can be tested without a
    network. Returns (state, detail).
    """
    if http_status == 404:
        return ("resolved",
                "404: the verification is soft deleted, which Verify does once "
                "it is approved, canceled or expired. Nothing is stuck.")

    body = verification or {}
    status = str(body.get("status") or "").strip().lower()

    if status == "max_attempts_reached":
        age = age_seconds(body.get("date_created"), now)
        if age is None:
            return ("burned",
                    "all %d checks spent. date_created is unreadable, so "
                    "whether the lifetime has run out cannot be told from here."
                    % MAX_CHECKS)
        remaining = ttl_seconds - age
        if remaining > 0:
            return ("burned-live",
                    "all %d checks spent %ds ago. Every further check returns "
                    "60202 for another %ds, and someone is looking at that "
                    "screen now." % (MAX_CHECKS, int(age), int(remaining)))
        return ("burned-cold",
                "all %d checks spent %ds ago, past the %ds lifetime. Nobody is "
                "stuck on it; it counts towards the rate."
                % (MAX_CHECKS, int(age), ttl_seconds))

    if status == "pending":
        return ("pending", "open, checks still available")
    if status in ("approved", "canceled"):
        return (status, "closed as " + status)
    return ("unknown", "status %r is not one this script recognises"
            % (body.get("status"),))


def fetch(session, url, **params):
    """GET returning (status_code, body). 404 is data here, not an error."""
    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 (404, {})
    r.raise_for_status()
    return (r.status_code, r.json())


def get(session, url, **params):
    status, body = fetch(session, url, **params)
    if status == 404:
        raise SystemExit("404 from %s: check the SID" % url)
    return body


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)
        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, so the attempts are the only
    read-only way to find out which ones existed. Order is preserved so the
    limit takes the oldest in the window rather than an arbitrary slice.
    """
    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("--burn-rate", type=float, default=2.0,
                    help="percent burned above which this is a client bug")
    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)

    now = datetime.now(timezone.utc)
    since = (now - 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 = burned = live = 0
    for svc in services:
        sid = svc.get("sid")
        for ve in verification_sids(session, sid, since, args.max_verifications):
            status, body = fetch(
                session, "%s/Services/%s/Verifications/%s" % (VERIFY, sid, ve))
            state, detail = verdict(status, body, now)
            inspected += 1
            if not state.startswith("burned"):
                continue
            burned += 1
            log.warning("%-12s %s  %s", state, ve, detail)
            if state == "burned-live":
                live += 1
                log.warning("  repair now: POST %s/Services/%s/Verifications/%s "
                            "with Status=canceled, then start a fresh "
                            "verification for that user", VERIFY, sid, ve)

    if not inspected:
        log.info("no verifications in the last %d hour(s)", args.hours)
        return 0

    rate = 100.0 * burned / inspected
    log.info("%d verification(s) inspected, %d burned (%.1f%%), %d still inside "
             "their lifetime", inspected, burned, rate, live)
    if rate > args.burn_rate:
        log.warning("above %.1f%%: debounce the check call and submit only on a "
                    "complete code. 60202 is terminal, not retryable.",
                    args.burn_rate)
        return 1
    return 1 if live else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-verify-check-attempts-audit.mjs
/**
 * Report Verify verifications that spent all five checks and died.
 *
 * A verification allows five checks. A handler that fires on every keystroke,
 * or a form that submits twice, spends them before the user finishes typing,
 * and the verification moves to max_attempts_reached for the rest of its
 * lifetime.
 *
 * 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: five checks per verification, ten minute lifetime.
export const MAX_CHECKS = 5;
export const TTL_SECONDS = 600;

/** 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;
}

export function ageSeconds(value, nowMs) {
  const ms = parseTime(value);
  return ms === null ? null : (nowMs - ms) / 1000;
}

/**
 * Classify one verification lookup. `httpStatus` matters as much as the body:
 * Verify soft-deletes a verification once it is approved, canceled or expired,
 * so a 404 means it resolved rather than that anything went wrong. Pure, so
 * that rule can be tested without a network. Returns [state, detail].
 */
export function verdict(httpStatus, verification, nowMs, ttlSeconds = TTL_SECONDS) {
  if (httpStatus === 404) {
    return ['resolved',
      '404: the verification is soft deleted, which Verify does once it is ' +
      'approved, canceled or expired. Nothing is stuck.'];
  }

  const body = verification ?? {};
  const status = String(body.status ?? '').trim().toLowerCase();

  if (status === 'max_attempts_reached') {
    const age = ageSeconds(body.date_created, nowMs);
    if (age === null) {
      return ['burned',
        `all ${MAX_CHECKS} checks spent. date_created is unreadable, so ` +
        'whether the lifetime has run out cannot be told from here.'];
    }
    const remaining = ttlSeconds - age;
    if (remaining > 0) {
      return ['burned-live',
        `all ${MAX_CHECKS} checks spent ${Math.trunc(age)}s ago. Every further ` +
        `check returns 60202 for another ${Math.trunc(remaining)}s, and ` +
        'someone is looking at that screen now.'];
    }
    return ['burned-cold',
      `all ${MAX_CHECKS} checks spent ${Math.trunc(age)}s ago, past the ` +
      `${ttlSeconds}s lifetime. Nobody is stuck on it; it counts towards the rate.`];
  }

  if (status === 'pending') return ['pending', 'open, checks still available'];
  if (status === 'approved' || status === 'canceled') {
    return [status, `closed as ${status}`];
  }
  return ['unknown',
    `status ${JSON.stringify(body.status)} is not one this script recognises`];
}

function authHeader(key, secret) {
  return `Basic ${Buffer.from(`${key}:${secret}`).toString('base64')}`;
}

/** GET returning [status, body]. 404 is data here, not an error. */
async function fetchJson(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 [404, {}];
  if (!res.ok) throw new Error(`${res.status} from ${u.pathname}`);
  return [res.status, await res.json()];
}

async function get(auth, url, params = {}) {
  const [status, body] = await fetchJson(auth, url, params);
  if (status === 404) throw new Error(`404 from ${url}: check the SID`);
  return body;
}

/** 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 nowMs = Date.now();
  const since = `${new Date(nowMs - 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 burned = 0;
  let live = 0;
  for (const svc of services) {
    for (const ve of await verificationSids(auth, svc.sid, since, 500)) {
      const [status, body] = await fetchJson(
        auth, `${VERIFY}/Services/${svc.sid}/Verifications/${ve}`);
      const [state, detail] = verdict(status, body, nowMs);
      inspected += 1;
      if (!state.startsWith('burned')) continue;
      burned += 1;
      console.warn(`${state.padEnd(12)} ${ve}  ${detail}`);
      if (state === 'burned-live') {
        live += 1;
        console.warn(`  repair now: POST ${VERIFY}/Services/${svc.sid}/` +
                     `Verifications/${ve} with Status=canceled, then start a ` +
                     'fresh verification for that user');
      }
    }
  }

  if (inspected === 0) {
    console.log(`no verifications in the last ${hours} hour(s)`);
    return;
  }

  const rate = (100 * burned) / inspected;
  console.log(`${inspected} verification(s) inspected, ${burned} burned ` +
              `(${rate.toFixed(1)}%), ${live} still inside their lifetime`);
  if (rate > 2) {
    console.warn('above 2.0%: debounce the check call and submit only on a ' +
                 'complete code. 60202 is terminal, not retryable.');
  }
  process.exitCode = rate > 2 || live ? 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

Two decisions carry this script and both are easy to get quietly wrong: a 404 has to read as resolved rather than as a failure, and a burned verification inside its ten-minute lifetime has to be separated from one outside it, because the first is a person waiting and the second is a statistic. The clock is passed in rather than read from the system, so the tests pin real elapsed times instead of sleeping.

test_twilio_verify_check_attempts_audit.py
from datetime import datetime, timedelta, timezone

from twilio_verify_check_attempts_audit import age_seconds, parse_time, verdict

NOW = datetime(2026, 3, 4, 12, 0, 0, tzinfo=timezone.utc)


def iso(seconds_ago):
    return (NOW - timedelta(seconds=seconds_ago)).strftime("%Y-%m-%dT%H:%M:%SZ")


def test_404_is_resolved_not_an_error():
    state, detail = verdict(404, None, NOW)
    assert state == "resolved"
    assert "soft deleted" in detail


def test_burned_inside_the_lifetime_is_someone_waiting_now():
    state, detail = verdict(200, {"status": "max_attempts_reached",
                                  "date_created": iso(120)}, NOW)
    assert state == "burned-live"
    assert "another 480s" in detail


def test_burned_after_the_lifetime_is_only_a_statistic():
    state, detail = verdict(200, {"status": "max_attempts_reached",
                                  "date_created": iso(3600)}, NOW)
    assert state == "burned-cold"
    assert "Nobody is stuck" in detail


def test_burned_with_an_unreadable_clock_is_still_burned():
    state, detail = verdict(200, {"status": "max_attempts_reached",
                                  "date_created": "not a date"}, NOW)
    assert state == "burned"
    assert "unreadable" in detail


def test_pending_and_approved_are_left_alone():
    assert verdict(200, {"status": "pending"}, NOW)[0] == "pending"
    assert verdict(200, {"status": "approved"}, NOW)[0] == "approved"


def test_an_unrecognised_status_is_reported_rather_than_assumed_healthy():
    state, detail = verdict(200, {"status": "expired"}, NOW)
    assert state == "unknown"
    assert "expired" in detail


def test_timestamps_with_a_trailing_z_parse_on_3_9():
    assert parse_time("2026-03-04T11:58:00Z") is not None
    assert parse_time("") is None
    assert age_seconds(iso(60), NOW) == 60
twilio-verify-check-attempts-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ageSeconds, parseTime, verdict } from './twilio-verify-check-attempts-audit.mjs';

const NOW = Date.parse('2026-03-04T12:00:00Z');
const iso = (secondsAgo) =>
  `${new Date(NOW - secondsAgo * 1000).toISOString().slice(0, 19)}Z`;

test('404 is resolved not an error', () => {
  const [state, detail] = verdict(404, null, NOW);
  assert.equal(state, 'resolved');
  assert.match(detail, /soft deleted/);
});

test('burned inside the lifetime is someone waiting now', () => {
  const [state, detail] = verdict(
    200, { status: 'max_attempts_reached', date_created: iso(120) }, NOW);
  assert.equal(state, 'burned-live');
  assert.match(detail, /another 480s/);
});

test('burned after the lifetime is only a statistic', () => {
  const [state, detail] = verdict(
    200, { status: 'max_attempts_reached', date_created: iso(3600) }, NOW);
  assert.equal(state, 'burned-cold');
  assert.match(detail, /Nobody is stuck/);
});

test('burned with an unreadable clock is still burned', () => {
  const [state, detail] = verdict(
    200, { status: 'max_attempts_reached', date_created: 'not a date' }, NOW);
  assert.equal(state, 'burned');
  assert.match(detail, /unreadable/);
});

test('pending and approved are left alone', () => {
  assert.equal(verdict(200, { status: 'pending' }, NOW)[0], 'pending');
  assert.equal(verdict(200, { status: 'approved' }, NOW)[0], 'approved');
});

test('an unrecognised status is reported rather than assumed healthy', () => {
  const [state, detail] = verdict(200, { status: 'expired' }, NOW);
  assert.equal(state, 'unknown');
  assert.match(detail, /expired/);
});

test('timestamps parse and age is measured in seconds', () => {
  assert.notEqual(parseTime('2026-03-04T11:58:00Z'), null);
  assert.equal(parseTime(''), null);
  assert.equal(ageSeconds(iso(60), NOW), 60);
});

FAQ

Can I reset the check counter on a verification?

No. The five checks belong to that verification and nothing refills them. The only way forward is a new verification, which is why the fix on the client is to replace the Verify button with a request-a-new-code button once 60202 comes back rather than letting the user keep pressing.

Why does the lookup 404 for most of my verifications?

Because they worked. Verify soft-deletes a verification once it is approved, canceled or expired, so a 404 is the ordinary end state and not a missing record. An audit that treats it as an error will flag a healthy account, which is why the script counts 404 as resolved.

60202 is a 429. Should the client back off and retry?

No, and this is the trap. A 429 usually means slow down and try again, but this one means the budget is gone for good. A generic retry wrapper turns one exhausted verification into a stream of identical failures against a resource that can no longer approve anything.

How do I tell a client bug from users mistyping?

By the rate. A percent or less spread evenly across the day is people. Several percent, or a step change that starts on a deploy date, is a handler firing checks the user did not ask for. That is why the script reports the rate rather than a list of records.

Should the server cancel stuck verifications automatically?

It can, but that is a write against a live authentication flow and this script will not do it. Canceling is the right repair when a human decides it is; running it on a schedule means a background job racing a user who is still typing.

Related field notes

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.