Skip to content

Diagnostic Twilio

sends to recipients who texted STOP bounce with 21610

Someone replied STOP four months ago. Twilio recorded it, blocked that sender from reaching them, and has been rejecting your sends with 21610 ever since. You were never charged, so nothing showed up on the bill; your send queue treated the rejection as a transient failure and retried; and the only place the whole story exists is the Messages list, which has no filter for it.

Read-only key Python and Node.js Tests included
Two envelopes on a table
Photo by Markus Spiske on Unsplash
The short answer

Page GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, collect the distinct to values on rows with error_code 21610, and separately collect inbound rows whose body is exactly STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT. Join the two on the consumer's number.

Twilio exposes no read API for the opt-out list. These rejections and these inbound keywords are the only evidence available to a read-only credential, which is exactly why the list has to be rebuilt from them and then stored on your side.

The problem in plain words

The opt-out is honoured either way — that is the part that hides it. Twilio rejects the send at request time, the recipient is not contacted, and nobody's phone buzzes. No message is billed. From the outside the system is behaving correctly, and in the narrow sense it is: the platform is enforcing an opt-out that your application forgot.

What you actually have is a record, growing daily, of your application attempting to contact someone who asked you to stop. A regulator, an auditor or a plaintiff reads that as intent. And because most send queues treat a 400 as retryable, the usual shape is not one attempt per campaign but dozens per day against the same number, each one another row.

Recipient textsSTOPinbound messageTwilio blockssenderopt-out storedYour app neverhearswebhook missed itNext sendrejectederror 21610Queue retriesitagain tomorrow
Every step behaves correctly. The opt-out is enforced, nobody is contacted, nothing is billed, and the record of trying keeps growing.

Why it happens

The opt-out lives on Twilio's side and cannot be read back. There is no endpoint that lists the numbers who have opted out of a sender or a Messaging Service. Nothing to sync from, nothing to reconcile against — the state exists, it is enforced, and it is invisible to your code until a send bounces off it.

Nobody told the application. The STOP arrives as an inbound message. If the inbound webhook is missing, filtered, or wired to a handler that only looks for the words your product cares about, the opt-out is enforced by Twilio and never written to your database. Then your normal sending logic keeps selecting that contact forever.

Opt-out is per sender, and reassignment is real. A recipient who stopped one long code has not stopped the others, so the same contact can be blocked on one sender in a pool and reachable on another. Separately, carriers recycle numbers: the person who opted out may not be the person who signed up.

Only the recipient can undo it. START, UNSTOP or YES from their handset is the sole way back. There is no support ticket, no API call and no console button that re-subscribes someone on their behalf, which makes cleaning your own list the entire repair.

The fix, as a flow

The script joins two directions of the same conversation: the inbound STOP is keyed on the sender's number and the rejected sends are keyed on the recipient's, and the finding only exists when both land on one person.

Rejections and keywordsjoined on the consumer numberSTOP, then silencesuppressed correctlySTOP, then more sendsyour database missed itRejections, no STOPopted out before the windowDozens of rejectionsa retry loop, not a contact
There is no read API for the opt-out list, so these rejections are the only material a read-only credential has to rebuild it from.

How to fix it

Page the Messages list over a window you can defend

GET /2010-04-01/Accounts/{AccountSid}/Messages.json?DateSent>=YYYY-MM-DD&PageSize=1000, following next_page_uri. There is no ErrorCode filter, so the window is the only lever you have on the size of the read. Thirty days is usually enough to find the loops; ninety builds a better suppression list.

Collect the rejections

Outbound rows with error_code 21610. Group them by to and count: one rejection is a stale contact, forty is a retry loop, and those need different conversations. Cross-check against GET https://monitor.twilio.com/v1/Alerts?LogLevel=error if you want the request that was rejected.

Match inbound keywords the way Twilio matches them

Twilio compares the whole body, case-insensitively, after trimming whitespace. STOP opts out; stop opts out; STOP please does not. A substring search here inflates your opt-out list with everyone who wrote "please stop sending these at 6am", which is a different problem with a different fix.

Join on the consumer's number

An inbound STOP is keyed on from; an outbound rejection is keyed on to. Both are the same person. The pairing you are looking for is a STOP followed by sends that were rejected afterwards: that is proof the opt-out reached Twilio and never reached your database.

Write the list down on your side, then stop the loop

Mark every number found as unsubscribed in your own store, because nothing on Twilio's side will tell you again. Fix the inbound handler that missed the keyword, make your queue treat 21610 as permanent rather than retryable, and turn on Advanced Opt-Out on the Messaging Service so keywords and confirmation replies are identical across every sender in the pool.

How to check it worked

Re-run over the same window after the suppression list is loaded. Recipients who opted out should report suppressed, and nothing should be in a retry loop.

python3 twilio_opt_out_audit.py --days 30
# 63 recipient(s) over 30 day(s), 0 still being messaged after STOP

The full code

One paginated GET, read with an API Key that has read access and nothing more. Two pure functions carry the note: the keyword matcher, which has to reproduce Twilio's whole-body rule rather than a friendly approximation of it, and the verdict, which decides whether a rejection is a stale contact or a machine that will not take no for an answer.

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_opt_out_audit.py
"""Rebuild Twilio's opt-out list from 21610 rejections and inbound keywords.

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.
"""
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_opt_out_audit")

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

UNSUBSCRIBED = 21610

OPT_OUT = ("STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT")
OPT_IN = ("START", "UNSTOP", "YES")


def error_code(message):
    """Read error_code as an integer, or None. It is null on healthy messages
    and a number on rejected ones; a string comparison finds nothing."""
    raw = message.get("error_code")
    if raw is None or raw == "":
        return None
    try:
        return int(raw)
    except (TypeError, ValueError):
        return None


def keyword_kind(body):
    """Return "out", "in" or "" for one inbound message body.

    Twilio matches the entire body, case-insensitively, after trimming
    whitespace. "STOP" opts out and "STOP please" does not. Matching loosely
    here fills the suppression list with people who merely complained, which is
    a different problem with a different repair.
    """
    word = str(body or "").strip().upper()
    if word in OPT_OUT:
        return "out"
    if word in OPT_IN:
        return "in"
    return ""


def tally(messages):
    """Group both directions onto the consumer's number.

    An inbound keyword is keyed on `from`, an outbound rejection on `to`, and
    they are the same person. Pure, so the join can be tested without a network.
    """
    out = {}

    def row(number):
        return out.setdefault(str(number or "unknown"),
                              {"rejected": 0, "stops": 0, "starts": 0, "sids": []})

    for m in messages:
        if str(m.get("direction") or "").startswith("inbound"):
            kind = keyword_kind(m.get("body"))
            if kind == "out":
                row(m.get("from"))["stops"] += 1
            elif kind == "in":
                row(m.get("from"))["starts"] += 1
            continue
        if error_code(m) == UNSUBSCRIBED:
            r = row(m.get("to"))
            r["rejected"] += 1
            if len(r["sids"]) < 3:
                r["sids"].append(m.get("sid"))
    return out


def verdict(record, loop_threshold=10):
    """Classify one recipient. Pure, so the rules stay readable.

    Returns (state, detail).
    """
    rejected = int(record.get("rejected") or 0)
    stops = int(record.get("stops") or 0)
    starts = int(record.get("starts") or 0)

    note = ""
    if starts:
        note = (" A START was seen from this number too, and that re-subscribes "
                "them to one sender only, so the rejections are from a different "
                "sender in the pool.")

    if not rejected:
        if stops:
            return ("suppressed",
                    "texted an opt-out keyword %d time(s) and nothing has been "
                    "sent to them since." % stops + note)
        return ("clean", "no 21610 rejections and no opt-out keywords." + note)

    if rejected >= loop_threshold:
        return ("retry-loop",
                "%d sends rejected with 21610: something is retrying an opt-out "
                "on a loop. Twilio rejects each one at request time so none are "
                "billed, but each is a record of contacting someone who asked "
                "you to stop." % rejected + note)

    if stops:
        return ("ignored-opt-out",
                "texted an opt-out keyword %d time(s), then %d send(s) went out "
                "and were rejected with 21610: the opt-out reached Twilio and "
                "never reached your database." % (stops, rejected) + note)

    return ("invisible-opt-out",
            "%d send(s) rejected with 21610 and no opt-out keyword in this "
            "window: it happened before the window or on another sender. There "
            "is no read API for the opt-out list, so these rejections are the "
            "only evidence you will get." % rejected + note)


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 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=30,
                    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("--loop-threshold", type=int, default=10,
                    help="rejections against one number that count as a retry loop")
    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

    people = tally(messages)
    bad = 0
    for number, record in sorted(people.items()):
        state, detail = verdict(record, args.loop_threshold)
        line = "%-18s %s  %s" % (state, number, detail)
        if state in ("clean", "suppressed"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if record["sids"]:
            log.warning("  message sids: %s",
                        ", ".join(str(s) for s in record["sids"]))
        log.warning("  repair: mark %s unsubscribed in your own database. Twilio "
                    "exposes no read API for the opt-out list and only the "
                    "recipient texting START, UNSTOP or YES re-subscribes them. "
                    "Enable Advanced Opt-Out on the Messaging Service so the "
                    "keywords are identical across every sender.", number)

    log.info("%d recipient(s) over %d day(s), %d still being messaged after STOP",
             len(people), args.days, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
twilio-opt-out-audit.mjs
/**
 * Rebuild Twilio's opt-out list from 21610 rejections and inbound keywords.
 *
 * 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 UNSUBSCRIBED = 21610;

const OPT_OUT = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'];
const OPT_IN = ['START', 'UNSTOP', 'YES'];

/** Read error_code as a number, or null. A string comparison finds nothing. */
export function errorCode(message) {
  const raw = message.error_code;
  if (raw === null || raw === undefined || raw === '') return null;
  const n = Number(raw);
  return Number.isFinite(n) ? n : null;
}

/**
 * Return 'out', 'in' or '' for one inbound body. Twilio matches the entire
 * body, case-insensitively, after trimming: 'STOP' opts out, 'STOP please' does
 * not. Matching loosely fills the suppression list with people who complained.
 */
export function keywordKind(body) {
  const word = String(body ?? '').trim().toUpperCase();
  if (OPT_OUT.includes(word)) return 'out';
  if (OPT_IN.includes(word)) return 'in';
  return '';
}

/**
 * Group both directions onto the consumer's number: inbound keywords are keyed
 * on `from`, outbound rejections on `to`, and they are the same person. Pure.
 */
export function tally(messages) {
  const out = new Map();
  const row = (number) => {
    const k = String(number ?? 'unknown');
    if (!out.has(k)) out.set(k, { rejected: 0, stops: 0, starts: 0, sids: [] });
    return out.get(k);
  };

  for (const m of messages) {
    if (String(m.direction ?? '').startsWith('inbound')) {
      const kind = keywordKind(m.body);
      if (kind === 'out') row(m.from).stops += 1;
      else if (kind === 'in') row(m.from).starts += 1;
      continue;
    }
    if (errorCode(m) === UNSUBSCRIBED) {
      const r = row(m.to);
      r.rejected += 1;
      if (r.sids.length < 3) r.sids.push(m.sid);
    }
  }
  return out;
}

/** Classify one recipient. Pure. Returns [state, detail]. */
export function verdict(record, loopThreshold = 10) {
  const rejected = Number(record.rejected ?? 0);
  const stops = Number(record.stops ?? 0);
  const starts = Number(record.starts ?? 0);

  const note = starts
    ? ' A START was seen from this number too, and that re-subscribes them to ' +
      'one sender only, so the rejections are from a different sender in the pool.'
    : '';

  if (!rejected) {
    if (stops) {
      return ['suppressed',
        `texted an opt-out keyword ${stops} time(s) and nothing has been sent ` +
        `to them since.${note}`];
    }
    return ['clean', `no 21610 rejections and no opt-out keywords.${note}`];
  }

  if (rejected >= loopThreshold) {
    return ['retry-loop',
      `${rejected} sends rejected with 21610: something is retrying an opt-out ` +
      'on a loop. Twilio rejects each one at request time so none are billed, ' +
      `but each is a record of contacting someone who asked you to stop.${note}`];
  }

  if (stops) {
    return ['ignored-opt-out',
      `texted an opt-out keyword ${stops} time(s), then ${rejected} send(s) ` +
      'went out and were rejected with 21610: the opt-out reached Twilio and ' +
      `never reached your database.${note}`];
  }

  return ['invisible-opt-out',
    `${rejected} send(s) rejected with 21610 and no opt-out keyword in this ` +
    'window: it happened before the window or on another sender. There is no ' +
    'read API for the opt-out list, so these rejections are the only evidence ' +
    `you will get.${note}`];
}

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] : 30) || 30;
  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 people = tally(messages);
  let bad = 0;
  for (const [number, record] of [...people.entries()].sort()) {
    const [state, detail] = verdict(record);
    const line = `${state.padEnd(18)} ${number}  ${detail}`;
    if (state === 'clean' || state === 'suppressed') { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (record.sids.length) console.warn(`  message sids: ${record.sids.join(', ')}`);
    console.warn(`  repair: mark ${number} unsubscribed in your own database. ` +
                 'Twilio exposes no read API for the opt-out list and only the ' +
                 'recipient texting START, UNSTOP or YES re-subscribes them. ' +
                 'Enable Advanced Opt-Out on the Messaging Service so the ' +
                 'keywords are identical across every sender.');
  }

  console.log(`${people.size} recipient(s) over ${days} day(s), ${bad} still ` +
              'being messaged after STOP');
  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 keyword rule is the one to pin: stop in lower case is an opt-out and STOP please is not, because that is where a well-meaning substring match starts suppressing customers who never asked to leave. After that, the join — an inbound STOP is keyed on from and the rejected sends on to, and the finding only exists when both land on the same person.

test_twilio_opt_out_audit.py
from twilio_opt_out_audit import keyword_kind, tally, verdict

CONSUMER = "+15557654321"


def inbound(body):
    return {"sid": "SMin", "direction": "inbound", "from": CONSUMER,
            "to": "+15550001111", "body": body}


def rejected(sid):
    return {"sid": sid, "direction": "outbound-api", "from": "+15550001111",
            "to": CONSUMER, "status": "failed", "error_code": 21610}


def test_keyword_matching_follows_twilios_whole_body_rule():
    assert keyword_kind("STOP") == "out"
    assert keyword_kind("  stop  ") == "out"
    assert keyword_kind("Unsubscribe") == "out"
    assert keyword_kind("START") == "in"
    # The line that keeps complainers out of the suppression list.
    assert keyword_kind("STOP please") == ""
    assert keyword_kind("please stop sending these at 6am") == ""
    assert keyword_kind(None) == ""


def test_the_join_puts_the_inbound_stop_and_the_rejections_on_one_person():
    rows = tally([inbound("STOP"), rejected("SM1"), rejected("SM2")])
    assert set(rows) == {CONSUMER}
    assert rows[CONSUMER]["stops"] == 1
    assert rows[CONSUMER]["rejected"] == 2
    assert rows[CONSUMER]["sids"] == ["SM1", "SM2"]


def test_stop_seen_and_sends_afterwards_is_the_finding():
    state, detail = verdict({"rejected": 2, "stops": 1})
    assert state == "ignored-opt-out"
    assert "never reached your database" in detail


def test_rejections_with_no_stop_in_the_window_are_still_actionable():
    state, detail = verdict({"rejected": 3, "stops": 0})
    assert state == "invisible-opt-out"
    assert "no read API" in detail


def test_a_retry_loop_outranks_everything_else():
    state, detail = verdict({"rejected": 40, "stops": 1})
    assert state == "retry-loop"
    assert "not billed" not in detail
    assert "none are billed" in detail


def test_a_start_is_reported_as_a_different_sender_not_a_mistake():
    state, detail = verdict({"rejected": 1, "stops": 1, "starts": 1})
    assert state == "ignored-opt-out"
    assert "different sender" in detail


def test_stop_with_no_sends_afterwards_is_correct_behaviour():
    state, detail = verdict({"rejected": 0, "stops": 1})
    assert state == "suppressed"
    assert verdict({"rejected": 0, "stops": 0})[0] == "clean"
    assert "nothing has been sent" in detail
twilio-opt-out-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { keywordKind, tally, verdict } from './twilio-opt-out-audit.mjs';

const CONSUMER = '+15557654321';
const inbound = (body) => ({ sid: 'SMin', direction: 'inbound', from: CONSUMER,
                             to: '+15550001111', body });
const rejected = (sid) => ({ sid, direction: 'outbound-api', from: '+15550001111',
                             to: CONSUMER, status: 'failed', error_code: 21610 });

test('keyword matching follows twilio whole body rule', () => {
  assert.equal(keywordKind('STOP'), 'out');
  assert.equal(keywordKind('  stop  '), 'out');
  assert.equal(keywordKind('Unsubscribe'), 'out');
  assert.equal(keywordKind('START'), 'in');
  assert.equal(keywordKind('STOP please'), '');
  assert.equal(keywordKind('please stop sending these at 6am'), '');
  assert.equal(keywordKind(null), '');
});

test('the join puts the inbound stop and the rejections on one person', () => {
  const rows = tally([inbound('STOP'), rejected('SM1'), rejected('SM2')]);
  assert.deepEqual([...rows.keys()], [CONSUMER]);
  assert.equal(rows.get(CONSUMER).stops, 1);
  assert.equal(rows.get(CONSUMER).rejected, 2);
  assert.deepEqual(rows.get(CONSUMER).sids, ['SM1', 'SM2']);
});

test('stop seen and sends afterwards is the finding', () => {
  const [state, detail] = verdict({ rejected: 2, stops: 1 });
  assert.equal(state, 'ignored-opt-out');
  assert.match(detail, /never reached your database/);
});

test('rejections with no stop in the window are still actionable', () => {
  const [state, detail] = verdict({ rejected: 3, stops: 0 });
  assert.equal(state, 'invisible-opt-out');
  assert.match(detail, /no read API/);
});

test('a retry loop outranks everything else', () => {
  const [state, detail] = verdict({ rejected: 40, stops: 1 });
  assert.equal(state, 'retry-loop');
  assert.match(detail, /none are billed/);
});

test('a start is reported as a different sender, not a mistake', () => {
  const [state, detail] = verdict({ rejected: 1, stops: 1, starts: 1 });
  assert.equal(state, 'ignored-opt-out');
  assert.match(detail, /different sender/);
});

test('stop with no sends afterwards is correct behaviour', () => {
  const [state, detail] = verdict({ rejected: 0, stops: 1 });
  assert.equal(state, 'suppressed');
  assert.match(detail, /nothing has been sent/);
  assert.equal(verdict({ rejected: 0, stops: 0 })[0], 'clean');
});

FAQ

Can I download the list of numbers that have opted out?

No. Twilio enforces the opt-out but publishes no read API for it, on the number, the Messaging Service or the account. Rebuilding the list from 21610 rejections and inbound keywords is the only route a read-only credential has, which is also why the list has to be stored on your side once you have it.

Does a 21610 cost me anything?

Not in money. The send is rejected at request time and no segment is billed. The cost is the record: each rejection is a logged attempt to contact someone who asked you to stop, and volume makes that look deliberate rather than accidental.

Why does the script ignore a message that reads STOP please?

Because Twilio does. The keyword match is against the whole body after trimming, case-insensitively. Matching substrings would add every annoyed customer to your suppression list, silencing people who never opted out and hiding the real ones in the noise.

Someone opted out of one number but not another. Is that a bug?

No, that is how it works. Opt-out is scoped to the sender, so a recipient blocked on one long code stays reachable on the others in the pool. Advanced Opt-Out on the Messaging Service is what makes the behaviour consistent, and treating the contact as globally unsubscribed in your own database is what makes it right.

How do I put someone back on the list after they opted out?

You cannot, and neither can Twilio Support. Only the recipient texting START, UNSTOP or YES to that sender clears the block. Any flow that promises a customer they will start receiving messages again after they call you is a flow that will not work.

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.