Skip to content

Diagnostic Twilio

error 30019: the MMS is too big for the carrier, not for Twilio

The image sends fine to your own phone. It sends fine to two of the three people who tested it. To the fourth it comes back undelivered with error_code 30019, content size exceeds carrier limit — and the file has not changed. Twilio's ceiling and the carrier's ceiling are different numbers, an order of magnitude apart, and only one of them rejects you up front.

Read-only key Python and Node.js Tests included
A store filled with lots of shelves filled with items
Photo by Oxana Melis on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and keep rows where error_code is 30019 and num_media is greater than zero. Read num_media as an integer — the 2010-04-01 API returns it as a string.

Then name the offending file: GET /2010-04-01/Accounts/{AccountSid}/Messages/{MessageSid}/Media.json gives you the media list and each content_type, but no size field. The bytes have to be measured separately, with a streamed GET whose Content-Length you read and whose body you never download.

The problem in plain words

Two ceilings apply to every MMS and they are not close together. Twilio accepts up to 5 MB of body plus attachments and will take your 4 MB photograph without complaint. The carrier on the other end stops somewhere between roughly 300 KB and 3.5 MB depending on the network — AT&T short-code MMS caps at 600 KB — and there is no way to ask in advance which ceiling a given destination number sits under.

So the failure is per-recipient, which is the worst shape it could take for debugging. The developer sends to their own handset on a tier-one carrier, it arrives, and the ticket is closed. It arrives for most of the recipient list too. The complaints come from whichever subset happens to be on the networks with the low ceiling, they are a minority, they are geographically scattered, and nothing about them correlates with anything except the carrier — a field you do not have.

Meanwhile the media is usually not the size anyone assumes. A photograph straight from a phone camera is three to five megabytes; a designer's export at 2x for retina is not far behind. Nobody looked, because nothing in the send pipeline ever mentions the size and Twilio accepted it.

Photo from acameraabout 4 MBTwilio acceptsunder its own 5 MBTester receivesittier one carrierOther networksrefuseceiling near 600kBUndelivered30019scatteredcomplaints
The test send went to a tier one handset, so the file passed every check anyone actually ran.

Why it happens

Twilio's limit is not the limit that matters. 5 MB gets your request accepted. The carrier's ceiling is enforced later, asynchronously, per destination, and that is where 30019 comes from. Passing the Twilio check tells you nothing about delivery.

The Media resource has no size field. Media.json returns the SID, the content_type and the URIs. It does not return a byte count, so the only way to know how big the file actually is is to fetch its headers — which is a separate request nobody makes by default.

num_media is a string. The 2010-04-01 API returns "1", not 1. A truthiness test happens to work; an arithmetic comparison against zero does not, and "0" is truthy, so the naive filter keeps every SMS in the account and reports an MMS problem across traffic that has no media at all.

The Messages list cannot be filtered by error. No ErrorCode and no Status parameter exist on the resource. Finding 30019 means paging the window and filtering client-side, which is why nobody has this number to hand when the complaints start.

The fix, as a flow

Two ceilings apply and only one of them rejects you up front. Twilio takes 5 MB; the destination carrier stops somewhere between about 300 kB and 3.5 MB, and you cannot ask which before sending.

Content-Length on the mediaread, body never downloadedUnder 300 kBfits every network300 kB to 600 kBat the short code cap600 kB to 3.5 MBdelivers to some, 30019 to othersOver 3.5 MBno carrier is taking it
The middle band is the one that produces the confusing tickets: it delivers to some recipients and fails for the rest, every time.

How to fix it

Page the Messages list and keep only real MMS

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. Coerce num_media to an integer and drop anything at zero before counting anything, or the denominator includes every SMS you sent and the MMS failure rate comes out as a rounding error.

Compute the failure rate per sender, not per account

Bucket on messaging_service_sid or from and divide 30019s by MMS sends. Above about half means the media is over even the tier-one ceiling and nobody is receiving it. Below that is the carrier-dependent case, which is the one that produces the confusing partial complaints.

Name the file from the Media subresource

GET /2010-04-01/Accounts/{AccountSid}/Messages/{MessageSid}/Media.json lists each attachment with its content_type. Check the type while you are there: Twilio transcodes image/jpeg, image/png and image/gif, and anything else is passed through untouched at whatever size it was.

Measure the bytes without downloading them

The Media resource carries no size, so issue a streamed GET against the media URI, read Content-Length from the response headers and close the connection before the body transfers. Some media hosts answer HEAD badly or not at all, which is why this is a GET that is abandoned rather than a HEAD.

Recompress under 600 KB and turn on the converter

600 KB is the conservative target: under the AT&T short-code cap and under every published carrier ceiling. Recompress at the source, serve only jpeg, png or gif, and enable the MMS Converter on the Messaging Service so Twilio downsizes what slips through. The script prints that change; it does not make it.

How to check it worked

Re-run over the same window once the media has been recompressed. Every sender should read clean, and every probed file should be safe.

python3 twilio_mms_size_audit.py --days 7 --probe-size
# 2 sender(s) over 7 day(s), 0 with a 30019 problem

The full code

GET requests throughout, including the size probe — a streamed GET whose headers are read and whose body is discarded — so an API Key with read access is enough. Three pure functions: the num_media coercion, the per-sender tally, and the size ladder. The ladder is the piece worth having in one readable place, because those thresholds are carrier facts rather than opinions and they are the whole explanation for why the same file delivers to one person and not the next.

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_mms_size_audit.py
"""Report Twilio MMS rejected by the carrier for size (30019), and how big the media is.

Read only. GET requests and nothing else, including the size probe: 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_mms_size_audit")

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

OVERSIZE = 30019

# Carrier ceilings, not Twilio's. Twilio accepts 5 MB of body plus attachments;
# the networks stop far earlier and each at its own number, which is the entire
# reason one file delivers to one handset and 30019s on the next.
SAFE_BYTES = 300000        # under every published carrier ceiling
CARRIER_FLOOR = 600000     # AT&T short-code MMS stops here
TIER_ONE = 3500000         # about as far as the most generous networks go
TWILIO_MAX = 5000000       # body plus attachments, enforced by Twilio itself

TRANSCODED = ("image/jpeg", "image/png", "image/gif")


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 to matter."""
    raw = message.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def media_count(message):
    """Read num_media as an integer. Pure, and less trivial than it looks.

    The 2010-04-01 API returns num_media as a string: "0" for an SMS, "1" for a
    one-image MMS. "0" is truthy, so a plain truthiness test keeps every SMS in
    the account and the MMS failure rate comes out divided by the wrong
    denominator.
    """
    raw = message.get("num_media")
    if raw is None or raw == "":
        return 0
    try:
        return int(raw)
    except (TypeError, ValueError):
        return 0


def mms_tally(messages):
    """Bucket MMS sends and their 30019s by sender. Pure, so the denominator
    rule can be tested without a network. Messages with no media never enter
    the count at all."""
    out = {}
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        if media_count(m) <= 0:
            continue
        key = m.get("messaging_service_sid") or m.get("from") or "unknown sender"
        row = out.setdefault(key, {"mms": 0, "oversize": 0, "sids": []})
        row["mms"] += 1
        if error_code(m) == OVERSIZE:
            row["oversize"] += 1
            if len(row["sids"]) < 3:
                row["sids"].append(m.get("sid"))
    return out


def size_verdict(content_length):
    """Place a media file on the carrier ceiling ladder. Pure, so the thresholds
    are readable and arguable rather than buried in a request loop.

    Returns (state, detail).
    """
    if content_length is None or content_length == "":
        return ("unknown",
                "the media host returned no Content-Length, so the size is not "
                "knowable from the headers. Check the object at its source.")
    try:
        n = int(content_length)
    except (TypeError, ValueError):
        return ("unknown",
                "Content-Length was not a number, so the size is not knowable "
                "from the headers. Check the object at its source.")

    kb = n / 1000.0

    if n <= SAFE_BYTES:
        return ("safe", "%.0f kB, under every published carrier ceiling." % kb)

    if n <= CARRIER_FLOOR:
        return ("at-risk",
                "%.0f kB. Inside Twilio's limit and right at the conservative "
                "carrier floor: AT&T short-code MMS stops at 600 kB." % kb)

    if n <= TIER_ONE:
        return ("carrier-dependent",
                "%.0f kB. Tier-one carriers take up to about 3.5 MB while many "
                "others stop between 300 and 600 kB. This is the exact band "
                "where one recipient gets the image and the next gets 30019."
                % kb)

    if n <= TWILIO_MAX:
        return ("over-carriers",
                "%.0f kB. Under Twilio's 5 MB ceiling for body plus attachments "
                "and over every carrier ceiling: 30019 on all of them." % kb)

    return ("over-twilio",
            "%.0f kB, past Twilio's own 5 MB ceiling for body plus attachments."
            % kb)


def sender_verdict(stats):
    """Classify one sender's MMS traffic. Pure. Returns (state, detail)."""
    mms = int(stats.get("mms") or 0)
    over = int(stats.get("oversize") or 0)

    if not mms:
        return ("no-mms", "no MMS from this sender in the window")

    if not over:
        return ("clean", "%d MMS, none rejected for size" % mms)

    rate = over / mms

    if rate >= 0.5:
        return ("every-carrier",
                "%d of %d MMS rejected with 30019 (%.1f%%). At that rate the "
                "media is over the tier-one ceiling too, so nobody is receiving "
                "it." % (over, mms, rate * 100))

    return ("carrier-dependent",
            "%d of %d MMS rejected with 30019 (%.1f%%). It delivers on the "
            "networks with the higher ceiling and fails on the rest, which is "
            "why it works on the phone in your hand."
            % (over, mms, rate * 100))


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 media_for(session, account, message_sid):
    """List the attachments on one message. Media.json carries the content_type
    but no byte count, which is why the size needs its own request."""
    page = get(session, "%s/Accounts/%s/Messages/%s/Media.json"
               % (BASE, account, message_sid))
    return page.get("media_list", [])


def probe_size(session, media_uri):
    """Read Content-Length without downloading the file.

    A streamed GET whose body is never read and whose connection is closed
    immediately. HEAD would be tidier, but media is often served from object
    storage behind a redirect that answers HEAD inconsistently, and a GET that is
    abandoned costs the same and always works.
    """
    url = HOST + str(media_uri or "").replace(".json", "")
    r = session.get(url, stream=True, timeout=30, allow_redirects=True)
    try:
        if not r.ok:
            return None
        return r.headers.get("Content-Length")
    finally:
        r.close()


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("--probe-size", action="store_true",
                    help="read Content-Length on the media of each flagged "
                         "message; one extra GET per attachment, body discarded")
    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 = mms_tally(messages)
    if not senders:
        log.info("no MMS in %d message(s) since %s", len(messages), since)
        return 0

    bad = 0
    for sender, stats in sorted(senders.items()):
        state, detail = sender_verdict(stats)
        line = "%-18s %s  %s" % (state, sender, detail)
        if state in ("clean", "no-mms"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  message sids: %s", ", ".join(str(s) for s in stats["sids"]))

        if args.probe_size:
            for sid in stats["sids"]:
                for item in media_for(session, account, sid):
                    ctype = item.get("content_type") or "unknown type"
                    mstate, mdetail = size_verdict(probe_size(session,
                                                              item.get("uri")))
                    log.warning("  %s %-18s %s  %s", sid, mstate, ctype, mdetail)
                    if ctype not in TRANSCODED:
                        log.warning("    %s is not transcoded by Twilio: it goes "
                                    "to the carrier at whatever size it is.",
                                    ctype)

        log.warning("  repair: recompress the media under 600 kB, serve it as "
                    "jpeg, png or gif, and enable the MMS Converter on the "
                    "Messaging Service (MmsConverter) so what slips through is "
                    "downsized.")

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


if __name__ == "__main__":
    sys.exit(main())
twilio-mms-size-audit.mjs
/**
 * Report Twilio MMS rejected by the carrier for size (30019), and how big the
 * media is.
 *
 * Read only. GET requests and nothing else, including the size probe: 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 OVERSIZE = 30019;

// Carrier ceilings, not Twilio's. Twilio accepts 5 MB of body plus attachments;
// the networks stop far earlier and each at its own number, which is the entire
// reason one file delivers to one handset and 30019s on the next.
const SAFE_BYTES = 300000;      // under every published carrier ceiling
const CARRIER_FLOOR = 600000;   // AT&T short-code MMS stops here
const TIER_ONE = 3500000;       // about as far as the most generous networks go
const TWILIO_MAX = 5000000;     // body plus attachments, enforced by Twilio

const TRANSCODED = ['image/jpeg', 'image/png', 'image/gif'];

/**
 * Read error_code as a number, or null. Null on healthy messages, a number on
 * failed ones, and a string often enough to matter.
 */
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;
}

/**
 * Read num_media as an integer. Pure, and less trivial than it looks: the
 * 2010-04-01 API returns it as a string, and "0" is truthy, so a plain
 * truthiness test keeps every SMS in the account and divides the MMS failure
 * rate by the wrong denominator.
 */
export function mediaCount(message) {
  const raw = message.num_media;
  if (raw === null || raw === undefined || raw === '') return 0;
  const n = Number(raw);
  return Number.isFinite(n) ? Math.trunc(n) : 0;
}

/**
 * Bucket MMS sends and their 30019s by sender. Pure, so the denominator rule can
 * be tested without a network. Messages with no media never enter the count.
 */
export function mmsTally(messages) {
  const out = new Map();
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    if (mediaCount(m) <= 0) continue;
    const key = m.messaging_service_sid || m.from || 'unknown sender';
    if (!out.has(key)) out.set(key, { mms: 0, oversize: 0, sids: [] });
    const row = out.get(key);
    row.mms += 1;
    if (errorCode(m) === OVERSIZE) {
      row.oversize += 1;
      if (row.sids.length < 3) row.sids.push(m.sid);
    }
  }
  return out;
}

/**
 * Place a media file on the carrier ceiling ladder. Pure, so the thresholds are
 * readable and arguable. Returns [state, detail].
 */
export function sizeVerdict(contentLength) {
  if (contentLength === null || contentLength === undefined || contentLength === '') {
    return ['unknown',
      'the media host returned no Content-Length, so the size is not knowable ' +
      'from the headers. Check the object at its source.'];
  }
  const n = Number(contentLength);
  if (!Number.isFinite(n)) {
    return ['unknown',
      'Content-Length was not a number, so the size is not knowable from the ' +
      'headers. Check the object at its source.'];
  }

  const kb = (n / 1000).toFixed(0);

  if (n <= SAFE_BYTES) {
    return ['safe', `${kb} kB, under every published carrier ceiling.`];
  }

  if (n <= CARRIER_FLOOR) {
    return ['at-risk',
      `${kb} kB. Inside Twilio's limit and right at the conservative carrier ` +
      'floor: AT&T short-code MMS stops at 600 kB.'];
  }

  if (n <= TIER_ONE) {
    return ['carrier-dependent',
      `${kb} kB. Tier-one carriers take up to about 3.5 MB while many others ` +
      'stop between 300 and 600 kB. This is the exact band where one recipient ' +
      'gets the image and the next gets 30019.'];
  }

  if (n <= TWILIO_MAX) {
    return ['over-carriers',
      `${kb} kB. Under Twilio's 5 MB ceiling for body plus attachments and over ` +
      'every carrier ceiling: 30019 on all of them.'];
  }

  return ['over-twilio',
    `${kb} kB, past Twilio's own 5 MB ceiling for body plus attachments.`];
}

/**
 * Classify one sender's MMS traffic. Pure. Returns [state, detail].
 */
export function senderVerdict(stats) {
  const mms = Number(stats.mms ?? 0);
  const over = Number(stats.oversize ?? 0);

  if (!mms) return ['no-mms', 'no MMS from this sender in the window'];
  if (!over) return ['clean', `${mms} MMS, none rejected for size`];

  const rate = over / mms;
  const pct = (rate * 100).toFixed(1);

  if (rate >= 0.5) {
    return ['every-carrier',
      `${over} of ${mms} MMS rejected with 30019 (${pct}%). At that rate the ` +
      'media is over the tier-one ceiling too, so nobody is receiving it.'];
  }

  return ['carrier-dependent',
    `${over} of ${mms} MMS rejected with 30019 (${pct}%). It delivers on the ` +
    'networks with the higher ceiling and fails on the rest, which is why it ' +
    'works on the phone in your hand.'];
}

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

/**
 * Read Content-Length without downloading the file: a GET whose body stream is
 * cancelled as soon as the headers are in. Media often sits behind a redirect to
 * object storage that answers HEAD inconsistently, so an abandoned GET is the
 * reliable read.
 */
async function probeSize(auth, mediaUri) {
  const url = HOST + String(mediaUri ?? '').replace('.json', '');
  const res = await fetch(url, { headers: { Authorization: auth } });
  const len = res.ok ? res.headers.get('content-length') : null;
  if (res.body) await res.body.cancel();
  return len;
}

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 probe = process.argv.includes('--probe-size');

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

  const senders = mmsTally(messages);
  if (senders.size === 0) {
    console.log(`no MMS in ${messages.length} message(s) since ${since}`);
    return;
  }

  let bad = 0;
  for (const [sender, stats] of [...senders.entries()].sort()) {
    const [state, detail] = senderVerdict(stats);
    const line = `${state.padEnd(18)} ${sender}  ${detail}`;
    if (state === 'clean' || state === 'no-mms') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  message sids: ${stats.sids.join(', ')}`);

    if (probe) {
      for (const sid of stats.sids) {
        const page = await get(auth,
          `${BASE}/Accounts/${account}/Messages/${sid}/Media.json`);
        for (const item of page.media_list ?? []) {
          const ctype = item.content_type || 'unknown type';
          const [mstate, mdetail] = sizeVerdict(await probeSize(auth, item.uri));
          console.warn(`  ${sid} ${mstate.padEnd(18)} ${ctype}  ${mdetail}`);
          if (!TRANSCODED.includes(ctype)) {
            console.warn(`    ${ctype} is not transcoded by Twilio: it goes to ` +
                         'the carrier at whatever size it is.');
          }
        }
      }
    }

    console.warn('  repair: recompress the media under 600 kB, serve it as jpeg, ' +
                 'png or gif, and enable the MMS Converter on the Messaging ' +
                 'Service (MmsConverter) so what slips through is downsized.');
  }

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

The ladder is tested on its boundaries, because every one of those numbers is a claim about a carrier and an off-by-one turns a safe file into an at-risk one in the report. The num_media cases matter just as much: "0" arriving as a string is truthy, and a tally that believes it counts every SMS in the account as an MMS send and divides the failure rate into nothing.

test_twilio_mms_size_audit.py
from twilio_mms_size_audit import (error_code, media_count, mms_tally,
                                   sender_verdict, size_verdict)


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


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


def test_num_media_arrives_as_a_string_and_zero_is_truthy():
    # The whole reason this is a function: "0" is a truthy string, so a
    # truthiness test counts every SMS in the account as an MMS.
    assert media_count({"num_media": "0"}) == 0
    assert media_count({"num_media": "1"}) == 1
    assert media_count({"num_media": 2}) == 2
    assert media_count({}) == 0
    assert media_count({"num_media": "not a number"}) == 0


def test_tally_counts_only_messages_that_carry_media():
    rows = mms_tally([mms("SM1", 30019), mms("SM2"), mms("SM3", media="0"),
                      {"sid": "SM4", "direction": "inbound", "num_media": "1"}])
    assert rows["+15550001111"] == {"mms": 2, "oversize": 1, "sids": ["SM1"]}


def test_tally_groups_on_the_messaging_service_when_there_is_one():
    m = mms("SM1", 30019)
    m["messaging_service_sid"] = "MG1"
    assert set(mms_tally([m])) == {"MG1"}


def test_the_size_ladder_holds_at_every_boundary():
    assert size_verdict(300000)[0] == "safe"
    assert size_verdict(300001)[0] == "at-risk"
    assert size_verdict(600000)[0] == "at-risk"
    assert size_verdict(600001)[0] == "carrier-dependent"
    assert size_verdict(3500000)[0] == "carrier-dependent"
    assert size_verdict(3500001)[0] == "over-carriers"
    assert size_verdict(5000000)[0] == "over-carriers"
    assert size_verdict(5000001)[0] == "over-twilio"


def test_the_ladder_takes_content_length_as_the_string_a_header_is():
    state, detail = size_verdict("4200000")
    assert state == "over-carriers"
    assert "4200 kB" in detail


def test_a_missing_or_unparseable_content_length_is_unknown_not_safe():
    assert size_verdict(None)[0] == "unknown"
    assert size_verdict("")[0] == "unknown"
    assert size_verdict("chunked")[0] == "unknown"


def test_the_carrier_dependent_band_explains_the_partial_failures():
    _, detail = size_verdict(1200000)
    assert "one recipient gets the image and the next gets 30019" in detail


def test_a_sender_with_no_failures_is_clean():
    state, detail = sender_verdict({"mms": 40, "oversize": 0})
    assert state == "clean"
    assert "40" in detail


def test_most_of_the_mms_failing_means_no_carrier_takes_it():
    state, detail = sender_verdict({"mms": 10, "oversize": 8})
    assert state == "every-carrier"
    assert "nobody is receiving it" in detail


def test_a_minority_failing_is_the_carrier_dependent_case():
    state, detail = sender_verdict({"mms": 100, "oversize": 12})
    assert state == "carrier-dependent"
    assert "phone in your hand" in detail


def test_a_sender_with_no_mms_at_all_says_so():
    state, _ = sender_verdict({"mms": 0, "oversize": 0})
    assert state == "no-mms"
twilio-mms-size-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { errorCode, mediaCount, mmsTally, senderVerdict, sizeVerdict }
  from './twilio-mms-size-audit.mjs';

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

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

test('num_media arrives as a string and "0" is truthy', () => {
  assert.equal(mediaCount({ num_media: '0' }), 0);
  assert.equal(mediaCount({ num_media: '1' }), 1);
  assert.equal(mediaCount({ num_media: 2 }), 2);
  assert.equal(mediaCount({}), 0);
  assert.equal(mediaCount({ num_media: 'not a number' }), 0);
});

test('tally counts only messages that carry media', () => {
  const rows = mmsTally([
    mms('SM1', 30019), mms('SM2'), mms('SM3', null, '0'),
    { sid: 'SM4', direction: 'inbound', num_media: '1' },
  ]);
  assert.deepEqual(rows.get('+15550001111'),
    { mms: 2, oversize: 1, sids: ['SM1'] });
});

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

test('the size ladder holds at every boundary', () => {
  assert.equal(sizeVerdict(300000)[0], 'safe');
  assert.equal(sizeVerdict(300001)[0], 'at-risk');
  assert.equal(sizeVerdict(600000)[0], 'at-risk');
  assert.equal(sizeVerdict(600001)[0], 'carrier-dependent');
  assert.equal(sizeVerdict(3500000)[0], 'carrier-dependent');
  assert.equal(sizeVerdict(3500001)[0], 'over-carriers');
  assert.equal(sizeVerdict(5000000)[0], 'over-carriers');
  assert.equal(sizeVerdict(5000001)[0], 'over-twilio');
});

test('the ladder takes Content-Length as the string a header is', () => {
  const [state, detail] = sizeVerdict('4200000');
  assert.equal(state, 'over-carriers');
  assert.match(detail, /4200 kB/);
});

test('a missing or unparseable Content-Length is unknown, not safe', () => {
  assert.equal(sizeVerdict(null)[0], 'unknown');
  assert.equal(sizeVerdict('')[0], 'unknown');
  assert.equal(sizeVerdict('chunked')[0], 'unknown');
});

test('the carrier-dependent band explains the partial failures', () => {
  const [, detail] = sizeVerdict(1200000);
  assert.match(detail, /one recipient gets the image and the next gets 30019/);
});

test('a sender with no failures is clean', () => {
  const [state, detail] = senderVerdict({ mms: 40, oversize: 0 });
  assert.equal(state, 'clean');
  assert.match(detail, /40/);
});

test('most of the MMS failing means no carrier takes it', () => {
  const [state, detail] = senderVerdict({ mms: 10, oversize: 8 });
  assert.equal(state, 'every-carrier');
  assert.match(detail, /nobody is receiving it/);
});

test('a minority failing is the carrier-dependent case', () => {
  const [state, detail] = senderVerdict({ mms: 100, oversize: 12 });
  assert.equal(state, 'carrier-dependent');
  assert.match(detail, /phone in your hand/);
});

test('a sender with no MMS at all says so', () => {
  const [state] = senderVerdict({ mms: 0, oversize: 0 });
  assert.equal(state, 'no-mms');
});

FAQ

What size should I actually target for MMS?

Under 600 KB. That is beneath the AT&T short-code cap and beneath every published carrier ceiling, so it removes the per-carrier lottery entirely. Twilio's own 5 MB limit is not a useful target: it only tells you whether the request will be accepted, not whether anything will be delivered.

Why does the same image deliver to some recipients and not others?

Because the ceiling belongs to the destination carrier, and they differ by roughly an order of magnitude. Tier-one networks take up to about 3.5 MB, many others stop between 300 and 600 KB. A file in that band delivers to whoever is on a generous network and returns 30019 for everyone else, and the destination carrier is not a field you can read before sending.

Can I get the media size from the API?

Not directly. The Media subresource returns the SID, the content_type and the URIs, but no byte count. The script reads Content-Length from the media URL with a GET whose body it never downloads, which is the only read-only way to get the number.

Does the MMS Converter fix this on its own?

It helps and it is not a substitute for fixing the source. The converter downsizes and transcodes what passes through the Messaging Service, but Twilio only transcodes jpeg, png and gif; anything else goes to the carrier untouched at whatever size it is. Recompress at the source and treat the converter as the safety net.

Why does the script care whether num_media is a string?

Because the 2010-04-01 API returns it as one, and a string “0” is truthy. A filter written as a truthiness test keeps every SMS on the account in the denominator, so a real MMS failure rate of thirty percent is reported as a fraction of a percent and nobody investigates.

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.