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.
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.
Configure an inbound Twilio webhook that validates the signature and writes smsOptOut: true synchronously, then check that flag in every outbound send.
// 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.
ID: email-sms-compliance.unsubscribe.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:
// 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' },
})
}
// 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
}