# Opt-out, consent & TCPA

Source: https://jointextkit.com/docs/opt-out
Index: https://jointextkit.com/llms.txt

Honouring opt-out is a legal obligation, not a feature. TextKit 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. TextKit 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" |
| Intent | 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`, also spaced or hyphenated (`S T O P`, `STOP ALL`), common misspellings (`STP`, `STIP`, `STOPP`, `SSTOP`) and `SPAM` | 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 TextKit,
because the person texting has never heard of us:

- A number linked to a [registration](/docs/compliance) 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](/docs/customers) 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](/docs/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` | Checked first; refused with `recipient_opted_out` (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`).

An opt-out the person gave themselves (a STOP or plain-words reply, or one
imported from another provider) is permanent: only they can lift it, by
texting START (or your opt-in keyword) to one of your numbers.
`POST /v1/consent/{phone}` with `"status": "opted_in"` answers
`409 invalid_request` and changes nothing. Recording an opt-out of your own
over theirs keeps theirs.

An opt-out you recorded yourself (`POST /v1/consent/{phone}` or the console),
or one from a report through our form (we cannot confirm who filed it), can be
undone with `proof` of a new opt-in, `collected_at` at or after the
opt-out; otherwise `409 invalid_request` (`param`: `proof` or
`proof.collected_at`). Once the person texts STOP themselves, it is theirs
and permanent. In the console, only an admin can (Compliance, **Opt back in**).

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:

```json
{
  "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.blocked` event (and webhook) with `to`, `from`,
  `reason: "opted_out"` and the same `opted_out_*` fields;
- a `blocked_send` entry in the number's consent history (at most one a
  day per number, so a retry loop cannot bury the opt-out itself);
- `blocked_sends` and `last_blocked_at` on `GET /v1/consent/{phone}`.

Broadcast recipients who opted out are skipped and counted, never messaged.
Scheduled sends re-check consent at send time.

**One-time codes are blocked too.** `POST /v1/verify` to a number that
opted out answers `403 recipient_opted_out`: nothing is sent and nothing is
billed. The same applies when the person texted STOP to the number the code
would come from. They text START to get codes again.

## 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
blocked sends. That history is your TCPA audit trail.

```bash
# Current state + full history for one number
curl https://api.jointextkit.com/v1/consent/+14155550132 \
  -H "Authorization: Bearer $TEXTKIT_API_KEY"

# Set consent from your own system (CRM sync, web form, support tool)
curl -X POST https://api.jointextkit.com/v1/consent/+14155550132 \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "opted_out", "note": "revoked by email"}'

# The whole suppression list, paginated
curl "https://api.jointextkit.com/v1/consent?limit=100" \
  -H "Authorization: Bearer $TEXTKIT_API_KEY"

# Bulk import (up to 500 per request), e.g. when migrating from another provider
curl -X POST https://api.jointextkit.com/v1/consent/import \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone_numbers": ["+14155550132", "+16155550176"]}'

# Export everything as CSV
curl https://api.jointextkit.com/v1/consent/export \
  -H "Authorization: Bearer $TEXTKIT_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](/docs/compliance#opt-in-proof)), and `campaign_id`, the
registration the person opted in to. `/v1/consent/export?type=ledger`
exports every consent event with those proof columns.

```bash
# An opt-in for one registration, with the evidence a marketing registration requires
curl -X POST https://api.jointextkit.com/v1/consent/+14155550132 \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "opted_in", "campaign_id": "reg_a1B2c3D4e5F6",
       "proof": {"source": "web_form", "collected_at": "2026-10-01T15:04:05Z",
                 "page_url": "https://acme.example/texts",
                 "disclosure": "By checking this box you agree to receive marketing texts from Acme..."}}'
```

## Consent is per registration

Carriers treat consent as given to one business for one kind of message:
the registration (use case) your number sends under. Someone who agreed to
appointment reminders did not agree to promotions from the same business.
So the consent API records an opt-in either for the whole account or for
one registration, and the send check reads it like this:

| Send | Opt-in it looks for |
| --- | --- |
| Marketing from a number linked to a registration | One recorded **for that registration**: `campaign_id` on the API, a `campaign_id` column in a contact import, a START (or YES) reply to one of its numbers, or a sign-up on its [hosted opt-in page](/docs/compliance#hosted-opt-in-page) |
| Marketing from a number with no registration (the sandbox, a toll-free or Canadian number, a number not yet linked) | Any opt-in on file with the account, as before |
| Transactional messages and one-time codes | Not checked |

An opt-out is never narrower than the account: STOP to any of your numbers
stops every registration, and clears every opt-in the number had. A keyword
opt-in is scoped automatically to the registration of the number the
person texted, with the inbound message recorded as its proof.

`GET /v1/consent/{phone}` lists the registrations a number is opted in to
as `campaigns`, and each history entry carries its `campaign_id`. Existing
account-level opt-ins keep working for every transactional send and for
marketing from an unregistered number; only marketing from a registered
number now asks for the registration-scoped record.

## Proof for marketing opt-ins

An opt-in recorded for a registration whose use case is marketing (or
mixed) must carry `proof`: the `source`, `collected_at`, and the
`page_url` for a web form or the inbound `message_id` for a keyword.
Without it `POST /v1/consent/{phone}` answers `400 invalid_request` and
records nothing; a contact import skips the row with the reason. Opt-ins
for other registrations, and account-level opt-ins, accept the same fields
and do not insist on them. Keyword opt-ins (START, JOIN, YES) and hosted
opt-in page sign-ups record their proof by themselves.

## Re-confirmation after 30 days

Some carriers require a fresh YES from anyone a marketing program has not
texted in 30 days. We do that for you: see
[re-confirmation](/docs/compliance-checks#re-confirmation-after-30-days).

The [console Compliance page](/console/compliance) shows the same ledger with
search, per-number history, import and export.

## Topics are narrower than an opt-out

[Subscription topics](/docs/contacts#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](/docs/contacts#csv-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 intent-tier detections, `confidence`. |
| `message.opted_in` | A recipient opted in (START, the API, or your [hosted opt-in page](/docs/compliance#hosted-opt-in-page): `method: "hosted_page"` with `registration_id`). Carries `campaign_id` when the opt-in is for one registration. Keyword opt-ins carry `resubscribed`: `true` when the number had been opted out, and `reconfirmed: true` with `released_message_ids` when the reply answered a [re-confirmation](/docs/compliance-checks#re-confirmation-after-30-days). |
| `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` | No longer sent: a code to an opted-out number is refused with `403 recipient_opted_out`. |

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:

```bash
curl -X POST https://api.jointextkit.com/v1/test/inbound \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from":"+15005550006","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; TextKit gives you enforcement and records by default.

## Bring your opt-outs

A number that is new to your TextKit account is not safe to text just
because nobody has replied STOP here yet. If you texted these people from
another provider or tool, some of them opted out there, and that choice
still applies. Opt-out lists are not shared between providers, so the list
has to come with you.

1. Export the opt-out list from wherever you sent before. Any file with one
   phone number per row works: a single column with or without a header, or
   an export with other columns beside it. Numbers can be `+14155550123`,
   `14155550123`, `(415) 555-0123` or `415-555-0123`.
2. Upload it in Console → Numbers → Bring your numbers → **Bring your
   opt-outs**, or send it to the API:

```bash
curl https://api.jointextkit.com/v1/consent/import \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" -H "Content-Type: application/json" \
  -d '{"phone_numbers": ["+14155550123", "(212) 555-0148"]}'
```

Up to 500 numbers per request. Each one is recorded as opted out with the
method `import` and is refused on every send from then on
(`403 recipient_opted_out`). The console shows how many were imported
and how many were skipped, with the reason: not a US or Canada number, a
blank row, repeated in the file, or already on your list.

**In a migration this step is required.** The import of numbers, pools and
webhooks does not run until you have uploaded a list or confirmed, in the
console, "I have no existing opt-outs to carry over". That confirmation is
recorded in your [audit log](/docs/audit-logs) as
`migration.opt_outs_attested`.

Outside a migration you will see a reminder, not a block, when you import
contacts or send a first broadcast with no opt-outs on file.
