Skip to content

Diagnostic Twilio

a trial account rejects multi-segment messages with 30044

“Test” sends. “Your verification code is 481920” sends. The real welcome message, the one with the customer's name and a link and a single cheerful emoji at the end, comes back undelivered with error_code=30044. Everyone's first theory is the link. It is not the link.

Read-only key Python and Node.js Tests included
Man with beard and tattoos pushing a bicycle
Photo by Caden Norcott on Unsplash
The short answer

Read GET /2010-04-01/Accounts/{AccountSid}.json and check type. If it is Trial, the account caps message length far below a paid one and 30044 is the rejection. Then page Messages.json?DateSent>={since}, count rows with error_code == 30044 and look at num_segments on them.

The reason a body that "fits" stops fitting is encoding. A body made entirely of GSM-7 characters gets 160 characters in one segment and 153 in each of several. One character outside that alphabet — a curly apostrophe pasted from a document, an emoji, an accented name — flips the whole body to UCS-2 and the budget drops to 70 and 67.

The problem in plain words

This is a length limit that moves. Not gradually, and not in proportion to what you added: paste a smart quote into a 150-character template and the same 150 characters now need three segments instead of one. The character you added cost you nothing; the encoding change it forced cost you the whole budget.

On a paid account that shows up as a bigger bill. On a trial account it shows up as 30044 and the message never leaves. Which means the failure is bound to the account rather than to the code, and it will disappear the moment somebody runs the same template against a paid account and declares the bug unreproducible.

Then there is the direction of travel. Trial is where every integration starts. The template gets written short, tested, approved. Real data arrives — a customer called Zoë, an order reference, a two-line address — and the body crosses the line in production, on the account that is least able to send it.

Templatewritten150 characters,testedEmoji appendedone friendlycharacterBody flips toUCS-2budget drops to 70Trial capexceedederror 30044Nothing sentshort tests stillpass
The character added costs one unit. The encoding change it forces costs the whole budget, from 160 characters down to 70.

Why it happens

Encoding is a property of the whole body, not of the character. There is no mixed mode. One character outside GSM-7 and every character in the message is encoded as UCS-2, at 70 per single segment and 67 per concatenated one. This is why "I only added an emoji" and "the message is now three segments" are the same sentence.

An emoji is usually two units, not one. Most emoji live outside the Basic Multilingual Plane and occupy two UTF-16 code units. A length check written with a language's character count will under-count them, agree the body fits, and hand Twilio something that does not.

Some GSM-7 characters already cost two. The extended set — the euro sign, square brackets, braces, the tilde, the backslash and the caret — is encoded as an escape plus the character. They stay GSM-7, they just spend twice. A template full of square brackets is closer to the limit than it looks.

The trial cap is invisible until it is hit. Nothing in the Account resource says "your messages are capped at this length". You get type: Trial, and you are expected to know what that implies. The script has to carry that knowledge, because the API will not tell you.

The fix, as a flow

The script reads the account type first, because 30044 only exists on a trial account: the same rejection on a paid one means the sending code is authenticating as an account you are not looking at.

Account type and Messagesjoined on error_codePaid, no 30044cap does not applyTrial, no 30044 yetone emoji away from itTrial, 30044 presentupgrade or shortenPaid, 30044 presentyou are reading the wrong account
A trial account with no rejections yet is not safe, it is untested: one accented name in real data and the same cap applies.

How to fix it

Confirm the account really is a trial

GET /2010-04-01/Accounts/{AccountSid}.json and read type and status. Trial is the precondition for 30044; anything else and the error is telling you the sending code is authenticating as a different account from the one you are reading.

Count the rejections in the window

Page Messages.json?DateSent>={since}&PageSize=1000 and filter client-side. There is no ErrorCode filter on this resource, so the window and the page cap are the only bounds you get. Read error_code as an integer: it arrives as a string often enough to make a raw comparison report zero findings on an account full of them.

Read num_segments on the failures

Twilio reports num_segments on the Message resource. A 30044 with num_segments greater than one confirms the diagnosis outright. If the count is low and the body is short, the cap has been hit by encoding rather than by length, which points at the next step.

Run the body through the segment planner before you send it

The pure function in this script takes a body and returns the encoding, the unit count, the per-segment budget and the number of segments. Run your templates through it with realistic data in the placeholders. That is where the smart quote and the accented name show up, on a laptop, rather than in production.

Upgrade, or shorten, or turn on Smart Encoding

Upgrading the account removes the cap. Short of that, strip the Unicode: replace curly quotes with straight ones, drop the emoji, transliterate where you can. If you send through a Messaging Service, Smart Encoding does the common substitutions for you — it is a field on the service, and the script prints the call rather than making it.

How to check it worked

Re-run after upgrading or shortening. The 30044 count for the window should be zero and the state should no longer be trial-blocked.

python3 twilio_trial_segment_audit.py --days 7
# paid          AC00000000  1,204 message(s), no 30044 in 7 days

The full code

Two GETs: the account, then the Messages list for the window. Everything that decides anything is pure — the GSM-7 alphabet, the segment arithmetic and the verdict — because the encoding rules are the part worth reading and the part worth testing. The repair, including the Smart Encoding call, is printed rather than performed.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 40 Twilio fixes, free and open source.
twilio_trial_segment_audit.py
"""Report Twilio messages rejected with 30044, and plan any body's segments.

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 math
import os
import sys

import requests

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

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

TRIAL_LENGTH = 30044

# The GSM 03.38 basic alphabet. A body made only of these encodes as GSM-7 at
# 160 characters in a single segment and 153 in each concatenated one.
GSM7_BASIC = set(
    "@£$¥èéùìòÇ"
    + chr(10) + "Øø" + chr(13) + "Åå"
    "Δ_ΦΓΛΩΠΨΣΘΞ"
    "ÆæßÉ"
    " !\"#¤%&'()*+,-./0123456789:;<=>?"
    "¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ"
    "¿abcdefghijklmnopqrstuvwxyzäöñüà"
)

# Still GSM-7, but each is sent as an escape plus the character, so it spends
# two of the budget rather than one.
GSM7_EXTENDED = set("^{}[~]|€") | {chr(92)}


def segment_plan(body):
    """Encoding, unit count, per-segment budget and segment count for a body.

    Pure, so the encoding rules are visible and testable without a network.

    There is no mixed mode: one character outside GSM-7 and the entire body is
    encoded as UCS-2, dropping the budget from 160 to 70. UCS-2 is counted in
    UTF-16 code units, not characters, because most emoji occupy two of them and
    a character count quietly under-reports them.
    """
    text = str(body or "")
    units = 0
    gsm = True
    for ch in text:
        if ch in GSM7_BASIC:
            units += 1
        elif ch in GSM7_EXTENDED:
            units += 2
        else:
            gsm = False
            break

    if gsm:
        single, multi, encoding = 160, 153, "GSM-7"
    else:
        units = sum(2 if ord(c) > 0xFFFF else 1 for c in text)
        single, multi, encoding = 70, 67, "UCS-2"

    if units <= single:
        return {"encoding": encoding, "units": units,
                "per_segment": single, "segments": 1}
    return {"encoding": encoding, "units": units, "per_segment": multi,
            "segments": int(math.ceil(units / float(multi)))}


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

    It is null on healthy messages and a number on failed ones, but it arrives
    as a string often enough that a raw comparison against 30044 is how this
    audit reports zero findings on an account that is full of them.
    """
    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):
    """Count outbound messages and the 30044 rejections among them. Pure."""
    stats = {"total": 0, "blocked": 0, "multi_segment": 0, "sids": []}
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        stats["total"] += 1
        if error_code(m) != TRIAL_LENGTH:
            continue
        stats["blocked"] += 1
        try:
            if int(m.get("num_segments") or 1) > 1:
                stats["multi_segment"] += 1
        except (TypeError, ValueError):
            pass
        if len(stats["sids"]) < 3:
            stats["sids"].append(m.get("sid"))
    return stats


def verdict(account, stats):
    """Classify the account against its rejections. Pure.

    Returns (state, detail).
    """
    kind = str((account or {}).get("type") or "").strip().lower()
    status = str((account or {}).get("status") or "").strip().lower()
    total = int(stats.get("total") or 0)
    blocked = int(stats.get("blocked") or 0)
    multi = int(stats.get("multi_segment") or 0)

    if kind == "trial" and blocked:
        return ("trial-blocked",
                "%d of %d outbound message(s) rejected with 30044, %d of them "
                "over one segment. The account is a Trial, so the length cap is "
                "real and no amount of retrying will move it."
                % (blocked, total, multi))

    if kind == "trial":
        return ("trial-exposed",
                "%d outbound message(s) and no 30044 yet, but the account is a "
                "Trial and the length cap applies to every send. One accented "
                "name or one emoji in a template and this becomes an outage."
                % total)

    if blocked:
        return ("unexpected",
                "%d message(s) rejected with 30044 but this account reads as "
                "'%s', not Trial. 30044 only exists on trial accounts, so the "
                "code that sent these is authenticating as a different account "
                "from the one being audited." % (blocked, kind or "unknown"))

    return ("paid",
            "%d message(s), no 30044 in the window%s"
            % (total, "" if status in ("active", "") else " (status %s)" % status))


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 ErrorCode filter on this resource, so the
    window and the page cap are the only ways to bound it."""
    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=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("--plan",
                    help="print the segment plan for one body and exit")
    args = ap.parse_args()

    if args.plan is not None:
        p = segment_plan(args.plan)
        log.info("%s, %d unit(s), %d per segment, %d segment(s)",
                 p["encoding"], p["units"], p["per_segment"], p["segments"])
        return 0

    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)

    detail_account = get(session, "%s/Accounts/%s.json" % (BASE, account))
    since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    stats = tally(list_messages(session, account, since, args.max_messages))
    state, detail = verdict(detail_account, stats)

    line = "%-14s %s  %s" % (state, account, detail)
    if state == "paid":
        log.info(line)
        return 0

    log.warning(line)
    if stats["sids"]:
        log.warning("  message sids: %s", ", ".join(str(s) for s in stats["sids"]))
    log.warning("  repair: upgrade the account in Console > Billing > Upgrade, "
                "or shorten the body and strip Unicode so it stays GSM-7. On a "
                "Messaging Service, enable Smart Encoding with a write to "
                "https://messaging.twilio.com/v1/Services/{ServiceSid} setting "
                "SmartEncoding=true.")
    return 1


if __name__ == "__main__":
    sys.exit(main())
twilio-trial-segment-audit.mjs
/**
 * Report Twilio messages rejected with 30044, and plan any body's segments.
 *
 * 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 TRIAL_LENGTH = 30044;

// The GSM 03.38 basic alphabet. A body made only of these encodes as GSM-7 at
// 160 characters in a single segment and 153 in each concatenated one.
const GSM7_BASIC = new Set(
  '@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7'
  + '\n\u00d8\u00f8\r\u00c5\u00e5'
  + '\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e'
  + '\u00c6\u00e6\u00df\u00c9'
  + ' !"#\u00a4%&\'()*+,-./0123456789:;<=>?'
  + '\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7'
  + '\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0',
);

// Still GSM-7, but each is sent as an escape plus the character, so it spends
// two of the budget rather than one.
const GSM7_EXTENDED = new Set('^{}[~]|\u20ac' + String.fromCharCode(92));

/**
 * Encoding, unit count, per-segment budget and segment count for a body. Pure,
 * so the encoding rules are visible and testable without a network.
 *
 * There is no mixed mode: one character outside GSM-7 and the entire body is
 * encoded as UCS-2, dropping the budget from 160 to 70. UCS-2 is counted in
 * UTF-16 code units, not characters, because most emoji occupy two of them.
 */
export function segmentPlan(body) {
  const text = String(body ?? '');
  let units = 0;
  let gsm = true;
  for (const ch of text) {
    if (GSM7_BASIC.has(ch)) units += 1;
    else if (GSM7_EXTENDED.has(ch)) units += 2;
    else { gsm = false; break; }
  }

  let single;
  let multi;
  let encoding;
  if (gsm) {
    [single, multi, encoding] = [160, 153, 'GSM-7'];
  } else {
    units = text.length; // UTF-16 code units, which is what UCS-2 counts
    [single, multi, encoding] = [70, 67, 'UCS-2'];
  }

  if (units <= single) {
    return { encoding, units, per_segment: single, segments: 1 };
  }
  return { encoding, units, per_segment: multi, segments: Math.ceil(units / multi) };
}

/**
 * Read error_code as a number, or null. It arrives as a string often enough
 * that a raw comparison against 30044 reports nothing on an account full of
 * findings.
 */
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;
}

/** Count outbound messages and the 30044 rejections among them. Pure. */
export function tally(messages) {
  const stats = { total: 0, blocked: 0, multi_segment: 0, sids: [] };
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    stats.total += 1;
    if (errorCode(m) !== TRIAL_LENGTH) continue;
    stats.blocked += 1;
    if (Number(m.num_segments ?? 1) > 1) stats.multi_segment += 1;
    if (stats.sids.length < 3) stats.sids.push(m.sid);
  }
  return stats;
}

/** Classify the account against its rejections. Pure. Returns [state, detail]. */
export function verdict(account, stats) {
  const kind = String(account?.type ?? '').trim().toLowerCase();
  const status = String(account?.status ?? '').trim().toLowerCase();
  const total = Number(stats.total ?? 0);
  const blocked = Number(stats.blocked ?? 0);
  const multi = Number(stats.multi_segment ?? 0);

  if (kind === 'trial' && blocked) {
    return ['trial-blocked',
      `${blocked} of ${total} outbound message(s) rejected with 30044, ${multi} ` +
      'of them over one segment. The account is a Trial, so the length cap is ' +
      'real and no amount of retrying will move it.'];
  }

  if (kind === 'trial') {
    return ['trial-exposed',
      `${total} outbound message(s) and no 30044 yet, but the account is a Trial ` +
      'and the length cap applies to every send. One accented name or one emoji ' +
      'in a template and this becomes an outage.'];
  }

  if (blocked) {
    return ['unexpected',
      `${blocked} message(s) rejected with 30044 but this account reads as ` +
      `'${kind || 'unknown'}', not Trial. 30044 only exists on trial accounts, ` +
      'so the code that sent these is authenticating as a different account ' +
      'from the one being audited.'];
  }

  const suffix = (status === 'active' || status === '') ? '' : ` (status ${status})`;
  return ['paid', `${total} message(s), no 30044 in the window${suffix}`];
}

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 planAt = process.argv.indexOf('--plan');
  if (planAt !== -1) {
    const p = segmentPlan(process.argv[planAt + 1] ?? '');
    console.log(`${p.encoding}, ${p.units} unit(s), ${p.per_segment} per segment, ` +
                `${p.segments} segment(s)`);
    return;
  }

  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 daysAt = process.argv.indexOf('--days');
  const days = daysAt === -1 ? 7 : Number(process.argv[daysAt + 1]);

  const detailAccount = await get(auth, `${BASE}/Accounts/${account}.json`);
  const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
  const stats = tally(await listMessages(auth, account, since));
  const [state, detail] = verdict(detailAccount, stats);

  const line = `${state.padEnd(14)} ${account}  ${detail}`;
  if (state === 'paid') { console.log(line); return; }

  console.warn(line);
  if (stats.sids.length) console.warn(`  message sids: ${stats.sids.join(', ')}`);
  console.warn('  repair: upgrade the account in Console > Billing > Upgrade, or ' +
               'shorten the body and strip Unicode so it stays GSM-7. On a ' +
               'Messaging Service, enable Smart Encoding with a write to ' +
               'https://messaging.twilio.com/v1/Services/{ServiceSid} setting ' +
               'SmartEncoding=true.');
  process.exitCode = 1;
}

// 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 segment planner is where the tests earn their keep. A 160-character ASCII body is one segment; adding a single emoji makes the same body two, because the emoji is two UTF-16 units and it drags the other 160 characters into UCS-2 with it. The euro sign gets its own test because it stays GSM-7 and still costs two.

test_twilio_trial_segment_audit.py
from twilio_trial_segment_audit import segment_plan, tally, verdict


def test_one_hundred_and_sixty_ascii_characters_is_one_gsm7_segment():
    p = segment_plan("a" * 160)
    assert p["encoding"] == "GSM-7"
    assert p["segments"] == 1
    assert p["per_segment"] == 160


def test_one_more_character_drops_the_budget_to_153():
    p = segment_plan("a" * 161)
    assert p["per_segment"] == 153
    assert p["segments"] == 2


def test_a_single_emoji_flips_the_whole_body_to_ucs2():
    p = segment_plan("Welcome aboard")
    assert p["encoding"] == "GSM-7"
    p = segment_plan("Welcome aboard \U0001F389")
    assert p["encoding"] == "UCS-2"
    assert p["per_segment"] == 70


def test_an_emoji_counts_as_two_utf16_units():
    # A character count would say 1 here and agree the body fits.
    assert segment_plan("\U0001F389" + "a" * 69)["segments"] == 2


def test_the_euro_sign_stays_gsm7_and_costs_two():
    p = segment_plan("\u20ac" * 80)
    assert p["encoding"] == "GSM-7"
    assert p["units"] == 160
    assert p["segments"] == 1


def test_a_curly_apostrophe_is_not_gsm7():
    assert segment_plan("we\u2019re open")["encoding"] == "UCS-2"
    assert segment_plan("we're open")["encoding"] == "GSM-7"


def test_tally_counts_only_outbound_rejections():
    rows = [
        {"direction": "outbound-api", "error_code": "30044", "num_segments": "3",
         "sid": "SM1"},
        {"direction": "outbound-api", "error_code": 30044, "num_segments": 1,
         "sid": "SM2"},
        {"direction": "inbound", "error_code": 30044, "sid": "SM3"},
        {"direction": "outbound-api", "error_code": None, "sid": "SM4"},
    ]
    stats = tally(rows)
    assert stats["total"] == 3
    assert stats["blocked"] == 2
    assert stats["multi_segment"] == 1
    assert stats["sids"] == ["SM1", "SM2"]


def test_trial_account_with_rejections_is_blocked():
    state, detail = verdict({"type": "Trial", "status": "active"},
                            {"total": 40, "blocked": 12, "multi_segment": 12})
    assert state == "trial-blocked"
    assert "no amount of retrying" in detail


def test_trial_account_with_no_rejections_is_still_exposed():
    state, _ = verdict({"type": "Trial", "status": "active"},
                       {"total": 40, "blocked": 0, "multi_segment": 0})
    assert state == "trial-exposed"


def test_30044_on_a_paid_account_means_the_wrong_account_is_being_read():
    state, detail = verdict({"type": "Full", "status": "active"},
                            {"total": 40, "blocked": 3, "multi_segment": 3})
    assert state == "unexpected"
    assert "different account" in detail
twilio-trial-segment-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { segmentPlan, tally, verdict } from './twilio-trial-segment-audit.mjs';

test('160 ascii characters is one gsm7 segment', () => {
  const p = segmentPlan('a'.repeat(160));
  assert.equal(p.encoding, 'GSM-7');
  assert.equal(p.segments, 1);
  assert.equal(p.per_segment, 160);
});

test('one more character drops the budget to 153', () => {
  const p = segmentPlan('a'.repeat(161));
  assert.equal(p.per_segment, 153);
  assert.equal(p.segments, 2);
});

test('a single emoji flips the whole body to ucs2', () => {
  assert.equal(segmentPlan('Welcome aboard').encoding, 'GSM-7');
  const p = segmentPlan('Welcome aboard \u{1F389}');
  assert.equal(p.encoding, 'UCS-2');
  assert.equal(p.per_segment, 70);
});

test('an emoji counts as two utf16 units', () => {
  assert.equal(segmentPlan('\u{1F389}' + 'a'.repeat(69)).segments, 2);
});

test('the euro sign stays gsm7 and costs two', () => {
  const p = segmentPlan('\u20ac'.repeat(80));
  assert.equal(p.encoding, 'GSM-7');
  assert.equal(p.units, 160);
  assert.equal(p.segments, 1);
});

test('a curly apostrophe is not gsm7', () => {
  assert.equal(segmentPlan('we\u2019re open').encoding, 'UCS-2');
  assert.equal(segmentPlan("we're open").encoding, 'GSM-7');
});

test('tally counts only outbound rejections', () => {
  const stats = tally([
    { direction: 'outbound-api', error_code: '30044', num_segments: '3', sid: 'SM1' },
    { direction: 'outbound-api', error_code: 30044, num_segments: 1, sid: 'SM2' },
    { direction: 'inbound', error_code: 30044, sid: 'SM3' },
    { direction: 'outbound-api', error_code: null, sid: 'SM4' },
  ]);
  assert.equal(stats.total, 3);
  assert.equal(stats.blocked, 2);
  assert.equal(stats.multi_segment, 1);
  assert.deepEqual(stats.sids, ['SM1', 'SM2']);
});

test('trial account with rejections is blocked', () => {
  const [state, detail] = verdict({ type: 'Trial', status: 'active' },
    { total: 40, blocked: 12, multi_segment: 12 });
  assert.equal(state, 'trial-blocked');
  assert.match(detail, /no amount of retrying/);
});

test('trial account with no rejections is still exposed', () => {
  const [state] = verdict({ type: 'Trial', status: 'active' },
    { total: 40, blocked: 0, multi_segment: 0 });
  assert.equal(state, 'trial-exposed');
});

test('30044 on a paid account means the wrong account is being read', () => {
  const [state, detail] = verdict({ type: 'Full', status: 'active' },
    { total: 40, blocked: 3, multi_segment: 3 });
  assert.equal(state, 'unexpected');
  assert.match(detail, /different account/);
});

FAQ

Why does one emoji cost so much?

Because SMS has no mixed encoding. Every character in the body has to use the same alphabet, so a single character outside GSM-7 re-encodes the entire message as UCS-2. The budget falls from 160 characters to 70 for a single segment and from 153 to 67 for concatenated ones, and a 150-character body that was one segment becomes three.

Where does 30044 come from if the message is short?

From the encoding rather than the length. A trial account's cap is on the message, and a body that reads as short on screen can be well over the limit once it is counted in UCS-2 units. Run the body through the segment planner in this script and it will tell you the unit count the carrier will see.

Can I get 30044 on a paid account?

No, which is why the script has a state for it. 30044 is the trial-account length rejection. If you are seeing it in the Messages list of an account whose type is not Trial, the credential you are auditing with and the credential your application sends with are pointing at different accounts, and that mismatch is the real finding.

Does Smart Encoding fix this?

Partly. Smart Encoding substitutes common look-alike characters, so a curly apostrophe or an en dash pasted from a document becomes its GSM-7 equivalent and the body stays at 160. It cannot help with an emoji or an accented name, because there is no GSM-7 character to substitute. It is a setting on the Messaging Service, and this script prints the call rather than making it.

Should I test with real customer data?

Test with realistic data, which is not the same thing. The failures come from names with accents, addresses with line breaks and text pasted out of a word processor, so a template filled with 'Test User' will pass every time. Substitute a name like Zoe with the diaeresis and see what the planner says.

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.