DEV Community

SMSRoute Dev
SMSRoute Dev

Posted on

STOP Is Not a String Match: Building an Opt-Out State Machine That Survives Audits

The one-line implementation and what it misses

Nearly every SMS integration starts here:

if body.strip().upper() == "STOP":
    unsubscribe(sender)
Enter fullscreen mode Exit fullscreen mode

It handles the exact string and nothing else. Real inbound traffic is messier than that in ways that are entirely predictable once you look at a week of logs:

"stop"                    lowercase
"STOP "                   trailing whitespace
"Stop."                   punctuation
"STOP PLEASE"             extra words
"please stop texting me"  keyword not first
"UNSUBSCRIBE"             different keyword
"ARRÊT"                   non-English keyword
"ST0P"                    zero for O
Enter fullscreen mode Exit fullscreen mode

Only the first three are caught by trimming and case-folding. The rest walk straight through, the user keeps receiving messages after asking to stop, and you have both an angry recipient and a compliance problem — carriers act on opt-out complaints, and the consequence lands on your sender reputation before it lands anywhere else.

Normalize, then match on tokens

The fix is a normalization pass followed by token matching rather than whole-string equality.

import re, unicodedata

STOP_WORDS = {"STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT",
              "ARRET", "ARRETER", "ALTO", "PARE"}
START_WORDS = {"START", "YES", "UNSTOP", "SUBSCRIBE"}
HELP_WORDS  = {"HELP", "INFO", "AIDE"}

def normalize(text: str) -> list[str]:
    # strip accents so ARRÊT matches ARRET
    text = unicodedata.normalize("NFKD", text)
    text = "".join(c for c in text if not unicodedata.combining(c))
    text = text.upper()
    # collapse punctuation to spaces, then split
    return [t for t in re.split(r"[^A-Z0-9]+", text) if t]

def classify(text: str) -> str | None:
    tokens = normalize(text)
    if not tokens:
        return None
    # a bare keyword, or a keyword leading the message
    if tokens[0] in STOP_WORDS:
        return "STOP"
    if tokens[0] in START_WORDS:
        return "START"
    if tokens[0] in HELP_WORDS:
        return "HELP"
    # keyword anywhere in a short message: "please stop texting me"
    if len(tokens) <= 5 and STOP_WORDS & set(tokens):
        return "STOP"
    return None
Enter fullscreen mode Exit fullscreen mode

Two deliberate choices are worth naming.

The length guard on the anywhere-match exists to avoid false positives. A long message containing the word "stop" incidentally — "can you stop by the office" — should not unsubscribe anyone. Short messages containing a stop keyword almost always mean it; long ones often do not.

Leetspeak substitutions like ST0P are intentionally not normalized. Mapping digits to letters produces false positives on legitimate content, and the population of users typing a zero deliberately is small. Log unmatched inbound messages and review them periodically rather than guessing at transformations up front — the log tells you what your actual users send, which beats any list you can imagine.

Opt-out is scoped, and the scope is a design decision

"Unsubscribed" is not a single boolean on a user row. The question is always unsubscribed from what, and getting this wrong produces either compliance failures or unnecessary lost reach.

The scope that matters operationally is the sending identity — the long code, short code, or alphanumeric sender ID the user replied to. A user who texts STOP to your marketing sender has not asked to stop receiving login codes from your transactional sender, and suppressing those breaks their account access. Conversely, if you send from a pool of numbers and scope opt-out to the individual number, the next message from a different number in the pool reaches a user who asked you to stop. That is the failure carriers hear about.

A workable model scopes suppression to (recipient, sending_identity) where sending identity is a logical stream rather than a physical number:

CREATE TABLE suppression (
    phone_e164       TEXT        NOT NULL,
    stream           TEXT        NOT NULL,   -- 'marketing', 'transactional', ...
    state            TEXT        NOT NULL,   -- 'opted_out' | 'opted_in'
    changed_at       TIMESTAMPTZ NOT NULL,
    source           TEXT        NOT NULL,   -- 'inbound_sms' | 'api' | 'support'
    inbound_message  TEXT,                   -- verbatim text that caused it
    PRIMARY KEY (phone_e164, stream)
);

CREATE TABLE suppression_log (   -- append-only history, never updated
    id               BIGSERIAL PRIMARY KEY,
    phone_e164       TEXT        NOT NULL,
    stream           TEXT        NOT NULL,
    state            TEXT        NOT NULL,
    changed_at       TIMESTAMPTZ NOT NULL,
    source           TEXT        NOT NULL,
    inbound_message  TEXT
);
Enter fullscreen mode Exit fullscreen mode

The current-state table answers "may I send to this number right now" with a single indexed lookup on the send path. The append-only log answers "prove this user consented on that date", which is the question that actually gets asked when a complaint arrives. Do not try to serve both from one mutable table; the moment you update a row you have destroyed the evidence.

Store the verbatim inbound text. When a dispute lands months later, "the user sent STOP" is an assertion, whereas the stored message is evidence.

The race that silently re-subscribes people

This is the bug that survives a correct keyword matcher and a correct data model.

Opt-out arrives as an inbound webhook. Campaign sends are dispatched from a queue. Between the moment a message is enqueued and the moment it is handed to the carrier, an opt-out can land. If the suppression check happens at enqueue time only, every message already in the queue goes out after the user asked you to stop.

For a large campaign the queue can hold minutes of work. A user who replies STOP to the first message keeps receiving the remainder.

# WRONG: checked once, at enqueue
for phone in audience:
    if not suppressed(phone, stream):
        queue.push(build(phone))       # opt-out after this point is ignored
Enter fullscreen mode Exit fullscreen mode

The check must happen immediately before dispatch, not only at enqueue:

# enqueue-time check is still worth doing — it avoids queueing obvious no-ops
for phone in audience:
    if not suppressed(phone, stream):
        queue.push(build(phone))

# but the authoritative check is at send time
def dispatch(job):
    if suppressed(job.phone, job.stream):       # re-check, always
        record_skipped(job, reason="suppressed_after_enqueue")
        return
    provider.send(job)
Enter fullscreen mode Exit fullscreen mode

Recording the skip rather than dropping silently matters. "We suppressed 1,240 messages mid-campaign because those users opted out" is a defensible operational story; an unexplained gap between audience size and messages sent is not.

The same reasoning applies to any scheduling delay. A message scheduled for tomorrow must be checked against tomorrow's suppression state, not today's.

Confirmations, and the loop to avoid

Regulators and carriers generally expect a single confirmation message acknowledging the opt-out, after which you send nothing further on that stream. Two constraints follow.

The confirmation must bypass the suppression check, because the user is now suppressed and a naive implementation will suppress the confirmation itself. Mark it explicitly:

def on_stop(phone, stream, raw_text):
    set_state(phone, stream, "opted_out", source="inbound_sms", inbound=raw_text)
    send(phone, CONFIRMATION_TEXT, stream=stream, bypass_suppression=True)
Enter fullscreen mode Exit fullscreen mode

And exactly one confirmation, ever. If a user sends STOP three times and you reply three times, you have sent two messages to someone who asked you to stop. Send the confirmation only on an actual state transition — when the row moved from opted-in to opted-out — not on every inbound STOP. If they were already suppressed, record the inbound message and stay silent.

That distinction is easy to encode: perform the state write conditionally and send only if it changed a row.

START must be as reliable as STOP

Opt-in reversal gets a fraction of the attention and produces support tickets that are hard to diagnose, because the user is certain they fixed it and your system disagrees.

Treat START symmetrically: same normalization, same token matching, same scoping, same append-only log entry, same single confirmation on transition. The one asymmetry worth keeping is direction of doubt. When classification is ambiguous, resolve toward suppression — an unnecessary opt-out costs you one recipient, while a missed opt-out costs sender reputation and potentially a carrier complaint.

What an audit actually asks for

The questions that arrive during a complaint investigation are narrower and more concrete than most teams expect:

  • For this number, what is the current state on this stream, and when did it change?
  • What was the verbatim inbound message that caused the change?
  • Show every message sent to this number after that timestamp.
  • If any were sent, why — which code path bypassed suppression, and was that legitimate?

Every one of these is answerable in seconds if you kept the append-only log with verbatim text and recorded suppression skips. None are answerable from a mutable boolean column, and reconstructing them from application logs after the fact is painful and unconvincing.

Handling of inbound keywords also differs by provider — some intercept STOP at the platform level and never deliver it to your webhook, others pass everything through and expect you to act. Those two worlds require different code, and assuming the wrong one means either double-handling or no handling at all. Confirm which model your gateway uses before you build; whichever SMS gateway you integrate should document whether keywords are intercepted upstream or delivered to you.

Summary

  • Normalize (strip accents, fold case, split on punctuation) then match tokens; do not compare whole strings.
  • Guard anywhere-in-message matching with a length limit to avoid false positives.
  • Scope suppression to (number, logical stream), not to a global boolean or a physical number.
  • Keep a mutable current-state table for the send path and an append-only log for evidence.
  • Re-check suppression immediately before dispatch, not only at enqueue, and record every skip.
  • Send exactly one confirmation, on state transition only, bypassing suppression.
  • Make START as robust as STOP, but resolve ambiguity toward suppression.

Top comments (0)