SMS opt-out via STOP persisted and respected for all future messages
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:
// 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_outstable, anopted_outboolean 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:
// 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 — Telephone Consumer Protection Act — 47 U.S.C. § 227 (SMS opt-out requirements)
- gdpr · Art. 21 — Right to object (withdrawal of consent for direct marketing communications)
- ccpa · §1798.120 — Right to opt-out of sale / sharing of personal information
Taxons
History
- 2026-04-18·v1.0.0·Initial import from booking-notifications-reminders·automated