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.
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.
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.
ID: booking-notifications-reminders.sms.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:
// 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).