Skip to content

Diagnostic Twilio

a rejected toll-free verification is fixable or it is not

The verification was filed, it came back TWILIO_REJECTED, somebody read the prose, changed a sentence in the use-case summary and resubmitted. It was rejected again. The reason is a code, the code says the business category is one Twilio will not carry on US and Canadian SMS routes at all, and no edit to the summary was ever going to change that. Meanwhile the window in which cheap edits were possible has been spent on them.

Read-only key Python and Node.js Tests included
A headset near a laptop
Photo by Petr Machacek on Unsplash
The short answer

Read GET https://messaging.twilio.com/v1/Tollfree/Verifications?Status=TWILIO_REJECTED. Each record carries rejection_reason as prose, rejection_reasons as coded entries, an error_code, and the two fields that decide what to do next: edit_allowed and edit_expiration.

Split the rejections in two. A coded reason such as 30469 — illegal substances or articles, which covers cannabis, CBD, kratom, vape and fireworks — is structural: the category is not carried on these routes regardless of local legality, and no edit fixes it. Everything else is a field problem, and edit_allowed with edit_expiration says whether the in-place correction is still available or a fresh submission is required.

An unverified or pending number is a different note. This one is about the record that came back with a reason on it.

The problem in plain words

Toll-free verification is a review of a business, not a check of a form. Twilio looks at the identity, the use case, the sample content, the public website, the opt-in flow and the privacy policy behind it. When it comes back rejected, the natural reading is that something was written badly, and the natural response is to write it better and try again.

That works for one kind of rejection and cannot work for the other. Some rejections are about evidence: the opt-in is not documented, the summary does not describe what the messages actually say, the website does not mention SMS at all. Those are edits. Others are about what the business is. A prohibited category is prohibited on US and Canadian SMS routes as a matter of carrier policy, not as a matter of how convincingly you described it, and it stays prohibited in states where the underlying product is entirely legal. Resubmitting into that is a loop, and each pass through it burns days of a limited edit window and, eventually, the cheap correction path entirely.

Verificationfiledbusiness and usecaseTWILIO_REJECTEDcoded reason onthe recordProse read,code ignoredreads like writingfeedbackSummaryrewordedcategory unchangedRejected againedit window spent
Traffic is blocked with 30032 for the whole loop, and the account is billed for the attempts that never left.

Why it happens

The prose and the code say different things. rejection_reason is written for a human and reads like feedback on a document. The code in rejection_reasons[] or error_code is the machine-readable classification, and it is the one that says whether this is a category decision or a content one. Reading only the prose is what produces the resubmission loop.

The record holds a clock, and nothing counts it down for you. edit_allowed plus edit_expiration is the difference between correcting the fields on the existing submission and filing a new one from the back of the review queue. The status stays TWILIO_REJECTED either way, so nothing changes appearance when the window closes.

Legal locally is not the same as carriable. This surprises people every time. US carriers apply their own content policy to A2P traffic, and cannabis, CBD, kratom, vape and fireworks sit outside it nationally. A dispensary operating lawfully under state law reads its rejection as a mistake, appeals, and gets the same answer.

The submission that was judged is still readable. use_case_categories, use_case_summary, opt_in_type and business_website are on the record. When the summary is one line and the website field is empty, you can see what the reviewer had to work with, and that is usually a better explanation of a vague rejection than the prose is.

Every day in this state is a day of blocked traffic. Rejected means unverified, and unverified toll-free traffic to US and Canadian mobiles is blocked outright with 30032, while you are still billed for the attempts. The clock on the edit window is running at the same time as the outage.

The fix, as a flow

The code decides, not the prose. A prohibited category cannot be reworded into an approval, and every attempt spends days of a window that only matters while it is open.

GET Tollfree/Verificationsrejection_reasons, edit_allowed,expiryNot a rejectionnothing to correctFixable, window opencorrect the named fieldsFixable, window closedfresh submission, back of queueProhibited categoryno edit ever passes
edit_allowed true with an expiration already past is the trap: the field says yes and the clock says no.

How to fix it

List the rejected records rather than reading them one at a time

GET https://messaging.twilio.com/v1/Tollfree/Verifications?Status=TWILIO_REJECTED. Items come back under verifications and the next page is absolute in meta.next_page_url. An account with several toll-free numbers usually has more than one of these, and they will not all be the same kind.

Collect the codes from both places they hide

rejection_reasons[] holds the coded entries and the record also carries a top-level error_code. Different records populate different ones. Collect both, deduplicate, and compare as strings before comparing as numbers — codes arrive as integers in some responses and strings in others.

Decide structural or fixable before reading a word of the prose

A prohibited-category code such as 30469 means the answer will not change. Say so in the report, name the code, and move the conversation to whether this use case belongs on SMS at all rather than to how it was worded. Everything else is a field problem.

Read the edit window against the clock

edit_allowed true with edit_expiration in the future is the cheap path: correct the named fields on the existing record. Past the expiration, or with edit_allowed false, it is a new submission and a new place in the queue. Report the days remaining, because the difference between those two paths is measured in weeks of blocked traffic.

Look at what was actually submitted

An empty business_website, a use_case_summary of a dozen characters, or no use_case_categories at all explain a vague rejection better than the prose does. These are the gaps to close before spending the edit window, not after.

Correct in place, or file fresh, then re-run

Within the window, POST https://messaging.twilio.com/v1/Tollfree/Verifications/{Sid} with the corrected fields. Outside it, POST https://messaging.twilio.com/v1/Tollfree/Verifications as a new submission with BusinessName, BusinessWebsite, NotificationEmail, UseCaseCategories, UseCaseSummary, ProductionMessageSample, OptInType, OptInImageUrls, MessageVolume and TollfreePhoneNumberSid. For a prohibited category, neither: move the use case off SMS.

How to check it worked

Re-run the script. No record should remain at TWILIO_REJECTED, and the structural count should be zero or accounted for.

python3 twilio_tollfree_rejection_audit.py --horizon-days 2
# 2 rejected record(s), 0 structural, 0 with the edit window closing

The full code

One paginated GET, filtered by status, and three pure functions doing the thinking: where the codes hide, whether a code means the answer can change, and what the submission looked like to the person who read it. The prohibited-code set is deliberately short — it holds the code this note documents rather than a guess at the rest, because a classifier that mislabels a fixable rejection as hopeless costs more than one that says it does not know.

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_tollfree_rejection_audit.py
"""Sort rejected toll-free verifications into fixable and structural.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The correction is printed, never performed,
because a resubmission consumes the edit window and enters a review queue.
"""
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_rejection_audit")

MSG = "https://messaging.twilio.com/v1"

REJECTED = "TWILIO_REJECTED"

# Codes where the answer cannot change by editing the submission. 30469 is
# illegal substances or articles: cannabis, CBD, kratom, vape, fireworks. US
# carriers apply this nationally, so lawful under state law is not the question.
#
# Deliberately short. Guessing at codes would mean telling somebody their
# fixable rejection is hopeless, which is a worse mistake than printing the
# reason and letting them read it.
STRUCTURAL_CODES = {30469}

# A summary shorter than this cannot describe a use case, whatever it says.
MIN_SUMMARY = 40


def parse_date(value):
    """Parse an ISO 8601 timestamp into aware UTC.

    fromisoformat on Python 3.9 rejects a trailing Z, and comparing naive to
    aware raises rather than quietly returning the wrong answer.
    """
    text = str(value or "").strip()
    if not text:
        return None
    try:
        parsed = datetime.datetime.fromisoformat(text.replace("Z", "+00:00"))
    except ValueError:
        return None
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=datetime.timezone.utc)
    return parsed.astimezone(datetime.timezone.utc)


def reason_codes(verification):
    """Every coded reason on the record, in order, deduplicated. Pure.

    Codes live in two places and not every record populates both: entries in
    rejection_reasons[] carry their own code, and the record carries a top-level
    error_code. They arrive as integers in some responses and strings in others,
    so everything is normalised to a string here and compared as one.
    """
    codes = []
    for reason in verification.get("rejection_reasons") or []:
        if not isinstance(reason, dict):
            continue
        for field in ("code", "error_code"):
            if reason.get(field) is not None:
                codes.append(str(reason[field]).strip())
                break
    if verification.get("error_code") is not None:
        codes.append(str(verification["error_code"]).strip())

    seen = set()
    out = []
    for code in codes:
        if code and code not in seen:
            seen.add(code)
            out.append(code)
    return out


def is_structural(codes):
    """Whether any code means an edit cannot help. Pure."""
    for code in codes:
        try:
            if int(code) in STRUCTURAL_CODES:
                return True
        except (TypeError, ValueError):
            continue
    return False


def submission_gaps(verification):
    """What the reviewer had to work with, where it was thin. Pure.

    A vague rejection is usually explained better by the submission than by the
    prose. These are the things to fix before spending the edit window.
    """
    gaps = []
    if not str(verification.get("business_website") or "").strip():
        gaps.append("business_website is empty: the reviewer had no site on "
                    "which to find the messaging programme or the privacy policy")
    summary = str(verification.get("use_case_summary") or "").strip()
    if len(summary) < MIN_SUMMARY:
        gaps.append("use_case_summary is %d character(s): too short to describe "
                    "what the messages say or who asked for them" % len(summary))
    if not (verification.get("use_case_categories") or []):
        gaps.append("use_case_categories is empty: nothing declares what this "
                    "traffic is for")
    if not str(verification.get("opt_in_type") or "").strip():
        gaps.append("opt_in_type is unset: no consent mechanism was declared")
    return gaps


def verdict(verification, now, horizon_days=2):
    """Classify one rejected verification. Pure, so the branches can be tested
    without a rejection and without waiting for a window to close.

    Returns (state, detail).
    """
    status = str(verification.get("status") or "").strip().upper()
    if status != REJECTED:
        return ("not-rejected",
                "status is %s: this record is not a rejection, so there is "
                "nothing here to correct." % (status or "unset"))

    codes = reason_codes(verification)
    listed = ", ".join(codes) or "no code given"

    if is_structural(codes):
        return ("structural",
                "rejected on %s: the business category is not carried on US and "
                "CA SMS routes regardless of local legality. Editing the "
                "submission cannot change this answer." % listed)

    expires = parse_date(verification.get("edit_expiration"))
    days = None if expires is None else (expires - now).days

    if verification.get("edit_allowed") and (days is None or days >= 0):
        window = ("an unstated date" if days is None
                  else "%d day(s) from now" % days)
        if days is not None and days <= horizon_days:
            return ("edit-closing",
                    "rejected on %s. edit_allowed is true but the window closes "
                    "%s: correct the named fields on this record now or lose the "
                    "cheap path." % (listed, window))
        return ("editable",
                "rejected on %s. edit_allowed is true until %s, so the named "
                "fields can be corrected in place." % (listed, window))

    if verification.get("edit_allowed") and days is not None and days < 0:
        return ("resubmit",
                "rejected on %s. edit_allowed still reads true but "
                "edit_expiration passed %d day(s) ago: treat this as a fresh "
                "submission." % (listed, -days))

    return ("resubmit",
            "rejected on %s and edit_allowed is false: the in-place correction "
            "is gone and a new submission goes to the back of the review "
            "queue." % listed)


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_verifications(session, status=REJECTED, limit=500):
    """Page the toll-free verifications. Items under `verifications`, and
    meta.next_page_url is absolute."""
    url = MSG + "/Tollfree/Verifications"
    params = {"PageSize": 50}
    if status:
        params["Status"] = status
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("verifications", []))
        url = (page.get("meta") or {}).get("next_page_url")
        params = {}
    return out[:limit]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--horizon-days", type=int, default=2,
                    help="how near an edit_expiration counts as closing")
    ap.add_argument("--all", action="store_true",
                    help="read every verification rather than only rejections")
    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_verifications(session, None if args.all else REJECTED)
    if not records:
        log.info("no rejected toll-free verifications on this account")
        return 0

    structural = closing = found = 0
    for record in records:
        state, detail = verdict(record, now, args.horizon_days)
        sid = record.get("sid", "?")
        line = "%-13s %s  %s" % (state, sid, detail)
        if state == "not-rejected":
            log.info(line)
            continue

        found += 1
        if state == "structural":
            structural += 1
        elif state == "edit-closing":
            closing += 1
        log.warning(line)

        for gap in submission_gaps(record):
            log.warning("  %s", gap)
        prose = str(record.get("rejection_reason") or "").strip()
        if prose:
            log.warning("  reviewer note: %s", prose)

        if state == "structural":
            log.warning("  repair: none through this resource. Move the use case "
                        "off US and CA SMS, or carry it on a channel that "
                        "permits the category.")
        elif state in ("editable", "edit-closing"):
            log.warning("  repair: send the corrected fields to %s/Tollfree/"
                        "Verifications/%s before edit_expiration", MSG, sid)
        else:
            log.warning("  repair: file a fresh submission at %s/Tollfree/"
                        "Verifications with BusinessName, BusinessWebsite, "
                        "NotificationEmail, UseCaseCategories, UseCaseSummary, "
                        "ProductionMessageSample, OptInType, OptInImageUrls, "
                        "MessageVolume and TollfreePhoneNumberSid", MSG)

    log.info("%d rejected record(s), %d structural, %d with the edit window "
             "closing", found, structural, closing)
    return 1 if found else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-tollfree-rejection-audit.mjs
/**
 * Sort rejected toll-free verifications into fixable and structural.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The correction is printed, never
 * performed, because a resubmission consumes the edit window and enters a
 * review queue.
 */
const MSG = 'https://messaging.twilio.com/v1';

const REJECTED = 'TWILIO_REJECTED';

// Codes where the answer cannot change by editing the submission. 30469 is
// illegal substances or articles: cannabis, CBD, kratom, vape, fireworks. US
// carriers apply this nationally, so lawful under state law is not the question.
//
// Deliberately short. Guessing at codes would mean telling somebody their
// fixable rejection is hopeless, which is the worse mistake.
const STRUCTURAL_CODES = new Set([30469]);

// A summary shorter than this cannot describe a use case, whatever it says.
const MIN_SUMMARY = 40;

/** Parse an ISO 8601 timestamp. */
export function parseDate(value) {
  const text = String(value ?? '').trim();
  if (!text) return null;
  const ms = Date.parse(text);
  return Number.isNaN(ms) ? null : new Date(ms);
}

/**
 * Every coded reason on the record, in order, deduplicated. Pure.
 *
 * Codes live in two places and not every record populates both: entries in
 * rejection_reasons[] carry their own code, and the record carries a top-level
 * error_code. They arrive as integers in some responses and strings in others.
 */
export function reasonCodes(verification) {
  const codes = [];
  for (const reason of verification.rejection_reasons ?? []) {
    if (!reason || typeof reason !== 'object') continue;
    for (const field of ['code', 'error_code']) {
      if (reason[field] != null) { codes.push(String(reason[field]).trim()); break; }
    }
  }
  if (verification.error_code != null) {
    codes.push(String(verification.error_code).trim());
  }
  return [...new Set(codes.filter(Boolean))];
}

/** Whether any code means an edit cannot help. Pure. */
export function isStructural(codes) {
  return codes.some((code) => {
    const n = Number.parseInt(code, 10);
    return Number.isInteger(n) && STRUCTURAL_CODES.has(n);
  });
}

/**
 * What the reviewer had to work with, where it was thin. Pure. A vague
 * rejection is usually explained better by the submission than by the prose.
 */
export function submissionGaps(verification) {
  const gaps = [];
  if (!String(verification.business_website ?? '').trim()) {
    gaps.push('business_website is empty: the reviewer had no site on which to ' +
              'find the messaging programme or the privacy policy');
  }
  const summary = String(verification.use_case_summary ?? '').trim();
  if (summary.length < MIN_SUMMARY) {
    gaps.push(`use_case_summary is ${summary.length} character(s): too short to ` +
              'describe what the messages say or who asked for them');
  }
  if ((verification.use_case_categories ?? []).length === 0) {
    gaps.push('use_case_categories is empty: nothing declares what this traffic ' +
              'is for');
  }
  if (!String(verification.opt_in_type ?? '').trim()) {
    gaps.push('opt_in_type is unset: no consent mechanism was declared');
  }
  return gaps;
}

/**
 * Classify one rejected verification. Pure, so the branches can be tested
 * without a rejection and without waiting for a window to close.
 *
 * Returns [state, detail].
 */
export function verdict(verification, now, horizonDays = 2) {
  const status = String(verification.status ?? '').trim().toUpperCase();
  if (status !== REJECTED) {
    return ['not-rejected',
      `status is ${status || 'unset'}: this record is not a rejection, so there ` +
      'is nothing here to correct.'];
  }

  const codes = reasonCodes(verification);
  const listed = codes.join(', ') || 'no code given';

  if (isStructural(codes)) {
    return ['structural',
      `rejected on ${listed}: the business category is not carried on US and CA ` +
      'SMS routes regardless of local legality. Editing the submission cannot ' +
      'change this answer.'];
  }

  const expires = parseDate(verification.edit_expiration);
  const days = expires === null
    ? null
    : Math.floor((expires.getTime() - now.getTime()) / 86400000);

  if (verification.edit_allowed && (days === null || days >= 0)) {
    const window = days === null ? 'an unstated date' : `${days} day(s) from now`;
    if (days !== null && days <= horizonDays) {
      return ['edit-closing',
        `rejected on ${listed}. edit_allowed is true but the window closes ` +
        `${window}: correct the named fields on this record now or lose the ` +
        'cheap path.'];
    }
    return ['editable',
      `rejected on ${listed}. edit_allowed is true until ${window}, so the named ` +
      'fields can be corrected in place.'];
  }

  if (verification.edit_allowed && days !== null && days < 0) {
    return ['resubmit',
      `rejected on ${listed}. edit_allowed still reads true but edit_expiration ` +
      `passed ${-days} day(s) ago: treat this as a fresh submission.`];
  }

  return ['resubmit',
    `rejected on ${listed} and edit_allowed is false: the in-place correction is ` +
    'gone and a new submission goes to the back of the review queue.'];
}

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();
}

/** Page the toll-free verifications: items under `verifications`. */
export async function listVerifications(auth, status = REJECTED, limit = 500) {
  let url = `${MSG}/Tollfree/Verifications`;
  let params = status ? { PageSize: 50, Status: status } : { PageSize: 50 };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.verifications ?? []));
    url = page.meta?.next_page_url ?? null;
    params = {};
  }
  return out.slice(0, limit);
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  if (!account || !key || !secret) {
    console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
                  '(an API Key with read access, not the auth token)');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);
  const flag = process.argv.indexOf('--horizon-days');
  const horizonDays = flag === -1 ? 2 : Number.parseInt(process.argv[flag + 1], 10);
  const now = new Date();

  const records = await listVerifications(
    auth, process.argv.includes('--all') ? null : REJECTED);
  if (records.length === 0) {
    console.log('no rejected toll-free verifications on this account');
    return;
  }

  let structural = 0;
  let closing = 0;
  let found = 0;
  for (const record of records) {
    const [state, detail] = verdict(record, now, horizonDays);
    const sid = record.sid ?? '?';
    const line = `${state.padEnd(13)} ${sid}  ${detail}`;
    if (state === 'not-rejected') { console.log(line); continue; }

    found += 1;
    if (state === 'structural') structural += 1;
    else if (state === 'edit-closing') closing += 1;
    console.warn(line);

    for (const gap of submissionGaps(record)) console.warn(`  ${gap}`);
    const prose = String(record.rejection_reason ?? '').trim();
    if (prose) console.warn(`  reviewer note: ${prose}`);

    if (state === 'structural') {
      console.warn('  repair: none through this resource. Move the use case off ' +
                   'US and CA SMS, or carry it on a channel that permits the ' +
                   'category.');
    } else if (state === 'editable' || state === 'edit-closing') {
      console.warn(`  repair: send the corrected fields to ${MSG}/Tollfree/` +
                   `Verifications/${sid} before edit_expiration`);
    } else {
      console.warn(`  repair: file a fresh submission at ${MSG}/Tollfree/` +
                   'Verifications with BusinessName, BusinessWebsite, ' +
                   'NotificationEmail, UseCaseCategories, UseCaseSummary, ' +
                   'ProductionMessageSample, OptInType, OptInImageUrls, ' +
                   'MessageVolume and TollfreePhoneNumberSid');
    }
  }

  console.log(`${found} rejected record(s), ${structural} structural, ` +
              `${closing} with the edit window closing`);
  process.exitCode = found ? 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 case the whole note turns on is the first one pinned: a structural code on a record whose edit_allowed is true must not be reported as editable, because that is precisely the report that sends somebody back into the resubmission loop. After that, the clock: an edit_expiration in the past has to override an edit_allowed that still reads true, and a code that arrives as a string has to classify the same as one that arrives as an integer.

test_twilio_tollfree_rejection_audit.py
import datetime

from twilio_tollfree_rejection_audit import (is_structural, reason_codes,
                                             submission_gaps, verdict)

NOW = datetime.datetime(2026, 8, 30, tzinfo=datetime.timezone.utc)


def make(**kw):
    record = {"sid": "HH00000000000000000000000000000001",
              "status": "TWILIO_REJECTED",
              "rejection_reason": "opt-in evidence was not found on the website",
              "rejection_reasons": [{"code": 30452,
                                     "description": "opt-in not documented"}],
              "error_code": None,
              "edit_allowed": True,
              "edit_expiration": "2026-09-05T00:00:00Z",
              "business_website": "https://example.com",
              "use_case_categories": ["TWO_FACTOR_AUTHENTICATION"],
              "use_case_summary": "One-time passcodes sent to customers who ask "
                                  "for them at sign-in.",
              "opt_in_type": "WEB_FORM"}
    record.update(kw)
    return record


def test_a_prohibited_category_beats_an_open_edit_window():
    # The case the whole script exists for. edit_allowed is true, so a naive
    # reader sends somebody off to reword a summary that was never the problem.
    state, detail = verdict(make(rejection_reasons=[{"code": 30469}]), NOW)
    assert state == "structural"
    assert "regardless of local legality" in detail
    assert "30469" in detail


def test_codes_classify_the_same_as_integers_or_strings():
    assert is_structural(["30469"]) is True
    assert is_structural([30469]) is True
    assert is_structural(["30452"]) is False
    assert is_structural(["not a code", None]) is False


def test_codes_are_collected_from_the_array_and_the_top_level():
    codes = reason_codes(make(rejection_reasons=[{"code": 30452},
                                                 {"error_code": "30453"}],
                              error_code=30452))
    assert codes == ["30452", "30453"]
    assert reason_codes(make(rejection_reasons=[], error_code=None)) == []


def test_an_open_window_is_the_cheap_path():
    state, detail = verdict(make(), NOW)
    assert state == "editable"
    assert "6 day(s) from now" in detail


def test_a_window_about_to_close_is_its_own_state():
    state, detail = verdict(make(edit_expiration="2026-08-31T00:00:00Z"), NOW)
    assert state == "edit-closing"
    assert "lose the cheap path" in detail


def test_an_expired_window_overrides_edit_allowed():
    state, detail = verdict(make(edit_expiration="2026-08-20T00:00:00Z"), NOW)
    assert state == "resubmit"
    assert "10 day(s) ago" in detail


def test_edit_allowed_false_is_a_fresh_submission():
    state, detail = verdict(make(edit_allowed=False), NOW)
    assert state == "resubmit"
    assert "back of the review queue" in detail


def test_a_record_that_is_not_a_rejection_is_left_alone():
    state, _ = verdict(make(status="TWILIO_APPROVED"), NOW)
    assert state == "not-rejected"


def test_gaps_name_what_the_reviewer_had_to_work_with():
    assert submission_gaps(make()) == []
    gaps = submission_gaps(make(business_website="", use_case_summary="OTPs",
                                use_case_categories=[], opt_in_type=""))
    assert len(gaps) == 4
    assert any("business_website" in g for g in gaps)
    assert any("4 character(s)" in g for g in gaps)
twilio-tollfree-rejection-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  isStructural, reasonCodes, submissionGaps, verdict,
} from './twilio-tollfree-rejection-audit.mjs';

const NOW = new Date('2026-08-30T00:00:00Z');

const make = (over = {}) => ({
  sid: 'HH00000000000000000000000000000001',
  status: 'TWILIO_REJECTED',
  rejection_reason: 'opt-in evidence was not found on the website',
  rejection_reasons: [{ code: 30452, description: 'opt-in not documented' }],
  error_code: null,
  edit_allowed: true,
  edit_expiration: '2026-09-05T00:00:00Z',
  business_website: 'https://example.com',
  use_case_categories: ['TWO_FACTOR_AUTHENTICATION'],
  use_case_summary: 'One-time passcodes sent to customers who ask for them at sign-in.',
  opt_in_type: 'WEB_FORM',
  ...over,
});

test('a prohibited category beats an open edit window', () => {
  const [state, detail] = verdict(make({ rejection_reasons: [{ code: 30469 }] }), NOW);
  assert.equal(state, 'structural');
  assert.match(detail, /regardless of local legality/);
  assert.match(detail, /30469/);
});

test('codes classify the same as integers or strings', () => {
  assert.equal(isStructural(['30469']), true);
  assert.equal(isStructural([30469]), true);
  assert.equal(isStructural(['30452']), false);
  assert.equal(isStructural(['not a code', null]), false);
});

test('codes are collected from the array and the top level', () => {
  const codes = reasonCodes(make({
    rejection_reasons: [{ code: 30452 }, { error_code: '30453' }],
    error_code: 30452 }));
  assert.deepEqual(codes, ['30452', '30453']);
  assert.deepEqual(reasonCodes(make({ rejection_reasons: [], error_code: null })), []);
});

test('an open window is the cheap path', () => {
  const [state, detail] = verdict(make(), NOW);
  assert.equal(state, 'editable');
  assert.match(detail, /6 day\(s\) from now/);
});

test('a window about to close is its own state', () => {
  const [state, detail] = verdict(make({ edit_expiration: '2026-08-31T00:00:00Z' }), NOW);
  assert.equal(state, 'edit-closing');
  assert.match(detail, /lose the cheap path/);
});

test('an expired window overrides edit_allowed', () => {
  const [state, detail] = verdict(make({ edit_expiration: '2026-08-20T00:00:00Z' }), NOW);
  assert.equal(state, 'resubmit');
  assert.match(detail, /10 day\(s\) ago/);
});

test('edit_allowed false is a fresh submission', () => {
  const [state, detail] = verdict(make({ edit_allowed: false }), NOW);
  assert.equal(state, 'resubmit');
  assert.match(detail, /back of the review queue/);
});

test('a record that is not a rejection is left alone', () => {
  assert.equal(verdict(make({ status: 'TWILIO_APPROVED' }), NOW)[0], 'not-rejected');
});

test('gaps name what the reviewer had to work with', () => {
  assert.deepEqual(submissionGaps(make()), []);
  const gaps = submissionGaps(make({
    business_website: '', use_case_summary: 'OTPs',
    use_case_categories: [], opt_in_type: '' }));
  assert.equal(gaps.length, 4);
  assert.ok(gaps.some((g) => g.includes('business_website')));
  assert.ok(gaps.some((g) => g.includes('4 character(s)')));
});

FAQ

How do I tell a structural rejection from a fixable one?

By the code, not the prose. A prohibited-category code such as 30469 says the business is outside what US carriers will carry on A2P SMS, and no version of the submission passes. Anything else is about evidence: the opt-in flow, the summary, the sample content, the website. The prose reads the same in both cases, which is why the loop happens.

Our product is legal in our state. Why is this a rejection at all?

Because the decision is a carrier content policy applied nationally, not a legal judgement about your jurisdiction. Cannabis, CBD, kratom, vape and fireworks sit outside it whatever the local law says. Appealing tends to produce the same answer, and the time it takes is time the toll-free traffic stays blocked.

What exactly does the edit window change?

Whether you correct the record you have or file a new one. Inside the window, a POST to the existing verification SID with corrected fields keeps your place. Outside it, you file fresh and go to the back of the review queue, which on toll-free is measured in weeks. Nothing about the record's appearance changes when the window closes, so it has to be read.

The rejection prose is vague. Where else can I look?

At what was submitted. use_case_summary, use_case_categories, opt_in_type and business_website are all on the record, and an empty website field or a twelve-character summary explains a vague rejection better than the note does. The script prints those gaps before the prose for that reason.

Can the script correct the record for me?

No. Every path out of a rejection is a submission into a human review queue, with a limited window and a real cost to getting it wrong twice. The script tells you which path applies, how long you have, and what was thin about the last attempt; a person writes the correction.

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.