Diagnostic Twilio
a rejected toll-free record's edit window closes on a clock
The rejection came in ten days ago. Somebody read it, agreed the opt-in wording needed work, put it on the board, and the board is long. Nothing has broken since: the number was already blocked, the status has read TWILIO_REJECTED the whole time, and it will read that tomorrow too. What changes tomorrow is edit_expiration, and after it passes the correction that would have taken twenty minutes becomes a fresh submission at the back of a queue measured in weeks.
Read GET https://messaging.twilio.com/v1/Tollfree/Verifications?Status=TWILIO_REJECTED and, on each record, the two fields that are about time rather than about the rejection: edit_allowed and edit_expiration. Flag anything where edit_allowed is true and edit_expiration falls inside your alerting horizon.
Nothing here is about why it was rejected. This is a deadline check, and it should run on a schedule whether or not anyone has read the rejection yet.
The problem in plain words
Every other toll-free finding announces itself by failing. This one has already failed: the number was blocked the moment the verification was rejected, and it stays blocked either way. So there is no new symptom to notice, no error rate that ticks up, and no moment where the situation gets worse in a way monitoring would see. It just gets more expensive to fix.
The console does not nag. The status does not change when the window closes — it reads TWILIO_REJECTED before and after — and edit_allowed is a field you have to go looking for. The whole loss is invisible: one day a correction is possible, the next it is not, and the only difference is a timestamp nobody was watching.
Why it happens
The deadline is on a different clock from the work. Whoever owns the rejection is scheduling it against sprint capacity. The window is running against wall time from the moment of rejection, including the weekend and the week the owner was on leave. Those two clocks never meet unless something reads the field.
It is a separate question from whether the rejection is fixable. Some rejection reasons can be corrected in place and some can never be, and that judgement takes a person reading rejection_reasons. The window closes at the same rate regardless. Keeping the two checks apart means the deadline still gets flagged while the reason is still being argued about.
Missing it costs the queue, not the fee. An in-place correction goes back for review on the existing record. A fresh submission starts over, and toll-free review times are measured in weeks. On a number that is already blocked, that is weeks of a customer-facing sender staying dark for a wording change.
The field and the clock can disagree. A record can read edit_allowed: true with an edit_expiration already in the past. Trusting the boolean there produces a confident report that the correction is available, followed by a rejected edit and a lost afternoon. Report the disagreement rather than picking a side.
The fix, as a flow
The script reads only edit_allowed and edit_expiration. Whether the rejection can be corrected at all is a separate question, and the window closes at the same rate while that one is being answered.
How to fix it
List the rejected verifications
GET https://messaging.twilio.com/v1/Tollfree/Verifications?Status=TWILIO_REJECTED, following meta.next_page_url. The status filter is server side here, which is a change from the Messages list where error codes have to be filtered client side.
Read edit_allowed and edit_expiration, and nothing else
Deliberately narrow. rejection_reason, rejection_reasons and error_code decide whether a correction can work, and that is a separate note and a separate conversation. This check is only about how long that conversation has left.
Distinguish absent from false
edit_allowed being false means the in-place path was never offered and a fresh submission is the only route. The field being absent from the response means something different: you have not learned that, and reporting it as closed would send somebody down the expensive path unnecessarily.
Treat an expiration in the past as authoritative
When edit_allowed still reads true but edit_expiration has passed, report the disagreement and expect the edit to be refused. The timestamp is the thing the platform is enforcing; the boolean is a summary that can lag it.
Print the correction and the deadline together
The repair is POST https://messaging.twilio.com/v1/Tollfree/Verifications/{Sid} with the corrected fields, before edit_expiration. In the console it is Phone Numbers, Manage, Active numbers, Regulatory Information, then edit and resubmit. Print the deadline next to the instruction so whoever reads the alert knows what it costs to defer it.
How to check it worked
Re-run the script. Nothing should be inside the horizon with an open window.
python3 twilio_tollfree_edit_window.py --horizon-hours 72
# 4 rejected verification(s), 0 closing inside 72 hours
The full code
One paginated GET with a server-side status filter, and a classifier that takes the hours remaining as a number. That keeps the interesting cases testable without freezing a clock: inside the horizon, already past it while the boolean still says otherwise, never offered at all, and absent from the response, which is not the same as offered and declined.
"""Flag rejected toll-free verifications whose edit window is about to close.
Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The correction itself needs a human who has
read the rejection reasons; this script only makes sure they still can.
"""
import argparse
import datetime
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_tollfree_edit_window")
MSG = "https://messaging.twilio.com/v1"
REJECTED = "TWILIO_REJECTED"
def parse_time(value):
"""Parse a messaging v1 timestamp. Pure.
These come back as ISO 8601 with a trailing Z, which
datetime.fromisoformat did not accept before Python 3.11.
"""
text = str(value or "").strip()
if not text:
return None
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.datetime.fromisoformat(text)
except ValueError:
return None
def hours_left(edit_expiration, now):
"""Hours until the edit window closes. Negative once it has passed."""
expires = parse_time(edit_expiration)
if expires is None or now is None:
return None
return (expires - now).total_seconds() / 3600.0
def verdict(record, hours, horizon_hours=72.0):
"""Classify one rejected toll-free verification against its edit window.
`hours` is the time remaining, or None. Taking it as an argument keeps the
clock out of the classifier. Nothing here reads the rejection reasons: what
can be corrected is a separate question from how long there is to do it.
Returns (state, detail).
"""
if not record:
return ("no-record", "no verification to read.")
status = str(record.get("status") or "").upper()
if status != REJECTED:
return ("not-rejected",
"status is %s: there is no edit window on a record that has "
"not been rejected." % (status or "unset"))
allowed = record.get("edit_allowed")
if allowed is None:
return ("edit-allowed-unset",
"rejected, and edit_allowed is absent from the response. That "
"is not the same as false: nothing has been learned about the "
"window, so do not file a fresh submission on this alone.")
if not allowed:
return ("no-edit-window",
"rejected with edit_allowed false. The in-place correction was "
"never on offer here, so a fresh submission is the only path "
"and there is no deadline to race.")
if hours is None:
return ("expiration-unreadable",
"rejected with edit_allowed true, and edit_expiration could "
"not be parsed. Treat the window as closing and correct now.")
if hours <= 0:
return ("window-lapsed",
"edit_expiration passed %.0f hours ago while edit_allowed "
"still reads true. The timestamp is what the platform "
"enforces, so expect the correction to be refused and plan on "
"a fresh submission." % abs(hours))
if hours <= horizon_hours:
return ("closing",
"%.0f hours left on the edit window. After that the in-place "
"correction is gone and the only route is a fresh submission, "
"back of the review queue." % hours)
return ("open",
"%.0f hours left on the edit window, outside the %.0f hour horizon."
% (hours, horizon_hours))
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_v1(session, url, key, limit=1000, **params):
"""Page a messaging.twilio.com list. meta.next_page_url is absolute."""
out = []
while url and len(out) < limit:
page = get(session, url, PageSize=50, **params)
out.extend(page.get(key, []))
url = (page.get("meta") or {}).get("next_page_url")
params = {}
return out[:limit]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--horizon-hours", type=float, default=72.0,
help="how close to the deadline counts as a finding")
ap.add_argument("--max-records", type=int, default=500)
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.datetime.now(datetime.timezone.utc)
records = list_v1(session, MSG + "/Tollfree/Verifications",
"verifications", args.max_records, Status=REJECTED)
if not records:
log.info("no rejected toll-free verifications on this account")
return 0
bad = 0
for rec in records:
hours = hours_left(rec.get("edit_expiration"), now)
state, detail = verdict(rec, hours, args.horizon_hours)
name = rec.get("tollfree_phone_number_sid") or rec.get("sid") or "record"
line = "%-22s %s %s" % (state, name, detail)
if state in ("open", "no-edit-window"):
log.info(line)
continue
bad += 1
log.warning(line)
if state in ("closing", "expiration-unreadable"):
log.warning(" repair: correct the named fields on %s/Tollfree/"
"Verifications/%s before %s, then resubmit. Console: "
"Phone Numbers, Manage, Active numbers, Regulatory "
"Information, edit and resubmit", MSG,
rec.get("sid", "{Sid}"),
rec.get("edit_expiration", "the expiration"))
elif state == "window-lapsed":
log.warning(" repair: file a fresh verification for this number "
"and expect the full review time; the in-place edit is "
"no longer available")
log.info("%d rejected verification(s), %d closing inside %.0f hours",
len(records), bad, args.horizon_hours)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Flag rejected toll-free verifications whose edit window is about to close.
*
* Read only. GET requests and nothing else: give this an API Key with read
* access rather than the account auth token. The correction needs a human who
* has read the rejection reasons; this only makes sure they still can.
*/
const MSG = 'https://messaging.twilio.com/v1';
const REJECTED = 'TWILIO_REJECTED';
/** Parse a messaging v1 ISO 8601 timestamp. Pure. Returns a Date or null. */
export function parseTime(value) {
const text = String(value ?? '').trim();
if (!text) return null;
const t = Date.parse(text);
return Number.isNaN(t) ? null : new Date(t);
}
/** Hours until the edit window closes. Negative once it has passed. */
export function hoursLeft(editExpiration, now) {
const expires = parseTime(editExpiration);
if (expires === null || !now) return null;
return (expires.getTime() - now.getTime()) / 3600000;
}
/**
* Classify one rejected toll-free verification against its edit window. `hours`
* is the time remaining, or null; taking it as an argument keeps the clock out
* of the classifier. Nothing here reads the rejection reasons. Pure.
* Returns [state, detail].
*/
export function verdict(record, hours, horizonHours = 72.0) {
if (!record) return ['no-record', 'no verification to read.'];
const status = String(record.status ?? '').toUpperCase();
if (status !== REJECTED) {
return ['not-rejected',
`status is ${status || 'unset'}: there is no edit window on a record ` +
'that has not been rejected.'];
}
const allowed = record.edit_allowed;
if (allowed === null || allowed === undefined) {
return ['edit-allowed-unset',
'rejected, and edit_allowed is absent from the response. That is not the ' +
'same as false: nothing has been learned about the window, so do not ' +
'file a fresh submission on this alone.'];
}
if (!allowed) {
return ['no-edit-window',
'rejected with edit_allowed false. The in-place correction was never on ' +
'offer here, so a fresh submission is the only path and there is no ' +
'deadline to race.'];
}
if (hours === null || hours === undefined) {
return ['expiration-unreadable',
'rejected with edit_allowed true, and edit_expiration could not be ' +
'parsed. Treat the window as closing and correct now.'];
}
if (hours <= 0) {
return ['window-lapsed',
`edit_expiration passed ${Math.abs(hours).toFixed(0)} hours ago while ` +
'edit_allowed still reads true. The timestamp is what the platform ' +
'enforces, so expect the correction to be refused and plan on a fresh ' +
'submission.'];
}
if (hours <= horizonHours) {
return ['closing',
`${hours.toFixed(0)} hours left on the edit window. After that the ` +
'in-place correction is gone and the only route is a fresh submission, ' +
'back of the review queue.'];
}
return ['open',
`${hours.toFixed(0)} hours left on the edit window, outside the ` +
`${horizonHours.toFixed(0)} hour horizon.`];
}
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 listV1(auth, url, key, limit = 1000, params = {}) {
const out = [];
let next = url;
let first = params;
while (next && out.length < limit) {
const page = await get(auth, next, { PageSize: 50, ...first });
out.push(...(page[key] ?? []));
next = page.meta?.next_page_url ?? null;
first = {};
}
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 flag = process.argv.indexOf('--horizon-hours');
const horizon = flag >= 0 ? Number(process.argv[flag + 1]) : 72.0;
const now = new Date();
const records = await listV1(auth, `${MSG}/Tollfree/Verifications`,
'verifications', 500, { Status: REJECTED });
if (records.length === 0) {
console.log('no rejected toll-free verifications on this account');
return;
}
let bad = 0;
for (const rec of records) {
const hours = hoursLeft(rec.edit_expiration, now);
const [state, detail] = verdict(rec, hours, horizon);
const name = rec.tollfree_phone_number_sid ?? rec.sid ?? 'record';
const line = `${state.padEnd(22)} ${name} ${detail}`;
if (state === 'open' || state === 'no-edit-window') { console.log(line); continue; }
bad += 1;
console.warn(line);
if (state === 'closing' || state === 'expiration-unreadable') {
console.warn(` repair: correct the named fields on ${MSG}/Tollfree/` +
`Verifications/${rec.sid ?? '{Sid}'} before ` +
`${rec.edit_expiration ?? 'the expiration'}, then resubmit. ` +
'Console: Phone Numbers, Manage, Active numbers, Regulatory ' +
'Information, edit and resubmit');
} else if (state === 'window-lapsed') {
console.warn(' repair: file a fresh verification for this number and ' +
'expect the full review time; the in-place edit is no longer ' +
'available');
}
}
console.log(`${records.length} rejected verification(s), ${bad} closing inside ` +
`${horizon} hours`);
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
Hours remaining is an argument, so the horizon boundary is one assertion and the lapsed case is another. The three that matter beyond the arithmetic are the ones where a careless reading costs real time: edit_allowed true over an expiration already in the past, edit_allowed false which has no deadline at all, and edit_allowed absent, which is not the same as false.
import datetime
from twilio_tollfree_edit_window import hours_left, verdict
OPEN = {"sid": "HH01", "status": "TWILIO_REJECTED", "edit_allowed": True,
"edit_expiration": "2026-09-02T00:00:00Z"}
NOW = datetime.datetime(2026, 8, 30, tzinfo=datetime.timezone.utc)
def test_inside_the_horizon_is_the_finding():
state, detail = verdict(OPEN, 40.0, horizon_hours=72.0)
assert state == "closing"
assert "back of the review queue" in detail
def test_outside_the_horizon_is_not_a_finding_yet():
assert verdict(OPEN, 200.0, horizon_hours=72.0)[0] == "open"
def test_the_timestamp_wins_over_the_boolean():
# edit_allowed can still read true after the expiration has passed.
state, detail = verdict(OPEN, -12.0)
assert state == "window-lapsed"
assert "expect the correction to be refused" in detail
def test_edit_allowed_false_has_no_deadline_to_race():
state, detail = verdict(dict(OPEN, edit_allowed=False), 40.0)
assert state == "no-edit-window"
assert "fresh submission is the only path" in detail
def test_an_absent_edit_allowed_is_not_read_as_false():
rec = {"sid": "HH02", "status": "TWILIO_REJECTED"}
state, detail = verdict(rec, 40.0)
assert state == "edit-allowed-unset"
assert "not the same as false" in detail
def test_an_unparseable_expiration_is_treated_as_urgent():
state, _ = verdict(dict(OPEN, edit_expiration="soon"), None)
assert state == "expiration-unreadable"
def test_records_that_were_not_rejected_are_skipped():
state, _ = verdict({"status": "TWILIO_APPROVED", "edit_allowed": True}, 5.0)
assert state == "not-rejected"
def test_hours_left_reads_the_trailing_z_timestamp():
assert round(hours_left("2026-08-31T00:00:00Z", NOW)) == 24
assert round(hours_left("2026-08-29T00:00:00Z", NOW)) == -24
assert hours_left("not a date", NOW) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hoursLeft, verdict } from './twilio-tollfree-edit-window.mjs';
const OPEN = {
sid: 'HH01',
status: 'TWILIO_REJECTED',
edit_allowed: true,
edit_expiration: '2026-09-02T00:00:00Z',
};
const NOW = new Date('2026-08-30T00:00:00Z');
test('inside the horizon is the finding', () => {
const [state, detail] = verdict(OPEN, 40.0, 72.0);
assert.equal(state, 'closing');
assert.match(detail, /back of the review queue/);
});
test('outside the horizon is not a finding yet', () => {
assert.equal(verdict(OPEN, 200.0, 72.0)[0], 'open');
});
test('the timestamp wins over the boolean', () => {
const [state, detail] = verdict(OPEN, -12.0);
assert.equal(state, 'window-lapsed');
assert.match(detail, /expect the correction to be refused/);
});
test('edit_allowed false has no deadline to race', () => {
const [state, detail] = verdict({ ...OPEN, edit_allowed: false }, 40.0);
assert.equal(state, 'no-edit-window');
assert.match(detail, /fresh submission is the only path/);
});
test('an absent edit_allowed is not read as false', () => {
const [state, detail] = verdict({ sid: 'HH02', status: 'TWILIO_REJECTED' }, 40.0);
assert.equal(state, 'edit-allowed-unset');
assert.match(detail, /not the same as false/);
});
test('an unparseable expiration is treated as urgent', () => {
assert.equal(verdict({ ...OPEN, edit_expiration: 'soon' }, null)[0],
'expiration-unreadable');
});
test('records that were not rejected are skipped', () => {
assert.equal(verdict({ status: 'TWILIO_APPROVED', edit_allowed: true }, 5.0)[0],
'not-rejected');
});
test('hoursLeft reads the trailing z timestamp', () => {
assert.equal(Math.round(hoursLeft('2026-08-31T00:00:00Z', NOW)), 24);
assert.equal(Math.round(hoursLeft('2026-08-29T00:00:00Z', NOW)), -24);
assert.equal(hoursLeft('not a date', NOW), null);
});
FAQ
How is this different from reading the rejection itself?
Reading the rejection tells you whether a correction can work at all, which needs a person looking at rejection_reasons and the use case. This tells you how long that person has. They are separate checks because the deadline keeps running while the reason is still being discussed.
What happens if the window closes?
The record stays rejected and the number stays blocked. What you lose is the cheap path: instead of correcting fields on the existing verification, you file a new one and wait out the full toll-free review, which is measured in weeks rather than days.
Can edit_allowed be true after edit_expiration has passed?
It can, and that is worth reporting as a disagreement rather than trusting either field alone. The timestamp is what the platform enforces. Reading only the boolean produces a report saying the correction is available, followed by an edit that is refused.
Should the script correct the record for me?
No. Resubmitting a compliance record puts it back into review with whatever content the script had, and a wrong automatic edit burns the window it was supposed to protect. The alert names the resource, the fields and the deadline, and a person makes the change.
How often should this run?
Daily is enough for a window measured in days, with the horizon set to whatever notice your team actually needs. Running it hourly does not help: the useful output is a reminder with enough lead time for someone to schedule the work, not a countdown.
Related field notes
- A rejected toll-free verification is fixable or it is not
- An unverified toll-free number is blocked outright
- An approved bundle counting down to expiry
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.
- Toll-Free Verification resource — Twilio Docs
- Error 30032: toll-free number has not been verified — Twilio Docs
- Toll-free message verification — 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.