Skip to content

Diagnostic Twilio

carrier filtering drops your SMS silently with error 30007

The API returned 201. The Message SID is in your logs. The status walked queued, sent, and then undelivered with error_code 30007, and the recipient saw nothing at all. You were billed for the attempt. No HTTP request failed, no webhook errored, nothing appeared in your exception tracker — a carrier, or Twilio itself, read the message and dropped it.

Read-only key Python and Node.js Tests included
Holding an envelope
Photo by erica steeves on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000 and count rows where status is undelivered and error_code is 30007, grouped by from or messaging_service_sid. Cross-check with GET https://monitor.twilio.com/v1/Alerts?LogLevel=error.

The list resource has no Status or ErrorCode filter — only To, From, DateSent and paging. The filtering has to happen in your own code, which is the single reason nobody has a dashboard for this.

The problem in plain words

Filtering is the one delivery failure that behaves like success right up until the final status. The request is accepted, the Message resource is created, the segments are priced, the status callback fires. Then the message is quietly discarded somewhere between Twilio and the handset, and the only trace is an integer on a resource nobody is reading.

What makes it expensive is that it is rarely uniform. A single sender in a pool of eight loses reputation, or one campaign's content trips a heuristic, and the aggregate delivery rate for the account slips from 97% to 92% — a number small enough to be dismissed as carrier noise for a quarter. Underneath it, one sender is at 40% and every customer routed to it has stopped hearing from you.

Send accepted201 and a messagesidSegments billedpriced on the wayoutCarrier filterscontent orreputationerror_code30007status undeliveredNobody reads itno alert exists
Nothing here returns an error to your code. The only trace is an integer on a resource that has no filter for it.

Why it happens

The Messages list cannot be queried by error. There is no Status parameter and no ErrorCode parameter on Messages.json. You can filter by To, From and DateSent, and that is the whole list. Finding 30007 means paging every message in the window and filtering client-side, so the check only exists if somebody wrote it.

You are billed for filtered messages. Twilio charges for the send attempt; the carrier drops it after that. A filtered message costs exactly what a delivered one costs, so cost monitoring will never show a dip and neither will your sent-volume chart.

Reputation attaches to the sender, not the message. Once a long code is flagged, well-formed messages from it are filtered too. That is why the per-sender rate is the number worth alerting on and the account-wide rate is not: averaging a poisoned sender with seven healthy ones hides the outage.

There is no API that repairs it. No field to set, no resource to POST. The fix is content, sender registration, and a Support ticket carrying at least three Message SIDs. A script that cannot fix anything is still the only thing that will tell you which three SIDs to send.

The fix, as a flow

The script groups by sender before it judges anything, because filtering attaches to a sender's reputation rather than to a message: one poisoned long code averaged with seven healthy ones disappears completely.

Messages paged by dategrouped by from or service sidNo 30007 at allclean, nothing to doOne or two filteredtoo few to escalateA steady few percentcontent or use caseHalf the traffic gonethe sender is burned
Two filtered messages is not a ticket and half a sender's traffic is not a wording problem, so they are deliberately different states.

How to fix it

Page the Messages list over a bounded window

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. Bound it by days and by a hard message cap; a busy account will happily hand you a million rows and the answer does not improve after the first few thousand.

Filter client-side on status and error_code

Keep rows where status == "undelivered" and error_code == 30007. Read error_code defensively: it is null on healthy messages and arrives as a number, so a comparison against the string "30007" silently matches nothing.

Group by sender, not by account

Bucket on messaging_service_sid when it is set and from otherwise. The rate per sender is what tells you whether this is a content problem across the account or one poisoned long code, and those have completely different repairs.

Read the content that is being filtered

Public link shorteners (bit.ly and friends) are the most common single cause, followed by no opt-out language, followed by traffic that does not match the registered A2P campaign use case. Compare the filtered bodies against the MessageSamples you registered; if a marketing blast is going out through a campaign registered for one-time passcodes, the filtering is working as designed.

Collect three SIDs and escalate

There is no API repair. Rewrite the content, confirm the campaign use case matches the traffic, and open a Twilio Support ticket with at least three Message SIDs showing 30007 so the filtering can be reviewed. Keep the script on a schedule afterwards: reputation damage recurs, and the per-sender rate is the early warning.

How to check it worked

Re-run the script over the same window after the content change. Every sender should report clean, or at worst isolated.

python3 twilio_filtered_messages_audit.py --days 7
# 4 sender(s) over 7 day(s), 0 with a filtering problem

The full code

One paginated GET over the Messages list and nothing else — give it an API Key with read access, which is all it can use. The two pure functions are the bucketing and the verdict, because the judgement calls here are arithmetic (what rate counts as a problem, how few failures are too few to escalate) and arithmetic belongs somewhere you can read it.

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_filtered_messages_audit.py
"""Report Twilio senders whose messages are being filtered with error 30007.

Read only. GET requests and nothing else: give this an API Key with read access
rather than the account auth token. The repair is printed, never performed,
because this script holds a credential to an account that can send messages and
spend money.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

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

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

FILTERED = 30007


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

    It is null on every healthy message and a number on failed ones, but some
    exports and some client libraries hand it back as a string. Comparing the
    raw value against 30007 is the mistake that makes this whole audit report
    zero findings on an account that is drowning in 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):
    """Bucket outbound messages by the sender a carrier actually judges.

    Pure, so the grouping rule can be tested without a network. Inbound messages
    are skipped: they have no sender of ours and no delivery status worth
    counting.
    """
    out = {}
    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            continue
        key = m.get("messaging_service_sid") or m.get("from") or "unknown sender"
        row = out.setdefault(key, {"total": 0, "filtered": 0, "undelivered": 0,
                                   "sids": []})
        row["total"] += 1
        if str(m.get("status") or "").lower() == "undelivered":
            row["undelivered"] += 1
        if error_code(m) == FILTERED:
            row["filtered"] += 1
            if len(row["sids"]) < 3:
                row["sids"].append(m.get("sid"))
    return out


def verdict(stats, min_filtered=3):
    """Classify one sender's filtering rate. Pure, so the thresholds are
    visible and testable rather than buried in a request loop.

    Returns (state, detail).
    """
    total = int(stats.get("total") or 0)
    filtered = int(stats.get("filtered") or 0)

    if not filtered:
        return ("clean", "%d message(s), none filtered" % total)

    rate = (filtered / total) if total else 1.0

    if filtered < min_filtered:
        return ("isolated",
                "%d of %d filtered (%.1f%%). Too few to escalate: Support wants "
                "at least %d Message SIDs before it will review filtering."
                % (filtered, total, rate * 100, min_filtered))

    if rate >= 0.5:
        return ("sender-blocked",
                "%d of %d filtered (%.1f%%). At this rate the sender itself is "
                "the problem, not the wording: reputation damage or an "
                "unregistered sender, and you are billed for every one."
                % (filtered, total, rate * 100))

    return ("filtering",
            "%d of %d filtered (%.1f%%). Content or campaign mismatch: public "
            "link shorteners, no opt-out footer, or traffic that does not match "
            "the registered use case." % (filtered, total, 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. There is no Status or 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("--min-filtered", type=int, default=3,
                    help="fewer than this on one sender is reported as isolated")
    args = ap.parse_args()

    account = os.environ.get("TWILIO_ACCOUNT_SID")
    key = os.environ.get("TWILIO_API_KEY")
    secret = os.environ.get("TWILIO_API_SECRET")
    if not (account and key and secret):
        log.error("set TWILIO_ACCOUNT_SID, TWILIO_API_KEY and TWILIO_API_SECRET "
                  "(an API Key with read access, not the auth token)")
        return 2

    session = requests.Session()
    session.auth = (key, secret)

    since = (dt.date.today() - dt.timedelta(days=args.days)).isoformat()
    messages = list_messages(session, account, since, args.max_messages)
    if not messages:
        log.info("no messages sent since %s", since)
        return 0

    senders = tally(messages)
    bad = 0
    for sender, stats in sorted(senders.items()):
        state, detail = verdict(stats, args.min_filtered)
        line = "%-15s %s  %s" % (state, sender, detail)
        if state == "clean":
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        log.warning("  message sids: %s", ", ".join(str(s) for s in stats["sids"]))
        log.warning("  repair: no API call fixes 30007. Drop public link "
                    "shorteners, add an opt-out footer, confirm the A2P campaign "
                    "use case matches this traffic, then send those SIDs to "
                    "Twilio Support for a filtering review.")

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


if __name__ == "__main__":
    sys.exit(main())
twilio-filtered-messages-audit.mjs
/**
 * Report Twilio senders whose messages are being filtered with error 30007.
 *
 * 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 FILTERED = 30007;

/**
 * Read error_code as a number, or null. It is null on healthy messages and a
 * number on failed ones, but comparing the raw value against 30007 without this
 * is how the audit 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;
}

/**
 * Bucket outbound messages by the sender a carrier actually judges. Pure, so
 * the grouping rule can be tested without a network.
 */
export function tally(messages) {
  const out = new Map();
  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) continue;
    const key = m.messaging_service_sid || m.from || 'unknown sender';
    if (!out.has(key)) out.set(key, { total: 0, filtered: 0, undelivered: 0, sids: [] });
    const row = out.get(key);
    row.total += 1;
    if (String(m.status ?? '').toLowerCase() === 'undelivered') row.undelivered += 1;
    if (errorCode(m) === FILTERED) {
      row.filtered += 1;
      if (row.sids.length < 3) row.sids.push(m.sid);
    }
  }
  return out;
}

/**
 * Classify one sender's filtering rate. Pure, so the thresholds are visible and
 * testable. Returns [state, detail].
 */
export function verdict(stats, minFiltered = 3) {
  const total = Number(stats.total ?? 0);
  const filtered = Number(stats.filtered ?? 0);

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

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

  if (filtered < minFiltered) {
    return ['isolated',
      `${filtered} of ${total} filtered (${pct}%). Too few to escalate: Support ` +
      `wants at least ${minFiltered} Message SIDs before it will review filtering.`];
  }

  if (rate >= 0.5) {
    return ['sender-blocked',
      `${filtered} of ${total} filtered (${pct}%). At this rate the sender itself ` +
      'is the problem, not the wording: reputation damage or an unregistered ' +
      'sender, and you are billed for every one.'];
  }

  return ['filtering',
    `${filtered} of ${total} filtered (${pct}%). Content or campaign mismatch: ` +
    'public link shorteners, no opt-out footer, or traffic that does not match ' +
    'the registered use case.'];
}

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

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

  const senders = tally(messages);
  let bad = 0;
  for (const [sender, stats] of [...senders.entries()].sort()) {
    const [state, detail] = verdict(stats);
    const line = `${state.padEnd(15)} ${sender}  ${detail}`;
    if (state === 'clean') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    console.warn(`  message sids: ${stats.sids.join(', ')}`);
    console.warn('  repair: no API call fixes 30007. Drop public link shorteners, ' +
                 'add an opt-out footer, confirm the A2P campaign use case matches ' +
                 'this traffic, then send those SIDs to Twilio Support for a ' +
                 'filtering review.');
  }

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

Three rules are worth pinning down. An error_code that arrives as a string still has to match, because that is the difference between a report full of findings and an empty one. Two filtered messages out of two is not escalated, because Support will not review fewer than three. And a sender above half is a different state from one at five percent, because the first is a dead sender and the second is bad copy.

test_twilio_filtered_messages_audit.py
from twilio_filtered_messages_audit import error_code, tally, verdict


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


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


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


def test_tally_groups_on_the_messaging_service_when_there_is_one():
    rows = tally([
        {"sid": "SM1", "from": "+15550001111", "messaging_service_sid": "MG1",
         "status": "undelivered", "error_code": 30007},
        {"sid": "SM2", "from": "+15550002222", "messaging_service_sid": "MG1",
         "status": "delivered"},
    ])
    assert set(rows) == {"MG1"}
    assert rows["MG1"] == {"total": 2, "filtered": 1, "undelivered": 1,
                           "sids": ["SM1"]}


def test_tally_ignores_inbound_messages():
    rows = tally([{"sid": "SM1", "from": "+15559990000", "direction": "inbound",
                   "status": "received"}])
    assert rows == {}


def test_two_filtered_out_of_two_is_isolated_not_an_outage():
    # Support will not open a filtering review on fewer than three SIDs, so a
    # 100% rate on two messages is deliberately the quieter state.
    state, detail = verdict({"total": 2, "filtered": 2})
    assert state == "isolated"
    assert "at least 3" in detail


def test_a_sender_above_half_is_the_sender_not_the_wording():
    state, detail = verdict({"total": 10, "filtered": 8})
    assert state == "sender-blocked"
    assert "reputation" in detail


def test_a_low_but_real_rate_is_a_content_problem():
    state, detail = verdict({"total": 200, "filtered": 10})
    assert state == "filtering"
    assert "shorteners" in detail


def test_no_filtered_messages_is_clean():
    state, detail = verdict({"total": 500, "filtered": 0})
    assert state == "clean"
    assert "500" in detail


def test_sids_are_capped_at_the_three_support_asks_for():
    rows = tally([filtered("SM%d" % i) for i in range(9)])
    assert rows["+15550001111"]["sids"] == ["SM0", "SM1", "SM2"]
    assert rows["+15550001111"]["filtered"] == 9
    assert verdict(rows["+15550001111"])[0] == "sender-blocked"
    assert verdict(tally([delivered("SM9")])["+15550001111"])[0] == "clean"
twilio-filtered-messages-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { errorCode, tally, verdict } from './twilio-filtered-messages-audit.mjs';

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

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

test('tally groups on the messaging service when there is one', () => {
  const rows = tally([
    { sid: 'SM1', from: '+15550001111', messaging_service_sid: 'MG1',
      status: 'undelivered', error_code: 30007 },
    { sid: 'SM2', from: '+15550002222', messaging_service_sid: 'MG1',
      status: 'delivered' },
  ]);
  assert.deepEqual([...rows.keys()], ['MG1']);
  assert.deepEqual(rows.get('MG1'),
    { total: 2, filtered: 1, undelivered: 1, sids: ['SM1'] });
});

test('tally ignores inbound messages', () => {
  const rows = tally([{ sid: 'SM1', from: '+15559990000', direction: 'inbound',
                        status: 'received' }]);
  assert.equal(rows.size, 0);
});

test('two filtered out of two is isolated, not an outage', () => {
  const [state, detail] = verdict({ total: 2, filtered: 2 });
  assert.equal(state, 'isolated');
  assert.match(detail, /at least 3/);
});

test('a sender above half is the sender, not the wording', () => {
  const [state, detail] = verdict({ total: 10, filtered: 8 });
  assert.equal(state, 'sender-blocked');
  assert.match(detail, /reputation/);
});

test('a low but real rate is a content problem', () => {
  const [state, detail] = verdict({ total: 200, filtered: 10 });
  assert.equal(state, 'filtering');
  assert.match(detail, /shorteners/);
});

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

test('sids are capped at the three Support asks for', () => {
  const rows = tally([0, 1, 2, 3, 4, 5, 6, 7, 8].map((i) => filtered(`SM${i}`)));
  const row = rows.get('+15550001111');
  assert.deepEqual(row.sids, ['SM0', 'SM1', 'SM2']);
  assert.equal(row.filtered, 9);
  assert.equal(verdict(row)[0], 'sender-blocked');
});

FAQ

Why can't I just query Twilio for messages with error 30007?

Because the Messages list resource has no ErrorCode parameter and no Status parameter. The documented filters are To, From, DateSent, DateSent< and DateSent>, plus paging. Every 30007 report in existence pages the list and filters client-side, which is why so few accounts have one.

Am I charged for a message that gets filtered?

Yes. Twilio prices the send attempt; the carrier discards it afterwards. Cost and sent-volume charts look identical whether the message arrived or not, so spend monitoring cannot detect this and neither can a delivery count that does not read error_code.

What actually triggers the filter?

Most often a public link shortener in the body, missing opt-out language, traffic that does not match the registered A2P campaign use case, or a sender whose reputation is already damaged. Carriers do not publish the rules, which is why the per-sender rate matters more than any theory about the wording.

Why does the script want three Message SIDs before it says anything is wrong?

Because that is what a Support filtering review needs. One or two 30007s inside a large volume is noise you cannot act on; three from the same sender is a ticket. The threshold is an argument so you can lower it when you already know something is wrong.

Can the script un-filter anything?

No, and nothing else can either. There is no API field, no resource to update, no setting to flip. The repair is a content change, a registration change, or a Support escalation, so the script prints the escalation and the SIDs to attach to it.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.