Skip to content

Diagnostic Twilio

messages stay queued or accepted and never reach a final state

The send succeeded four hours ago. status is still queued. error_code is null, date_sent is null, and the status callback has never fired because nothing has happened to report. Nobody has been paged, because nothing has failed yet — and in six hours these will start failing with 30001 or expiring with 30036, long after the passcode they carry stopped being useful.

Read-only key Python and Node.js Tests included
A blue envelope
Photo by Bianca Ackermann on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and flag rows whose status is queued, accepted or sending while date_created is more than an hour old. That is throughput starvation: the sender's queue is not draining.

Two states next to it are not the same finding. scheduled is a message waiting for a send window you asked for. And sent is terminal on carriers that return no delivery receipt, so treating every non-delivered message as a failure invents an outage that is not there.

The problem in plain words

Every alert anyone writes fires on an error. This has none. The message resource is healthy in every field a monitor looks at: no error_code, no failed status, no webhook to fail. It is simply not moving, and there is no event for not moving.

By the time it does produce an error, the useful window has closed. A one-time passcode queued behind eleven thousand marketing segments on the same long code arrives after the login page has timed out, or does not arrive at all when the validity period runs out. The user has already asked for another code, which goes into the same queue behind the first one, which is how a slow queue becomes a stopped one.

Bulk job queuedthousands ofsegmentsPasscode queuednextsame long codeSender metersit outabout one persecondHours in queuedno error_codeFails with30001or expires 30036
There is no event for not moving, so the first alert anyone gets is the failure hours later, long after the code expired.

Why it happens

Throughput belongs to the sender, not to the account. A US long code moves about one message segment per second. Handing it a bulk job puts every later message behind that job, and Twilio holds roughly ten hours of segments per sender before overflowing. Nothing rejects the send at the door; the queue accepts everything and then meters it out.

Not moving is not an error. Twilio has nothing to report while a message waits, so no status callback fires and no alert exists. The only way to see it is to read date_created against the clock, which means someone has to have decided what "too old" means for your traffic.

Two adjacent states look identical from a dashboard. A scheduled message is waiting on purpose — up to 35 days out — and fires no callbacks while it waits. A sent message on a carrier with no delivery receipts is finished and successful. Both are non-final in a naive query, and counting either as stuck produces a report nobody trusts twice.

There is no status filter to ask with. The Messages list takes To, From, DateSent and paging, and nothing else. You cannot ask for the queued ones; you page the window and compare timestamps yourself, which is why almost nobody notices until the failures start.

The fix, as a flow

The script ages every non-final message against a clock you pass in, because three of the four non-final states are healthy and only the timestamp separates them from the one that is not.

Messages aged on date_createdagainst a clock passed inScheduled for laterwaiting on purposeSent, no receiptterminal and successfulQueued under an hourstill in flightQueued for hoursthe queue is not draining
Counting scheduled and sent messages as stuck is how a queue report loses the reader's trust on its first run.

How to fix it

Page a short window, not a long one

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. Two days is usually right: anything older is already resolved one way or another, and a wider window mostly costs you paging time.

Age each non-final message against the clock

Read date_created, which the 2010-04-01 API returns as an RFC 2822 string like Mon, 12 Aug 2024 10:15:03 +0000. Anything queued, accepted or sending older than about an hour is not in flight any more, it is starved. Make the threshold an argument; an OTP flow and a nightly batch do not deserve the same number.

Separate the scheduled ones

scheduled means you booked it, anywhere from 15 minutes to 35 days ahead, and no status callback fires while it waits. Those belong in their own bucket. A scheduled message whose send time has already passed and whose status has not moved is a real finding; one that is simply waiting is not.

Do not count sent as a failure

On carriers that return no delivery receipt, sent is the last status a message will ever have. It never becomes delivered and nothing is wrong. Report it as its own state so the delivery rate you quote is not quietly wrong for entire countries.

Widen the sender pool, then raise the validity period

The repair for starvation is more senders, not more retries: send through a Messaging Service and add numbers to the pool so the segments have somewhere to go. Raise ValidityPeriod to 36000 so throttled messages are not thrown away before their turn, and cancel scheduled sends you no longer want with POST /2010-04-01/Accounts/{AccountSid}/Messages/{MessageSid}.json and Status=canceled.

How to check it worked

Re-run after the pool is widened. The queued and accepted counts should drain within the threshold, and the only non-final states left should be scheduled and sent.

python3 twilio_stuck_messages_audit.py --days 2 --stuck-after 60
# 4210 message(s) over 2 day(s), 0 not moving

The full code

One paginated GET, read with an API Key that has read access. The two pure functions are the date parser and the verdict, and they are pure for the same reason: this whole note is about telling four non-final states apart, and a rule that decides what counts as stuck should be testable at a fixed clock rather than at whatever time the test happens to run.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 12 Twilio fixes, free and open source.
twilio_stuck_messages_audit.py
"""Report Twilio messages that are not moving, and the ones that only look stuck.

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
from email.utils import parsedate_to_datetime

import requests

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

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

FINAL = ("delivered", "undelivered", "failed", "canceled", "read", "received")
WAITING = ("queued", "accepted", "sending")
NOT_MOVING = ("stuck", "scheduled-overdue", "unknown-age", "unknown-status")


def age_minutes(date_str, now):
    """Minutes between `date_str` and `now`; negative when it is in the future.

    The 2010-04-01 API returns RFC 2822 dates ("Mon, 12 Aug 2024 10:15:03
    +0000"), not ISO 8601, so the obvious parser is the wrong one. Returns None
    for a missing or unreadable value rather than guessing, because guessing
    here means reporting a message as stuck on the strength of a parse failure.
    """
    raw = str(date_str or "").strip()
    if not raw:
        return None
    try:
        when = parsedate_to_datetime(raw)
    except (TypeError, ValueError):
        return None
    if when is None:
        return None
    if when.tzinfo is None:
        when = when.replace(tzinfo=dt.timezone.utc)
    return (now - when).total_seconds() / 60.0


def verdict(message, now, stuck_after=60):
    """Classify one message against a clock you pass in.

    Pure, so the four non-final states can be told apart in a test at a fixed
    time instead of at whatever moment the suite happens to run.

    Returns (state, detail).
    """
    status = str(message.get("status") or "").lower()

    if status in FINAL:
        return ("final", "status %s" % (status or "unset"))

    if status == "scheduled":
        due = age_minutes(message.get("send_at"), now)
        if due is None:
            return ("scheduled",
                    "waiting for a send window. The list response does not "
                    "always carry send_at, so age these against your own record "
                    "of when they were booked.")
        if due < 0:
            return ("scheduled",
                    "waiting: due in %d minute(s). No status callback fires "
                    "while a message is scheduled." % round(-due))
        return ("scheduled-overdue",
                "its send_at passed %d minute(s) ago and the status has not "
                "moved." % round(due))

    age = age_minutes(message.get("date_created"), now)

    if status == "sent":
        if age is not None and age >= stuck_after:
            return ("sent-no-dlr",
                    "sent %d minute(s) ago with no delivery receipt. On carriers "
                    "that return no receipt, sent is the terminal state: count "
                    "it as success rather than as a failure." % round(age))
        return ("in-flight", "sent, waiting for a delivery receipt.")

    if status in WAITING:
        if age is None:
            return ("unknown-age",
                    "status %s but date_created could not be read, so it cannot "
                    "be aged." % status)
        if age >= stuck_after:
            return ("stuck",
                    "%s for %d minute(s) with no error_code. The sender's queue "
                    "is not draining; Twilio holds about ten hours of segments "
                    "per sender, then these fail with 30001 or expire with "
                    "30036." % (status, round(age)))
        return ("in-flight", "%s for %d minute(s), still inside the window."
                % (status, round(age)))

    return ("unknown-status",
            "status %s is not one this script knows how to age." % (status or "unset"))


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. There is no Status filter on this resource, so a
    short window and a hard 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 main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=2,
                    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("--stuck-after", type=int, default=60,
                    help="minutes in a waiting status before it counts as stuck")
    ap.add_argument("--show", type=int, default=20,
                    help="how many individual messages to print")
    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 since %s", since)
        return 0

    now = dt.datetime.now(dt.timezone.utc)
    counts, shown, bad = {}, 0, 0
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        state, detail = verdict(m, now, args.stuck_after)
        counts[state] = counts.get(state, 0) + 1
        if state not in NOT_MOVING:
            continue
        bad += 1
        if shown >= args.show:
            continue
        shown += 1
        log.warning("%-17s %s  %s", state, m.get("sid"), detail)
        if state == "scheduled-overdue":
            log.warning("  repair: cancel it with POST %s/Accounts/%s/Messages/"
                        "%s.json Status=canceled", BASE, account, m.get("sid"))
        elif state == "stuck":
            log.warning("  repair: send through a Messaging Service with more "
                        "senders in the pool, and raise the validity period with "
                        "POST %s/Services/{ServiceSid} ValidityPeriod=36000", MSG)

    log.info("states: %s",
             ", ".join("%s %d" % kv for kv in sorted(counts.items())))
    log.info("%d message(s) over %d day(s), %d not moving",
             len(messages), args.days, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-stuck-messages-audit.mjs
/**
 * Report Twilio messages that are not moving, and the ones that only look stuck.
 *
 * 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 MSG = 'https://messaging.twilio.com/v1';

const FINAL = ['delivered', 'undelivered', 'failed', 'canceled', 'read', 'received'];
const WAITING = ['queued', 'accepted', 'sending'];
const NOT_MOVING = ['stuck', 'scheduled-overdue', 'unknown-age', 'unknown-status'];

/**
 * Minutes between `dateStr` and `now`; negative when it is in the future. The
 * 2010-04-01 API returns RFC 2822 dates, not ISO 8601. Returns null for a
 * missing or unreadable value rather than guessing, because guessing means
 * calling a message stuck on the strength of a parse failure.
 */
export function ageMinutes(dateStr, now) {
  const raw = String(dateStr ?? '').trim();
  if (!raw) return null;
  const ms = Date.parse(raw);
  if (Number.isNaN(ms)) return null;
  return (now.getTime() - ms) / 60000;
}

/**
 * Classify one message against a clock you pass in. Pure, so the four non-final
 * states can be told apart at a fixed time in a test. Returns [state, detail].
 */
export function verdict(message, now, stuckAfter = 60) {
  const status = String(message.status ?? '').toLowerCase();

  if (FINAL.includes(status)) return ['final', `status ${status || 'unset'}`];

  if (status === 'scheduled') {
    const due = ageMinutes(message.send_at, now);
    if (due === null) {
      return ['scheduled',
        'waiting for a send window. The list response does not always carry ' +
        'send_at, so age these against your own record of when they were booked.'];
    }
    if (due < 0) {
      return ['scheduled',
        `waiting: due in ${Math.round(-due)} minute(s). No status callback ` +
        'fires while a message is scheduled.'];
    }
    return ['scheduled-overdue',
      `its send_at passed ${Math.round(due)} minute(s) ago and the status has ` +
      'not moved.'];
  }

  const age = ageMinutes(message.date_created, now);

  if (status === 'sent') {
    if (age !== null && age >= stuckAfter) {
      return ['sent-no-dlr',
        `sent ${Math.round(age)} minute(s) ago with no delivery receipt. On ` +
        'carriers that return no receipt, sent is the terminal state: count it ' +
        'as success rather than as a failure.'];
    }
    return ['in-flight', 'sent, waiting for a delivery receipt.'];
  }

  if (WAITING.includes(status)) {
    if (age === null) {
      return ['unknown-age',
        `status ${status} but date_created could not be read, so it cannot be aged.`];
    }
    if (age >= stuckAfter) {
      return ['stuck',
        `${status} for ${Math.round(age)} minute(s) with no error_code. The ` +
        "sender's queue is not draining; Twilio holds about ten hours of " +
        'segments per sender, then these fail with 30001 or expire with 30036.'];
    }
    return ['in-flight',
      `${status} for ${Math.round(age)} minute(s), still inside the window.`];
  }

  return ['unknown-status',
    `status ${status || 'unset'} is not one this script knows how to age.`];
}

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 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 arg = (name, fallback) => Number(process.argv.includes(name)
    ? process.argv[process.argv.indexOf(name) + 1] : fallback) || fallback;
  const days = arg('--days', 2);
  const stuckAfter = arg('--stuck-after', 60);
  const show = arg('--show', 20);
  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 since ${since}`);
    return;
  }

  const now = new Date();
  const counts = new Map();
  let shown = 0;
  let bad = 0;
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    const [state, detail] = verdict(m, now, stuckAfter);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    if (!NOT_MOVING.includes(state)) continue;
    bad += 1;
    if (shown >= show) continue;
    shown += 1;
    console.warn(`${state.padEnd(17)} ${m.sid}  ${detail}`);
    if (state === 'scheduled-overdue') {
      console.warn(`  repair: cancel it with POST ${BASE}/Accounts/${account}` +
                   `/Messages/${m.sid}.json Status=canceled`);
    } else if (state === 'stuck') {
      console.warn('  repair: send through a Messaging Service with more senders ' +
                   `in the pool, and raise the validity period with POST ${MSG}` +
                   '/Services/{ServiceSid} ValidityPeriod=36000');
    }
  }

  console.log(`states: ${[...counts.entries()].sort()
    .map(([k, v]) => `${k} ${v}`).join(', ')}`);
  console.log(`${messages.length} message(s) over ${days} day(s), ${bad} not moving`);
  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

Every test here runs against a frozen clock, because a rule about age is untestable otherwise. The three that matter are the ones that keep the report honest: a message scheduled for next week is not stuck, a sent message with no delivery receipt is not a failure, and a date that will not parse is reported as unreadable rather than as four hours old.

test_twilio_stuck_messages_audit.py
import datetime as dt

from twilio_stuck_messages_audit import age_minutes, verdict

NOW = dt.datetime(2026, 1, 1, 12, 0, tzinfo=dt.timezone.utc)


def rfc2822(hour, minute=0, day=1):
    return "Thu, %02d Jan 2026 %02d:%02d:00 +0000" % (day, hour, minute)


def test_age_is_read_from_rfc_2822_not_iso_8601():
    assert age_minutes(rfc2822(9), NOW) == 180
    assert age_minutes(rfc2822(14), NOW) == -120     # in the future
    assert age_minutes("2026-01-01T09:00:00Z", NOW) is None
    assert age_minutes("", NOW) is None
    assert age_minutes(None, NOW) is None


def test_four_hours_queued_with_no_error_code_is_stuck():
    state, detail = verdict({"status": "queued", "date_created": rfc2822(8)}, NOW)
    assert state == "stuck"
    assert "30036" in detail


def test_ten_minutes_queued_is_still_in_flight():
    state, _ = verdict({"status": "accepted", "date_created": rfc2822(11, 50)}, NOW)
    assert state == "in-flight"


def test_a_scheduled_message_is_not_stuck_however_old_the_row_is():
    state, detail = verdict({"status": "scheduled", "date_created": rfc2822(1),
                             "send_at": rfc2822(9, 0, day=8)}, NOW)
    assert state == "scheduled"
    assert "No status callback" in detail


def test_a_scheduled_message_whose_time_has_passed_is_a_finding():
    state, _ = verdict({"status": "scheduled", "send_at": rfc2822(9)}, NOW)
    assert state == "scheduled-overdue"


def test_sent_with_no_receipt_is_success_not_failure():
    state, detail = verdict({"status": "sent", "date_created": rfc2822(8)}, NOW)
    assert state == "sent-no-dlr"
    assert "success" in detail


def test_delivered_and_failed_are_both_final():
    assert verdict({"status": "delivered"}, NOW)[0] == "final"
    assert verdict({"status": "failed", "error_code": 30003}, NOW)[0] == "final"


def test_an_unreadable_date_is_reported_as_unreadable():
    state, detail = verdict({"status": "queued", "date_created": "yesterday"}, NOW)
    assert state == "unknown-age"
    assert "cannot" in detail
    assert verdict({"status": "partially_delivered"}, NOW)[0] == "unknown-status"


def test_the_threshold_is_an_argument_not_a_constant():
    msg = {"status": "queued", "date_created": rfc2822(11, 30)}
    assert verdict(msg, NOW)[0] == "in-flight"
    assert verdict(msg, NOW, stuck_after=15)[0] == "stuck"
twilio-stuck-messages-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ageMinutes, verdict } from './twilio-stuck-messages-audit.mjs';

const NOW = new Date('2026-01-01T12:00:00Z');
const pad = (n) => String(n).padStart(2, '0');
const rfc2822 = (hour, minute = 0, day = 1) =>
  `Thu, ${pad(day)} Jan 2026 ${pad(hour)}:${pad(minute)}:00 +0000`;

test('age is read from rfc 2822 dates', () => {
  assert.equal(ageMinutes(rfc2822(9), NOW), 180);
  assert.equal(ageMinutes(rfc2822(14), NOW), -120);
  assert.equal(ageMinutes('', NOW), null);
  assert.equal(ageMinutes(null, NOW), null);
});

test('four hours queued with no error code is stuck', () => {
  const [state, detail] = verdict({ status: 'queued', date_created: rfc2822(8) }, NOW);
  assert.equal(state, 'stuck');
  assert.match(detail, /30036/);
});

test('ten minutes queued is still in flight', () => {
  const [state] = verdict({ status: 'accepted', date_created: rfc2822(11, 50) }, NOW);
  assert.equal(state, 'in-flight');
});

test('a scheduled message is not stuck however old the row is', () => {
  const [state, detail] = verdict({ status: 'scheduled', date_created: rfc2822(1),
                                    send_at: rfc2822(9, 0, 8) }, NOW);
  assert.equal(state, 'scheduled');
  assert.match(detail, /No status callback/);
});

test('a scheduled message whose time has passed is a finding', () => {
  assert.equal(verdict({ status: 'scheduled', send_at: rfc2822(9) }, NOW)[0],
               'scheduled-overdue');
});

test('sent with no receipt is success, not failure', () => {
  const [state, detail] = verdict({ status: 'sent', date_created: rfc2822(8) }, NOW);
  assert.equal(state, 'sent-no-dlr');
  assert.match(detail, /success/);
});

test('delivered and failed are both final', () => {
  assert.equal(verdict({ status: 'delivered' }, NOW)[0], 'final');
  assert.equal(verdict({ status: 'failed', error_code: 30003 }, NOW)[0], 'final');
});

test('an unreadable date is reported as unreadable', () => {
  const [state, detail] = verdict({ status: 'queued', date_created: 'not a date' }, NOW);
  assert.equal(state, 'unknown-age');
  assert.match(detail, /cannot/);
  assert.equal(verdict({ status: 'partially_delivered' }, NOW)[0], 'unknown-status');
});

test('the threshold is an argument, not a constant', () => {
  const msg = { status: 'queued', date_created: rfc2822(11, 30) };
  assert.equal(verdict(msg, NOW)[0], 'in-flight');
  assert.equal(verdict(msg, NOW, 15)[0], 'stuck');
});

FAQ

Why is there no error code on a message that has been queued for four hours?

Because nothing has failed. Queued means Twilio holds the message and is metering it out at the sender's throughput. An error only appears when the queue overflows (30001) or the validity period runs out (30036), which is hours after the message stopped being useful.

How long is too long?

It depends entirely on the traffic. An hour is a sane default for transactional sends and far too aggressive for a bulk campaign on a single long code, where a queue of several hours is the design working as intended. That is why the threshold is an argument rather than a constant in the script.

Is a message stuck at sent a problem?

Usually not. Some carriers return no delivery receipt at all, and for those, sent is the last status the message will ever have. Counting them as failures understates delivery for entire countries; the script gives them their own state so the number you quote is defensible.

Does a scheduled message fire status callbacks while it waits?

No. It sits with status scheduled until its send time arrives, which can be up to 35 days out, and nothing is reported in the meantime. A monitor that alerts on any non-final message will page somebody every night for messages that are working perfectly.

What actually fixes throughput starvation?

More senders, not more retries. Send through a Messaging Service and put enough numbers in the pool that the segments have somewhere to go, then raise ValidityPeriod to 36000 so throttled messages are not discarded before their turn. The script prints both, and performs neither.

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.