# SMS STOP keyword immediately removes user from marketing SMS lists

- **Pattern:** `ab-001240` (`email-sms-compliance.unsubscribe.sms-stop-honored`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001240
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001240)

## Why it matters

TCPA §227(b) imposes strict liability for sending SMS to numbers on the national Do-Not-Call registry or after a STOP reply — penalties run $500–$1,500 per message. Most SMS providers (Twilio, Vonage) auto-honor STOP at the carrier level, but that protection evaporates the moment you switch providers or add a second sending path. Without an inbound webhook that writes opt-out status to your database, the only suppression record lives in a third-party system you don't control. GDPR Art. 7(3) and CCPA §1798.120 add parallel requirements: withdrawal of consent must be honored immediately and must propagate to all processing activities.

## Severity rationale

High because relying solely on carrier-level STOP handling leaves opted-out numbers unprotected if the provider changes or a second SMS path is added — each message sent to an opted-out number is an independent TCPA violation with statutory damages.

## Remediation

Configure an inbound Twilio webhook that validates the signature and writes `smsOptOut: true` synchronously, then check that flag in every outbound send.

```ts
// app/api/webhooks/sms/route.ts
const STOP_KEYWORDS = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']

export async function POST(req: Request) {
  const body = await req.formData()
  const params = Object.fromEntries(body.entries()) as Record<string, string>
  const sig = req.headers.get('x-twilio-signature') ?? ''
  if (!validateRequest(process.env.TWILIO_AUTH_TOKEN!, sig, process.env.TWILIO_WEBHOOK_URL!, params)) {
    return new Response('Forbidden', { status: 403 })
  }
  if (STOP_KEYWORDS.includes((params['Body'] ?? '').trim().toUpperCase())) {
    await db.smsSubscriber.updateMany({
      where: { phoneNumber: params['From'] },
      data: { smsOptOut: true, optOutAt: new Date() },
    })
  }
  return new Response('<Response></Response>', { headers: { 'Content-Type': 'text/xml' } })
}
```

Also guard every outbound send with a `smsOptOut: false` check before calling the provider API.

## Detection

- **ID:** `sms-stop-honored`
- **Severity:** `high`
- **What to look for:** Enumerate every relevant item. TCPA and CTIA guidelines require that SMS senders honor STOP (and equivalent keywords: STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) immediately. Most SMS service providers (Twilio, Vonage, Plivo) automatically handle STOP at the carrier/platform level and block further sends to that number. However, the application must also update its own database to reflect the opt-out — otherwise, if you switch providers or send through a different channel, you will re-message an opted-out user. Check whether there is an inbound SMS webhook handler that processes STOP replies. Check whether the handler updates the subscriber's opt-out status in the database. Check whether the `sms_opt_in` or equivalent field is checked before every outbound SMS send.
- **Pass criteria:** At least 1 of the following conditions is met. An inbound SMS webhook handler exists that receives carrier opt-out notifications (or directly handles STOP replies) and immediately updates the subscriber's SMS opt-out status in the database. The outbound SMS send logic checks this status and never sends marketing SMS to opted-out numbers. If the SMS provider handles STOP automatically, the application additionally syncs this status to its own database.
- **Fail criteria:** No inbound SMS webhook configured. STOP is handled by the SMS provider but the application database is never updated, creating a gap if the sending channel changes. Outbound SMS send does not check local opt-out status.
- **Do NOT pass when:** The item exists only as a placeholder, stub, or TODO comment — partial implementation does not count as passing.
- **Skip (N/A) when:** The application sends no marketing or promotional SMS — only transactional notifications (e.g., OTP codes, appointment reminders triggered by user action).
- **Cross-reference:** For user-facing accessibility and compliance, the Accessibility Basics audit covers foundational requirements.
- **Detail on fail:** Example: `"Twilio is configured for outbound SMS but no inbound webhook handler found in codebase. STOP handling is entirely delegated to Twilio; local database never updated. If sending channel changes, opted-out users will be re-messaged."` or `"No SMS opt-out status field in database. STOP replies not processed."`.
- **Remediation:** Configure an inbound webhook and sync opt-out status to your database:

  ```ts
  // app/api/webhooks/sms/route.ts — Twilio inbound SMS webhook
  import { validateRequest } from 'twilio'

  export async function POST(req: Request) {
    // Verify the request came from Twilio
    const url = process.env.TWILIO_WEBHOOK_URL!
    const authToken = process.env.TWILIO_AUTH_TOKEN!
    const sig = req.headers.get('x-twilio-signature') ?? ''
    const body = await req.formData()
    const params = Object.fromEntries(body.entries()) as Record<string, string>

    if (!validateRequest(authToken, sig, url, params)) {
      return new Response('Forbidden', { status: 403 })
    }

    const from = params['From'] ?? ''
    const messageBody = (params['Body'] ?? '').trim().toUpperCase()

    const stopKeywords = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT']

    if (stopKeywords.includes(messageBody)) {
      // Immediately mark the number as opted out
      await db.smsSubscriber.updateMany({
        where: { phoneNumber: from },
        data: { smsOptOut: true, optOutAt: new Date(), optOutKeyword: messageBody },
      })
    }

    // Twilio expects a TwiML response
    return new Response('<Response></Response>', {
      headers: { 'Content-Type': 'text/xml' },
    })
  }
  ```

  ```ts
  // In your outbound SMS sending function — always check opt-out status
  export async function sendMarketingSms(to: string, body: string) {
    const subscriber = await db.smsSubscriber.findFirst({ where: { phoneNumber: to } })
    if (!subscriber || subscriber.smsOptOut) {
      console.warn(`[sms] Skipping opted-out number: ${to}`)
      return
    }
    // Proceed with send
  }
  ```

---

## External references

- external TCPA-§227(b) — https://www.fcc.gov/consumers/guides/stop-unwanted-robocalls-and-texts
- gdpr Art. 7(3)
- ccpa §1798.120

Taxons: regulatory-conformance, privacy-consent

HTML version: https://auditbuffet.com/patterns/ab-001240
