Opt-out, consent & TCPA
Honouring opt-out is a legal obligation, not a feature. SimpleSMS enforces it on your behalf for every send, keeps an append-only consent ledger you can export, and gives you the events to keep your own systems in sync. There is nothing to configure; every account gets this by default.
Revocation in plain English
The FCC's April 2025 revocation rule requires honouring a revocation expressed by any reasonable means, not just the standard keywords. SimpleSMS detects revocation in three tiers, in order:
| Tier | Detects | Example |
|---|---|---|
| Keywords | The exact CTIA keywords | "STOP" |
| Phrases | Common plain-English forms, deterministically | "please stop texting me", "remove me from your list" |
| AI | Everything else revocation-shaped, with a confidence score | "i would rather you didn't message this number" |
All three record the opt-out, send the single confirmation reply, block future
sends, and emit message.opted_out with a method field
(keyword, phrase, or ai) so you can see how it was
detected. A revocation is never answered by an auto-reply.
Keywords
| Reply | Effect |
|---|---|
STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, OPTOUT, REVOKE, ARRET | Opts the number out of your messages. We send one confirmation and nothing more. |
START, UNSTOP, YES | Opts back in. Someone who was opted out gets one "you are resubscribed" reply; an ordinary "yes" from someone who never opted out gets no reply. |
HELP, INFO, AIDE | Sends your help reply, even to an opted-out number, and emits message.help_requested. |
A second STOP from a number that is already opted out changes nothing:
no second confirmation, no second event.
Replies speak for your business
The confirmation, resubscribe and help replies name you, not Delivered, because the person texting has never heard of us:
- A number linked to a registration sends exactly the HELP, STOP and opt-in messages that registration filed with the carriers, including your support contact.
- A number assigned to a customer is answered in that customer's name.
- Otherwise the reply uses your account name. An account name that is an email address is never shown; the reply says "this number" instead.
Keyword matching is exact: the message has to be the keyword, ignoring case, surrounding whitespace and punctuation. "please stop by tomorrow" is an ordinary message; the phrase tier only fires on messaging-directed forms like "stop texting me".
One suppression list per account
Every account has a single suppression list, and every ordinary message passes through it before it can reach a carrier. It is account-wide on purpose: an opt-out applies to all your numbers, sender pools and customers, so a recipient who texts STOP to one of your numbers cannot be texted from another.
| Path | How it honours the list |
|---|---|
POST /v1/messages, console sends | Checked first; refused with recipient_opted_out. |
| Batches and broadcasts | Opted-out recipients are skipped and counted, then re-checked at send time. |
| Scheduled sends, automations | Re-checked when the send actually runs, not when it was scheduled. |
| Automatic carrier retries | Re-checked before every attempt. |
| Auto-replies | Never sent to an opted-out number. |
| STOP / START / HELP replies | Exempt: these are the replies the rules require. |
POST /v1/verify | Exempt and logged (see below). |
The list fails closed. If we cannot read it, the send is not attempted: you
get 503 with Retry-After, nothing is sent and nothing is billed. We
would rather make you retry than text someone who said stop.
You manage the same list through the API above: read it
(GET /v1/consent), add to it (POST /v1/consent/{phone}, or
/v1/consent/import for a list from another provider) and export it
(/v1/consent/export).
The list is yours alone. An opt-out silences your traffic to that number, not everyone's. Someone who unsubscribes from one sender does not stop receiving login codes from another; that would turn an unsubscribe into an account lockout.
What gets blocked
POST /v1/messages to an opted-out number returns:
{
"error": {
"code": "recipient_opted_out",
"message": "This recipient opted out of your messages on Oct 5, 2026 at 8:42 AM UTC by replying STOP. Nothing was sent and you were not charged. They can opt back in by texting START to your number.",
"param": "to",
"opted_out_at": "2026-10-05T08:42:00.000Z",
"opted_out_via": "sms_keyword",
"opted_out_method": "keyword",
"failure": { "code": "opted_out", "title": "Recipient opted out", "explanation": "...", "action": "Recommended: ..." }
}
}The status is 403. Nothing leaves our network and nothing is billed. You
do not have to check consent before sending: send, and handle this one code.
A refused send also leaves a trace, so "why wasn't this delivered?" is answered wherever you look:
- a
message.blockedevent (and webhook) withto,from,reason: "opted_out"and the sameopted_out_*fields; - a
blocked_sendentry in the number's consent history (at most one a day per number, so a retry loop cannot bury the opt-out itself); blocked_sendsandlast_blocked_atonGET /v1/consent/{phone}.
Broadcast recipients who opted out are skipped and counted, never messaged. Scheduled sends re-check consent at send time.
One-time passcodes are exempt. POST /v1/verify still delivers,
because a user asking to log in is asking for that code, and blocking it locks
them out of their own account. Every such send is written to the consent ledger
and emits verification.sent_to_opted_out so the pattern stays auditable.
The consent ledger
Every consent change is appended to a per-number history that is never deleted: opt-outs (with the detected text and method), opt-ins, imports, API changes, and verification exemptions. That history is your TCPA audit trail.
# Current state + full history for one number
curl https://api.joinsimplesms.com/v1/consent/+14155550132 \
-H "Authorization: Bearer $SIMPLESMS_API_KEY"
# Set consent from your own system (CRM sync, web form, support tool)
curl -X POST https://api.joinsimplesms.com/v1/consent/+14155550132 \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "opted_out", "note": "revoked by email"}'
# The whole suppression list, paginated
curl "https://api.joinsimplesms.com/v1/consent?limit=100" \
-H "Authorization: Bearer $SIMPLESMS_API_KEY"
# Bulk import (up to 500 per request), e.g. when migrating from Twilio
curl -X POST https://api.joinsimplesms.com/v1/consent/import \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone_numbers": ["+14155550132", "+16155550176"]}'
# Export everything as CSV
curl https://api.joinsimplesms.com/v1/consent/export \
-H "Authorization: Bearer $SIMPLESMS_API_KEY"Opt-ins set via the API can carry proof: the source, time, page, exact
disclosure wording, IP and user agent (see
Opt-in proof). /v1/consent/export?type=ledger
exports every consent event with those proof columns.
The console Compliance page shows the same ledger with search, per-number history, import and export.
Topics are narrower than an opt-out
Subscription topics let a person stop
one kind of message (say Marketing) and keep another. They sit on top of
everything on this page and never replace it: the opt-out check runs first on
every send, an opted-out number gets nothing whatever its topics say, and
subscribing a number to a topic does not opt it back in. Topic changes appear
in the ledger as topic_unsubscribe and topic_subscribe; opt-in evidence
stored by a contact import appears as import.
Neither changes status.
Events
| Event | When |
|---|---|
message.opted_out | A revocation was detected (any tier) or set via API. Payload includes method and, for AI detections, confidence. |
message.opted_in | A recipient opted back in (START or API). Keyword opt-ins carry resubscribed: true when the number had been opted out. |
message.help_requested | A recipient texted HELP, INFO or AIDE. The help reply has already been sent. |
message.blocked | A send was refused because the recipient is opted out. Carries to, from, reason and when and how they opted out. |
verification.sent_to_opted_out | A passcode went to an opted-out number under the exemption. |
Subscribe to these and mirror the state in your own database. You should never re-add a number that opted out, even though we block it. Imports do not emit per-number events; the ledger records each one.
Testing it
Opt-out works in the sandbox exactly as it does live, scoped to your account, so you can rehearse the whole path before going live. The phrase tier is deterministic, so it is testable too:
curl -X POST https://api.joinsimplesms.com/v1/test/inbound \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"from":"+15005550006","to":"<your sandbox number>","body":"please stop texting me"}'The next send to that number returns 403, and
GET /v1/consent/+15005550006 shows the opt-out with
"method": "phrase". Send START to opt back in; the history keeps
both entries.
This page is educational, not legal advice. Your own counsel decides what your consent program needs; SimpleSMS gives you enforcement and records by default.