Skip to content

Diagnostic Twilio

error 30036: messages expire in the queue before they send

The messages were accepted. They were never sent. error_code 30036 means the message sat in the sender's queue until its ValidityPeriod ran out and Twilio dropped it — a deadline your own code set, enforced against a queue whose depth your own code did not know.

Read-only key Python and Node.js Tests included
Green circuit board with a central processor and glowing components.
Photo by Brecht Corbeel on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and count rows where error_code is 30036 (expired in queue), 30045 (validity period outside 1–36,000 seconds) or 30012 (TTL below what the route accepts), bucketed by messaging_service_sid or from.

Then read the sender's cap: GET https://messaging.twilio.com/v1/Services/{ServiceSid} and look at validity_period. A service well under 36,000 with 30036s behind it is the cause. A service at the default with 30036s anyway means the deadline came from the send call — or the queue really is ten hours deep, which is a throughput problem wearing a TTL error code.

The problem in plain words

ValidityPeriod is a promise about time that gets set once, usually early, usually by someone reasoning about the message rather than about the queue. Five minutes sounds right for a one-time passcode: if it has not gone in five minutes it is useless anyway. That is true of the passcode and false of the mechanism, because the deadline is enforced from the moment Twilio accepts the message, not from the moment your sender is free.

So the failure only appears under load. At low volume the queue drains in a second and no deadline is ever reached. Then a campaign, a retry storm or a Monday morning puts four thousand messages in front of a long code that clears about one per second, and everything with a five-minute ceiling behind position three hundred dies without ever being transmitted. The API returned 201 for every one. The error arrives later, asynchronously, on messages your code already considers sent.

What makes it hard to read is that three different error codes describe three different moments. 30045 and 30012 are request-time rejections: nothing queued at all, and the fix is in the caller. 30036 is a queue timeout: the message queued, waited, and lost. Pooling them produces a count that points at no repair in particular.

Five minute TTLsensible for apasscodeCampaign queuesfirst4000 segmentsaheadLong codedrainsabout one persecondDeadline passesstill waiting itsturnDropped, 30036never transmitted
The deadline is measured from acceptance, not from transmission, so every second spent waiting for the sender counts against it.

Why it happens

The deadline is measured against queue time, not send time. ValidityPeriod starts when Twilio accepts the message. Everything spent waiting for a sender to be free counts against it, which is exactly the interval your application has no visibility into.

It can be set in two places and the lower one wins. A Messaging Service carries a validity_period for everything sent through it, and a send call can pass its own. Reading only the service explains half the findings; reading only the messages explains none of them.

The default is 36,000 seconds, and that is not a safe answer either. Ten hours means a passcode can arrive long after the user gave up and requested three more. Raising the ceiling stops the 30036s and converts them into very late deliveries, which for time-critical traffic is worse. The right number depends on what the message is for.

The Messages list cannot be filtered by error. No ErrorCode parameter and no Status parameter exist — only To, From, DateSent and paging. All three codes have to be counted client-side after paging the window.

The fix, as a flow

The three codes are counted apart rather than summed, because 30045 and 30012 are refused at request time and never queue at all. Changing the service setting cannot touch a send that was rejected outright.

Message counts plusvalidity_periodper sender, from two APIsNo expiriesqueue drains in time30045 or 30012rejected outright, fix the callerService cap far too lowraise it and widen the poolCap is the defaultsend call or a ten hour queue
A short deadline and a deep queue produce the same code. Only the service level cap tells you which of the two you are looking at.

How to fix it

Page the Messages list and count the three codes separately

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. Keep 30036, 30045 and 30012 in three separate counters. They are three different moments in the send and they have three different repairs.

Bucket by the sender that owns the queue

Use messaging_service_sid when it is set and from otherwise. The queue that filled up belongs to a sender, so an account-wide expiry count tells you nothing actionable; a per-sender rate tells you which pool is too narrow for its traffic.

Read validity_period off each Messaging Service

GET https://messaging.twilio.com/v1/Services/{ServiceSid} for every MG bucket you found, and read validity_period. This is the one number that decides whether the deadline is a service setting somebody can change or a per-message argument in the sending code.

Handle 30045 and 30012 first, in the caller

Neither of these ever reached a queue. 30045 means a ValidityPeriod outside the permitted 1 to 36,000 seconds — usually a unit mix-up, milliseconds where seconds were meant. 30012 means the TTL asked for is below what the route will accept. Both are fixed where the send is constructed, and both will keep firing no matter what you do to the service.

Match the deadline to the traffic, then widen the pool

POST https://messaging.twilio.com/v1/Services/{ServiceSid} with a ValidityPeriod that suits the traffic — long for marketing, deliberately short for passcodes so a stale code fails rather than arrives. Then fix the actual cause by adding senders to the pool or rate-limiting the producer, because a deadline is only ever a symptom of a queue that is too long.

How to check it worked

Re-run over the same window after the change. Every sender should read clean, and no sender should be in out-of-range or ttl-too-small.

python3 twilio_validity_period_audit.py --days 7
# 3 sender(s) over 7 day(s), 0 with an expiry problem

The full code

One paginated GET over the Messages list, plus one GET per Messaging Service that actually appears in the results — an API Key with read access covers both. The tally and the verdict are pure. The verdict's ordering is the interesting part: the request-time codes are checked before the queue-timeout code, because when both are present the caller is the thing to fix and the service setting is a distraction.

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_validity_period_audit.py
"""Report Twilio messages that expired in the queue (30036) and the TTL rejections near it.

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,
because this script holds a credential to an account that can send messages and
spend money.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("twilio_validity_period_audit")

HOST = "https://api.twilio.com"
BASE = HOST + "/2010-04-01"
MESSAGING = "https://messaging.twilio.com/v1"

EXPIRED = 30036         # queued past its ValidityPeriod and dropped
OUT_OF_RANGE = 30045    # ValidityPeriod outside 1..36000, rejected at request time
TTL_TOO_SMALL = 30012   # TTL below what the route accepts, rejected at request time

MAX_VALIDITY = 36000


def error_code(message):
    """Read error_code as an integer, or None.

    Null on healthy messages, a number on failed ones, and a string often enough
    that comparing the raw value against 30036 quietly reports nothing.
    """
    raw = message.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def tally(messages):
    """Bucket the three TTL codes by the sender whose queue they waited in.

    Pure, so the grouping can be tested without a network. The codes are kept
    apart rather than summed: 30045 and 30012 never reached a queue at all, so
    folding them into the expiry count produces a number that points at no
    particular repair.
    """
    out = {}
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        key = m.get("messaging_service_sid") or m.get("from") or "unknown sender"
        row = out.setdefault(key, {"total": 0, "expired": 0, "out_of_range": 0,
                                   "ttl_too_small": 0, "sids": []})
        row["total"] += 1
        code = error_code(m)
        if code == EXPIRED:
            row["expired"] += 1
        elif code == OUT_OF_RANGE:
            row["out_of_range"] += 1
        elif code == TTL_TOO_SMALL:
            row["ttl_too_small"] += 1
        else:
            continue
        if len(row["sids"]) < 3:
            row["sids"].append(m.get("sid"))
    return out


def verdict(stats, validity_period=None, floor=3600):
    """Classify one sender. Pure, so the ordering and the thresholds are visible.

    validity_period is the service-level cap in seconds, or None when the sender
    is a bare From number with no Messaging Service behind it.

    The request-time codes are checked first on purpose. When both kinds are
    present the caller is constructing bad sends, and changing the service
    setting fixes none of those.

    Returns (state, detail).
    """
    total = int(stats.get("total") or 0)
    expired = int(stats.get("expired") or 0)
    out_of_range = int(stats.get("out_of_range") or 0)
    ttl_too_small = int(stats.get("ttl_too_small") or 0)

    if out_of_range:
        return ("out-of-range",
                "%d message(s) rejected with 30045. ValidityPeriod has to be 1 to "
                "%d seconds and something is passing a value outside that, so "
                "those sends never entered a queue. Usually a unit mix-up: "
                "milliseconds where seconds were meant."
                % (out_of_range, MAX_VALIDITY))

    if ttl_too_small:
        return ("ttl-too-small",
                "%d message(s) rejected with 30012: the TTL asked for is below "
                "what the route will accept, so the send was refused before "
                "anything was queued. Fix it where the send is built."
                % ttl_too_small)

    if not expired:
        return ("clean", "%d message(s), none expired in queue" % total)

    rate = (expired / total) if total else 1.0

    if validity_period is not None and validity_period < floor:
        return ("service-too-low",
                "%d of %d expired with 30036 (%.1f%%) and this Messaging Service "
                "caps every message at %d second(s). The queue in front of these "
                "messages is deeper than that deadline, so they died waiting for "
                "a sender that was never going to be free in time."
                % (expired, total, rate * 100, validity_period))

    allowed = ("no service-level cap" if validity_period is None
               else "the service allows %d second(s)" % validity_period)
    return ("per-message",
            "%d of %d expired with 30036 (%.1f%%) while there is %s. The short "
            "deadline is coming from the send call itself, or the queue really is "
            "hours deep, which is a throughput problem wearing a TTL error code."
            % (expired, total, rate * 100, allowed))


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_messages(session, account, since, limit):
    """Page Messages.json. No Status or ErrorCode filter exists on this resource,
    so the date window and the page cap are the only bounds available."""
    url = "%s/Accounts/%s/Messages.json" % (BASE, account)
    params = {"PageSize": 1000, "DateSent>=": since}
    out = []
    while url and len(out) < limit:
        page = get(session, url, **params)
        out.extend(page.get("messages", []))
        nxt = page.get("next_page_uri")
        url, params = (HOST + nxt) if nxt else None, {}
    return out[:limit]


def service_validity(session, service_sid):
    """Read validity_period off one Messaging Service, or None if it is not
    readable. A bare From number has no service and therefore no cap."""
    if not str(service_sid or "").startswith("MG"):
        return None
    svc = get(session, "%s/Services/%s" % (MESSAGING, service_sid))
    raw = svc.get("validity_period")
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=7,
                    help="how far back to read the Messages list")
    ap.add_argument("--max-messages", type=int, default=20000,
                    help="stop paging after this many messages")
    ap.add_argument("--floor", type=int, default=3600,
                    help="a service cap below this is treated as the cause")
    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 = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    messages = list_messages(session, account, since, args.max_messages)
    if not messages:
        log.info("no messages sent since %s", since)
        return 0

    senders = tally(messages)
    bad = 0
    for sender, stats in sorted(senders.items()):
        cap = service_validity(session, sender)
        state, detail = verdict(stats, cap, args.floor)
        line = "%-15s %s  %s" % (state, sender, detail)
        if state == "clean":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  message sids: %s", ", ".join(str(s) for s in stats["sids"]))
        if state in ("out-of-range", "ttl-too-small"):
            log.warning("  repair: fix the ValidityPeriod argument where the send "
                        "is constructed. It must be 1 to %d seconds, and no "
                        "service setting can rescue a rejected request.",
                        MAX_VALIDITY)
        elif state == "service-too-low":
            log.warning("  repair: raise the cap with a write to %s/Services/%s "
                        "(ValidityPeriod), then widen the sender pool so the "
                        "queue drains inside the new deadline.", MESSAGING, sender)
        else:
            log.warning("  repair: stop passing a short per-message "
                        "ValidityPeriod, and add senders to the pool or rate "
                        "limit the producer. The deadline is the symptom; the "
                        "queue length is the problem.")

    log.info("%d sender(s) over %d day(s), %d with an expiry problem",
             len(senders), args.days, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-validity-period-audit.mjs
/**
 * Report Twilio messages that expired in the queue (30036) and the TTL rejections
 * near it.
 *
 * Read only. GET requests and nothing else: give this an API Key with read
 * access rather than the account auth token. The repair is printed, never
 * performed.
 */
const HOST = 'https://api.twilio.com';
const BASE = `${HOST}/2010-04-01`;
const MESSAGING = 'https://messaging.twilio.com/v1';

const EXPIRED = 30036;        // queued past its ValidityPeriod and dropped
const OUT_OF_RANGE = 30045;   // ValidityPeriod outside 1..36000, rejected outright
const TTL_TOO_SMALL = 30012;  // TTL below what the route accepts, rejected outright

const MAX_VALIDITY = 36000;

/**
 * Read error_code as a number, or null. Null on healthy messages, a number on
 * failed ones, and a string often enough that comparing the raw value against
 * 30036 quietly reports nothing.
 */
export function errorCode(message) {
  const raw = message.error_code;
  if (raw === null || raw === undefined || raw === '') return null;
  const n = Number(raw);
  return Number.isFinite(n) ? n : null;
}

/**
 * Bucket the three TTL codes by the sender whose queue they waited in. Pure, so
 * the grouping can be tested without a network. The codes are kept apart rather
 * than summed: 30045 and 30012 never reached a queue at all.
 */
export function tally(messages) {
  const out = new Map();
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    const key = m.messaging_service_sid || m.from || 'unknown sender';
    if (!out.has(key)) {
      out.set(key, { total: 0, expired: 0, out_of_range: 0, ttl_too_small: 0,
                     sids: [] });
    }
    const row = out.get(key);
    row.total += 1;
    const code = errorCode(m);
    if (code === EXPIRED) row.expired += 1;
    else if (code === OUT_OF_RANGE) row.out_of_range += 1;
    else if (code === TTL_TOO_SMALL) row.ttl_too_small += 1;
    else continue;
    if (row.sids.length < 3) row.sids.push(m.sid);
  }
  return out;
}

/**
 * Classify one sender. Pure, so the ordering and the thresholds are visible.
 * validityPeriod is the service-level cap in seconds, or null for a bare From
 * number. The request-time codes are checked first on purpose: when both kinds
 * are present the caller is building bad sends and the service setting fixes
 * none of them. Returns [state, detail].
 */
export function verdict(stats, validityPeriod = null, floor = 3600) {
  const total = Number(stats.total ?? 0);
  const expired = Number(stats.expired ?? 0);
  const outOfRange = Number(stats.out_of_range ?? 0);
  const ttlTooSmall = Number(stats.ttl_too_small ?? 0);

  if (outOfRange) {
    return ['out-of-range',
      `${outOfRange} message(s) rejected with 30045. ValidityPeriod has to be 1 ` +
      `to ${MAX_VALIDITY} seconds and something is passing a value outside that, ` +
      'so those sends never entered a queue. Usually a unit mix-up: milliseconds ' +
      'where seconds were meant.'];
  }

  if (ttlTooSmall) {
    return ['ttl-too-small',
      `${ttlTooSmall} message(s) rejected with 30012: the TTL asked for is below ` +
      'what the route will accept, so the send was refused before anything was ' +
      'queued. Fix it where the send is built.'];
  }

  if (!expired) return ['clean', `${total} message(s), none expired in queue`];

  const rate = total ? expired / total : 1;
  const pct = (rate * 100).toFixed(1);

  if (validityPeriod !== null && validityPeriod !== undefined
      && validityPeriod < floor) {
    return ['service-too-low',
      `${expired} of ${total} expired with 30036 (${pct}%) and this Messaging ` +
      `Service caps every message at ${validityPeriod} second(s). The queue in ` +
      'front of these messages is deeper than that deadline, so they died ' +
      'waiting for a sender that was never going to be free in time.'];
  }

  const allowed = (validityPeriod === null || validityPeriod === undefined)
    ? 'no service-level cap'
    : `the service allows ${validityPeriod} second(s)`;
  return ['per-message',
    `${expired} of ${total} expired with 30036 (${pct}%) while there is ${allowed}. ` +
    'The short deadline is coming from the send call itself, or the queue really ' +
    'is hours deep, which is a throughput problem wearing a TTL error code.'];
}

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 listMessages(auth, account, since, limit = 20000) {
  let url = `${BASE}/Accounts/${account}/Messages.json`;
  let params = { PageSize: 1000, 'DateSent>=': since };
  const out = [];
  while (url && out.length < limit) {
    const page = await get(auth, url, params);
    out.push(...(page.messages ?? []));
    url = page.next_page_uri ? HOST + page.next_page_uri : null;
    params = {};
  }
  return out.slice(0, limit);
}

async function serviceValidity(auth, serviceSid) {
  if (!String(serviceSid ?? '').startsWith('MG')) return null;
  const svc = await get(auth, `${MESSAGING}/Services/${serviceSid}`);
  const n = Number(svc.validity_period);
  return Number.isFinite(n) ? n : null;
}

async function main() {
  const account = process.env.TWILIO_ACCOUNT_SID;
  const key = process.env.TWILIO_API_KEY;
  const secret = process.env.TWILIO_API_SECRET;
  if (!account || !key || !secret) {
    console.error('set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET ' +
                  '(an API Key with read access, not the auth token)');
    process.exitCode = 2;
    return;
  }
  const auth = authHeader(key, secret);

  const days = Number(process.argv.includes('--days')
    ? process.argv[process.argv.indexOf('--days') + 1] : 7) || 7;
  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);

  const messages = await listMessages(auth, account, since);
  if (messages.length === 0) {
    console.log(`no messages sent since ${since}`);
    return;
  }

  const senders = tally(messages);
  let bad = 0;
  for (const [sender, stats] of [...senders.entries()].sort()) {
    const cap = await serviceValidity(auth, sender);
    const [state, detail] = verdict(stats, cap);
    const line = `${state.padEnd(15)} ${sender}  ${detail}`;
    if (state === 'clean') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  message sids: ${stats.sids.join(', ')}`);
    if (state === 'out-of-range' || state === 'ttl-too-small') {
      console.warn('  repair: fix the ValidityPeriod argument where the send is ' +
                   `constructed. It must be 1 to ${MAX_VALIDITY} seconds, and no ` +
                   'service setting can rescue a rejected request.');
    } else if (state === 'service-too-low') {
      console.warn(`  repair: raise the cap with a write to ${MESSAGING}/Services/` +
                   `${sender} (ValidityPeriod), then widen the sender pool so the ` +
                   'queue drains inside the new deadline.');
    } else {
      console.warn('  repair: stop passing a short per-message ValidityPeriod, ' +
                   'and add senders to the pool or rate limit the producer. The ' +
                   'deadline is the symptom; the queue length is the problem.');
    }
  }

  console.log(`${senders.size} sender(s) over ${days} day(s), ${bad} with an ` +
              'expiry problem');
  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

Precedence is the thing worth pinning. A sender with both a 30045 and fifty 30036s reports out-of-range, not service-too-low, because the rejected sends never queued and no service change touches them. After that, the same expiry count reads two different ways depending on one number read from a different API: a 300-second service cap makes it the service's fault, the 36,000-second default makes it the send call's or the pool's.

test_twilio_validity_period_audit.py
from twilio_validity_period_audit import error_code, tally, verdict


def msg(sid, code=None, sender="+15550001111"):
    return {"sid": sid, "from": sender, "status": "undelivered",
            "error_code": code, "direction": "outbound-api"}


def test_error_code_reads_strings_and_numbers_the_same():
    assert error_code({"error_code": 30036}) == 30036
    assert error_code({"error_code": "30036"}) == 30036
    assert error_code({"error_code": None}) is None
    assert error_code({}) is None


def test_tally_keeps_the_three_codes_apart():
    rows = tally([msg("SM1", 30036), msg("SM2", 30045), msg("SM3", 30012),
                  msg("SM4")])
    row = rows["+15550001111"]
    assert row["total"] == 4
    assert row["expired"] == 1
    assert row["out_of_range"] == 1
    assert row["ttl_too_small"] == 1


def test_tally_groups_on_the_messaging_service_when_there_is_one():
    m = msg("SM1", 30036)
    m["messaging_service_sid"] = "MG1"
    rows = tally([m])
    assert set(rows) == {"MG1"}


def test_tally_ignores_inbound_and_caps_the_sids():
    rows = tally([msg("SM%d" % i, 30036) for i in range(7)]
                 + [{"sid": "SM9", "direction": "inbound", "status": "received"}])
    assert rows["+15550001111"]["sids"] == ["SM0", "SM1", "SM2"]
    assert len(rows) == 1


def test_no_expiries_is_clean():
    state, detail = verdict({"total": 400, "expired": 0})
    assert state == "clean"
    assert "400" in detail


def test_a_request_time_rejection_outranks_the_queue_timeout():
    # 30045 never queued, so the service cap is irrelevant to it. Reporting
    # service-too-low here would send someone to change the wrong setting.
    state, detail = verdict({"total": 100, "expired": 50, "out_of_range": 1},
                            validity_period=300)
    assert state == "out-of-range"
    assert "36000" in detail


def test_a_ttl_below_the_route_minimum_is_its_own_state():
    state, detail = verdict({"total": 100, "expired": 50, "ttl_too_small": 2},
                            validity_period=300)
    assert state == "ttl-too-small"
    assert "before anything was queued" in detail


def test_a_low_service_cap_behind_expiries_is_the_cause():
    state, detail = verdict({"total": 100, "expired": 40}, validity_period=300)
    assert state == "service-too-low"
    assert "300 second(s)" in detail


def test_expiries_at_the_default_cap_point_at_the_send_call_or_the_queue():
    state, detail = verdict({"total": 100, "expired": 40}, validity_period=36000)
    assert state == "per-message"
    assert "throughput problem" in detail


def test_a_bare_from_number_has_no_service_cap_to_blame():
    state, detail = verdict({"total": 100, "expired": 40}, validity_period=None)
    assert state == "per-message"
    assert "no service-level cap" in detail
twilio-validity-period-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { errorCode, tally, verdict } from './twilio-validity-period-audit.mjs';

const msg = (sid, code = null, sender = '+15550001111') => ({
  sid, from: sender, status: 'undelivered', error_code: code,
  direction: 'outbound-api',
});

test('error code reads strings and numbers the same', () => {
  assert.equal(errorCode({ error_code: 30036 }), 30036);
  assert.equal(errorCode({ error_code: '30036' }), 30036);
  assert.equal(errorCode({ error_code: null }), null);
  assert.equal(errorCode({}), null);
});

test('tally keeps the three codes apart', () => {
  const rows = tally([msg('SM1', 30036), msg('SM2', 30045), msg('SM3', 30012),
                      msg('SM4')]);
  const row = rows.get('+15550001111');
  assert.equal(row.total, 4);
  assert.equal(row.expired, 1);
  assert.equal(row.out_of_range, 1);
  assert.equal(row.ttl_too_small, 1);
});

test('tally groups on the messaging service when there is one', () => {
  const rows = tally([{ ...msg('SM1', 30036), messaging_service_sid: 'MG1' }]);
  assert.deepEqual([...rows.keys()], ['MG1']);
});

test('tally ignores inbound and caps the sids', () => {
  const rows = tally([
    ...[0, 1, 2, 3, 4, 5, 6].map((i) => msg(`SM${i}`, 30036)),
    { sid: 'SM9', direction: 'inbound', status: 'received' },
  ]);
  assert.deepEqual(rows.get('+15550001111').sids, ['SM0', 'SM1', 'SM2']);
  assert.equal(rows.size, 1);
});

test('no expiries is clean', () => {
  const [state, detail] = verdict({ total: 400, expired: 0 });
  assert.equal(state, 'clean');
  assert.match(detail, /400/);
});

test('a request-time rejection outranks the queue timeout', () => {
  // 30045 never queued, so the service cap is irrelevant to it.
  const [state, detail] = verdict(
    { total: 100, expired: 50, out_of_range: 1 }, 300);
  assert.equal(state, 'out-of-range');
  assert.match(detail, /36000/);
});

test('a TTL below the route minimum is its own state', () => {
  const [state, detail] = verdict(
    { total: 100, expired: 50, ttl_too_small: 2 }, 300);
  assert.equal(state, 'ttl-too-small');
  assert.match(detail, /before anything was queued/);
});

test('a low service cap behind expiries is the cause', () => {
  const [state, detail] = verdict({ total: 100, expired: 40 }, 300);
  assert.equal(state, 'service-too-low');
  assert.match(detail, /300 second\(s\)/);
});

test('expiries at the default cap point at the send call or the queue', () => {
  const [state, detail] = verdict({ total: 100, expired: 40 }, 36000);
  assert.equal(state, 'per-message');
  assert.match(detail, /throughput problem/);
});

test('a bare From number has no service cap to blame', () => {
  const [state, detail] = verdict({ total: 100, expired: 40}, null);
  assert.equal(state, 'per-message');
  assert.match(detail, /no service-level cap/);
});

FAQ

What exactly does ValidityPeriod measure?

The time from Twilio accepting the message to the message being handed to the carrier. Queue time counts against it in full. That is why a five-minute value looks safe in testing, where the queue is empty, and starts killing messages the first time a campaign puts a few thousand segments in front of a one-per-second long code.

Should I just set ValidityPeriod to the maximum?

Only for traffic where a late delivery is still worth something. 36,000 seconds is ten hours, so a passcode can arrive long after the user gave up and requested three more codes. For time-critical messages a short period is correct and the 30036s are telling you the queue is too long, not that the deadline is too short.

How is 30036 different from 30045 and 30012?

30036 happens after the message queued and waited. 30045 and 30012 happen at request time: the first means the ValidityPeriod is outside the permitted 1 to 36,000 seconds, the second that the TTL is below what the route accepts. Neither of those messages was ever queued, so no service setting will change the outcome. Fix them where the send is constructed.

Where does the deadline come from if the service is set to 36,000?

The send call. A per-message ValidityPeriod overrides the service, so a service at the default with expiries behind it means the short value is in your sending code. If it is not there either, the queue genuinely is ten hours deep and the finding is a throughput problem, not a TTL one.

Does the script change my Messaging Service?

No. It reads validity_period from the service and prints the write you would run yourself. Everything it issues is a GET, which is why an API Key with read access is enough and is what you should give it.

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.