# SMS opt-out via STOP persisted and respected for all future messages

- **Pattern:** `ab-000563` (`booking-notifications-reminders.sms.sms-optout-persisted`)
- **Severity:** critical
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000563
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000563)

## Why it matters

TCPA (47 U.S.C. § 227) imposes statutory damages of $500–$1,500 per message sent to a consumer who has opted out via STOP. GDPR Article 21 and CCPA § 1798.120 impose parallel opt-out obligations in their jurisdictions. An in-memory opt-out store survives only until the process restarts; a persistent database table that is never read before sends is functionally equivalent to no opt-out at all. The failure mode is silent: every SMS send succeeds from the carrier's perspective while each one adds to legal exposure. A single batch run against a list that includes opted-out numbers can generate thousands of violations before anyone notices.

## Severity rationale

Critical because sending SMS to opted-out numbers after a STOP violates TCPA and exposes the business to $500–$1,500 per-message statutory damages with no cap.

## Remediation

Persist opt-out status in a dedicated database table and route every SMS send through a single function that checks it before calling the carrier API. Add a webhook handler at `src/app/api/webhooks/sms/route.ts` that writes STOP replies to the table immediately:

```ts
// prisma/schema.prisma
model SmsOptOut {
  phone_number String    @id
  opted_out_at DateTime
  opted_in_at  DateTime?
}

// src/lib/sms.ts — central send function
async function sendSMS(phoneNumber: string, message: string) {
  const optOut = await db.smsOptOut.findUnique({ where: { phone_number: phoneNumber } })
  if (optOut?.opted_out_at && !optOut.opted_in_at) return { blocked: true }
  return twilioClient.messages.create({ body: message, from: process.env.TWILIO_PHONE!, to: phoneNumber })
}
```

Every SMS-sending function in the codebase must call `sendSMS()` — not the Twilio client directly.

## Detection

- **ID:** `sms-optout-persisted`
- **Severity:** `critical`
- **What to look for:** Find SMS handling code. Search for: (1) An opt-out storage mechanism — a dedicated `sms_opt_outs` table, an `opted_out` boolean on the customer record, or equivalent persistent store. Quote the table name or column. (2) A STOP reply handler — a webhook endpoint or message handler that parses inbound SMS for "STOP" (case-insensitive) and updates the opt-out store. Quote the handler function name. (3) A pre-send opt-out check — in every function that sends SMS, verify there is a query that checks opt-out status BEFORE calling the SMS provider API. Count the number of SMS-sending functions and how many include the opt-out check.
- **Pass criteria:** Count all SMS-sending functions and enumerate each. ALL of the following: (1) Opt-out status is stored in a persistent database table or column — quote the table/column name. In-memory storage does NOT count as pass. (2) A STOP reply handler exists that sets the opt-out flag — quote the handler function or route. (3) 100% of SMS-sending functions check the opt-out flag before sending — list all functions by name and report the ratio: "N of N functions check opt-out".
- **Fail criteria:** Opt-out stored in memory only (process variable, in-memory Map, etc.). Opt-out flag exists but not all SMS-sending functions check it (state the count: "3 of 5 functions check"). No STOP handler exists. STOP handler updates a record but the field is never read before sending.
- **Skip (N/A) when:** SMS notifications are not offered — no SMS provider dependency found in package.json (searched twilio, vonage, sinch, telnyx, aws-sdk/client-sns) AND no SMS-sending code found in the codebase.
- **Detail on fail:** State what was found and what is missing. Quote the storage mechanism (or state "none found"). Count SMS-sending functions and how many check opt-out. Example: `"Opt-out stored in sms_opt_outs table (schema.prisma:45). STOP handler at /api/webhooks/sms (route.ts:12) sets opted_out_at. But sendReminderSMS() at src/lib/sms.ts:67 does NOT check opt-out — only sendConfirmationSMS() checks."`.
- **Remediation:** Store opt-out status persistently and check it before EVERY SMS send:

  ```ts
  // prisma/schema.prisma — persistent opt-out table
  model SmsOptOut {
    phone_number String   @id
    opted_out_at DateTime
    opted_in_at  DateTime?
  }

  // src/lib/sms.ts — check opt-out before EVERY send
  async function sendSMS(phoneNumber: string, message: string) {
    const optOut = await db.smsOptOut.findUnique({
      where: { phone_number: phoneNumber }
    })
    if (optOut && optOut.opted_out_at && !optOut.opted_in_at) {
      console.log(`SMS to ${phoneNumber} blocked — customer opted out`)
      return { blocked: true }
    }

    return twilioClient.messages.create({
      body: message,
      from: process.env.TWILIO_PHONE!,
      to: phoneNumber
    })
  }

  // src/app/api/webhooks/sms/route.ts — handle STOP replies
  export async function POST(req: Request) {
    const body = await req.formData()
    const messageBody = body.get('Body')?.toString() ?? ''
    const from = body.get('From')?.toString() ?? ''

    if (messageBody.toUpperCase().includes('STOP')) {
      await db.smsOptOut.upsert({
        where: { phone_number: from },
        create: { phone_number: from, opted_out_at: new Date() },
        update: { opted_out_at: new Date(), opted_in_at: null },
      })
    }
  }
  ```
- **Cross-reference:** Email/SMS Compliance audit checks TCPA opt-out requirements. CCPA Readiness audit checks right-to-opt-out persistence. Database Audit checks that opt-out writes are durable (not in-memory).

## External references

- external TCPA-47-USC-227 — https://www.law.cornell.edu/uscode/text/47/227
- gdpr Art. 21
- ccpa §1798.120

Taxons: privacy-consent, regulatory-conformance

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