> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bluereacher.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Opt-out handling

> How BlueReacher detects opt-outs from keywords and plain language, enforces them across every line on your account at send time, and how to sync suppression with your own systems.

Every inbound message on your account is checked for opt-out intent before it reaches your webhook. When a contact opts out, the suppression takes effect immediately and applies to your entire account. No line can message that contact again until you clear it.

This page covers what triggers an opt-out, what happens the moment one lands, the `contact.opted_out` webhook event, the two endpoints for syncing suppression with your own systems, and why enforcement happens at send time instead of at list-build time.

## What triggers an opt-out

BlueReacher runs two checks on every inbound message.

**Keyword match.** Case-insensitive, punctuation-tolerant, whitespace-trimmed. A reply is a keyword opt-out when the whole message, after stripping punctuation and emoji, matches one of these:

| Keyword     | Also matches                      |
| ----------- | --------------------------------- |
| STOP        | `stop`, `Stop.`, `STOP!`, `stop ` |
| STOPALL     | `stop all`, `stopall`             |
| UNSUBSCRIBE | `unsubscribe`, `Unsubscribe.`     |
| END         | `end`                             |
| QUIT        | `quit`                            |
| CANCEL      | `cancel`                          |
| OPTOUT      | `opt out`, `opt-out`              |
| REVOKE      | `revoke`                          |

**Plain-language match.** Most people do not type STOP. They write a sentence. Every inbound reply is classified for removal intent and the contact is suppressed when the message is a removal request, whatever the wording. These suppress:

* "please take me off your list"
* "don't text me again"
* "how did you get this number, remove me"
* "stop messaging me, I'm not interested"
* "unsubscribe me from whatever this is"
* "keep texting and I'm reporting this"

These do not suppress:

* "stop by the office next week" (keyword inside a sentence, not the whole message)
* "we're looking to stop using our current vendor" (same)
* "not interested" (a soft no, classified as a negative reply, not a removal request)
* "who is this?"

Soft negatives arrive on the inbound webhook with a negative classification and leave the contact sendable. If your policy is to suppress every negative reply, do that yourself by calling `set-opt-out` from your reply handler.

## What happens the moment a contact opts out

In order, within the same second the reply is processed:

1. The contact is marked `opted_out: true` with a timestamp and a source.
2. Every queued and scheduled message addressed to that contact is cancelled, on every line, in every campaign, and moves to `status: "rejected"`.
3. The `contact.opted_out` webhook fires.
4. Any new send addressed to that contact is refused at the API with `403`.

Suppression is scoped to your account, not to a line and not to a channel. A contact who opts out on one of your dedicated iMessage lines cannot be reached from any other line on the account, including lines added later, and cannot be reached on `imessage`, `rcs`, or `sms`. The identity being suppressed is the phone number.

A refused send returns:

```json theme={null}
{
  "status": "rejected",
  "error": {
    "code": "contact_opted_out",
    "message": "Contact +15125550142 opted out at 2026-07-24T15:04:10Z. No line on this account can message this contact.",
    "contact_id": "cnt_8f21ab90",
    "opted_out_at": "2026-07-24T15:04:10Z"
  }
}
```

Branch on `error.code`, not on the message string. The code is stable, the message is not.

## The contact.opted\_out webhook

Subscribe in the dashboard under Webhooks. The event fires once per opt-out, whether it came from detection, the API, or a dashboard action.

```json theme={null}
{
  "event": "contact.opted_out",
  "created_at": "2026-07-24T15:04:11Z",
  "data": {
    "contact_id": "cnt_8f21ab90",
    "to": "+15125550142",
    "line_id": "line_4a7c9e",
    "channel": "imessage",
    "source": "plain_language",
    "content": "please take me off your list",
    "external_id": "lead_88213",
    "opted_out_at": "2026-07-24T15:04:10Z",
    "cancelled_messages": 3
  }
}
```

| Field                | Type    | Description                                                                                     |
| -------------------- | ------- | ----------------------------------------------------------------------------------------------- |
| `contact_id`         | string  | BlueReacher identifier for the contact.                                                         |
| `to`                 | string  | The suppressed number in E.164. In every opt-out payload, `to` is the contact, never your line. |
| `line_id`            | string  | The line the opt-out arrived on. Informational only. Suppression is account-wide.               |
| `channel`            | string  | `imessage`, `rcs`, or `sms`. The channel the reply came in on.                                  |
| `source`             | string  | `keyword`, `plain_language`, `api`, or `dashboard`.                                             |
| `content`            | string  | The message that triggered detection. `null` when `source` is `api` or `dashboard`.             |
| `external_id`        | string  | Your identifier for the contact, if you set one at send or import time.                         |
| `opted_out_at`       | string  | ISO 8601 UTC timestamp of the suppression.                                                      |
| `cancelled_messages` | integer | Queued or scheduled messages cancelled for this contact.                                        |

Two rules for the handler. Write the suppression to your system of record in the same run, because this event is the only push signal you get. And deduplicate on `contact_id`, because webhook delivery is at-least-once and a retry will arrive with an identical payload.

## set-opt-out

Push a suppression from your own systems into BlueReacher, or clear one you added by mistake. Use it when someone unsubscribes by email, tells a rep on a call, or gets flagged Do Not Contact in your CRM.

`POST https://api.bluereacher.com/functions/v1/set-opt-out`

Auth uses the same key as sending: `Authorization: Bearer bluereacher_your_api_key`. There is no separate compliance scope.

| Field             | Type    | Required                        | Description                                                                                                 |
| ----------------- | ------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `to`              | string  | Yes, unless `contact_id` is set | Phone number in E.164.                                                                                      |
| `contact_id`      | string  | Yes, unless `to` is set         | BlueReacher contact identifier.                                                                             |
| `opted_out`       | boolean | Yes                             | `true` suppresses, `false` clears.                                                                          |
| `source`          | string  | No                              | Free-form label up to 64 characters, stored on the record. Use it to trace where the suppression came from. |
| `external_id`     | string  | No                              | Your identifier for this contact. Written to the contact if not already set.                                |
| `confirm_consent` | boolean | Conditional                     | Required when setting `opted_out: false` on a contact suppressed by `keyword` or `plain_language`.          |

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/set-opt-out \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+15125550142",
    "opted_out": true,
    "source": "hubspot_dnc_sync",
    "external_id": "lead_88213"
  }'
```

```json theme={null}
{
  "contact_id": "cnt_8f21ab90",
  "to": "+15125550142",
  "opted_out": true,
  "source": "hubspot_dnc_sync",
  "external_id": "lead_88213",
  "opted_out_at": "2026-07-24T15:04:10Z",
  "cancelled_messages": 0,
  "status": "updated",
  "created_at": "2026-06-02T18:22:41Z"
}
```

| Field                | Type    | Description                                                             |
| -------------------- | ------- | ----------------------------------------------------------------------- |
| `contact_id`         | string  | Contact identifier. Created if the number was not on your account yet.  |
| `to`                 | string  | Normalized E.164 number.                                                |
| `opted_out`          | boolean | State after the call.                                                   |
| `source`             | string  | The label you sent, or the existing source if the state did not change. |
| `external_id`        | string  | Your identifier, or `null`.                                             |
| `opted_out_at`       | string  | Timestamp of the suppression, or `null` when `opted_out` is `false`.    |
| `cancelled_messages` | integer | Queued messages cancelled by this call.                                 |
| `status`             | string  | `updated` or `unchanged`.                                               |
| `created_at`         | string  | When the contact record was first created.                              |

Errors:

| Status | Code                            | Meaning                                                                         |
| ------ | ------------------------------- | ------------------------------------------------------------------------------- |
| 400    | `invalid_phone_number`          | `to` is not parseable as E.164.                                                 |
| 400    | `missing_field`                 | Neither `to` nor `contact_id` was sent, or `opted_out` is absent.               |
| 401    | `invalid_api_key`               | Key missing, malformed, or revoked.                                             |
| 409    | `consent_confirmation_required` | You tried to clear a detected opt-out without `confirm_consent: true`.          |
| 429    | `rate_limited`                  | Over 120 requests per minute on this key. Retry after the `Retry-After` header. |

Clearing a detected opt-out is allowed, and it is logged with the API key that did it and a timestamp. Do it only when you hold a record of new consent. BlueReacher does not evaluate that record. You own it.

## check-opt-out

Read suppression state for a batch of numbers before you import them or open a send window. It creates nothing, changes nothing, and does not count against send volume.

`POST https://api.bluereacher.com/functions/v1/check-opt-out`

Auth header is the same: `Authorization: Bearer bluereacher_your_api_key`.

| Field            | Type             | Required | Description                                                                    |
| ---------------- | ---------------- | -------- | ------------------------------------------------------------------------------ |
| `to`             | array of strings | Yes      | 1 to 1,000 phone numbers in E.164.                                             |
| `include_source` | boolean          | No       | Default `false`. When `true`, each result carries `source` and `opted_out_at`. |

```bash theme={null}
curl -X POST https://api.bluereacher.com/functions/v1/check-opt-out \
  -H "Authorization: Bearer bluereacher_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["+15125550142", "+14155550188", "+12065550119"],
    "include_source": true
  }'
```

```json theme={null}
{
  "results": [
    {
      "to": "+15125550142",
      "contact_id": "cnt_8f21ab90",
      "opted_out": true,
      "opted_out_at": "2026-07-24T15:04:10Z",
      "source": "keyword"
    },
    {
      "to": "+14155550188",
      "contact_id": "cnt_2b7710de",
      "opted_out": false,
      "opted_out_at": null,
      "source": null
    },
    {
      "to": "+12065550119",
      "contact_id": null,
      "opted_out": false,
      "opted_out_at": null,
      "source": null
    }
  ],
  "invalid": [],
  "checked_at": "2026-07-24T16:11:03Z"
}
```

| Field                    | Type             | Description                                                                                       |
| ------------------------ | ---------------- | ------------------------------------------------------------------------------------------------- |
| `results`                | array            | One entry per valid number, in the order you submitted them.                                      |
| `results[].to`           | string           | Normalized E.164 number.                                                                          |
| `results[].contact_id`   | string           | Contact identifier, or `null` if the number has never been on your account.                       |
| `results[].opted_out`    | boolean          | Current suppression state.                                                                        |
| `results[].opted_out_at` | string           | Timestamp, or `null`. Present only when `include_source` is `true`.                               |
| `results[].source`       | string           | `keyword`, `plain_language`, `api`, or `dashboard`. Present only when `include_source` is `true`. |
| `invalid`                | array of strings | Numbers that failed E.164 parsing. They are skipped, not fatal.                                   |
| `checked_at`             | string           | ISO 8601 UTC timestamp of the read.                                                               |

Errors:

| Status | Code                | Meaning                                               |
| ------ | ------------------- | ----------------------------------------------------- |
| 400    | `invalid_request`   | `to` is missing, empty, or not an array.              |
| 401    | `invalid_api_key`   | Key missing, malformed, or revoked.                   |
| 422    | `too_many_contacts` | More than 1,000 numbers in one call. Split the batch. |
| 429    | `rate_limited`      | Over 60 requests per minute on this key.              |

A `contact_id` of `null` with `opted_out: false` means no opt-out is on file. That is not the same as consent. It only tells you this number has not asked you to stop.

## Cleaning a list before import

1. Normalize every number to E.164 before you send it. Numbers that fail parsing land in `invalid` and get silently skipped, which reads as a clean list when it is not.
2. Deduplicate. Duplicate numbers burn batch slots and produce duplicate results.
3. Split into batches of 1,000 and call `check-opt-out` on each.
4. For every hit, write the suppression back to the source system. Dropping the row from your CSV loses the reason.
5. Import the remainder.

The check is a snapshot taken at `checked_at`. A list cleaned Monday and sent Thursday is three days stale. The running webhook handler is what covers that gap, not the import-time check.

## Why enforcement happens at send time

Filtering at list-build time is the obvious design: clean the list, then send it. It breaks in four predictable ways.

**Lists go stale.** An opt-out that lands at 10:02 does nothing to a list filtered at 09:00. The larger your campaign, the longer the window between build and last send, and the more messages go out to people who already asked you to stop.

**Opt-outs are account-wide, lists are not.** A contact who opts out on one line is usually sitting in three other lists for other campaigns on other lines. Filtering per list means every list has to learn about every opt-out independently. One missed sync is a message you cannot take back.

**Scheduled and multi-step sends fire later.** Follow-ups, drips, and retries execute hours or days after the list was assembled. A build-time filter cannot see a decision the contact makes in between.

**Failure directions are not equal.** A list-time filter that misses someone fails open: the message ships and you learn about it from the recipient. A send-time check that misbehaves fails closed: the send is refused, you see a `403` in your logs, and you can fix it. A refused send is recoverable. A delivered one is not.

Send-time enforcement also preserves the record. Deleting suppressed people from your list destroys your evidence that you honored the request. Keeping the contact with `opted_out`, `opted_out_at`, and `source` means you can show exactly when someone asked you to stop and what happened next.

The tradeoff is real and you have to code for it. Your queued count will not match your delivered count, and `403 contact_opted_out` is a normal outcome rather than an exception. Treat it as terminal. Retrying it never succeeds.

## Checklist

* Handle `contact.opted_out` and write to your system of record in the same run. Deduplicate on `contact_id`.
* Treat `403 contact_opted_out` as a terminal state. No retry, no backoff.
* Run `check-opt-out` on every list before import, in batches of 1,000, on normalized E.164 numbers.
* Push CRM, email, and phone unsubscribes into `set-opt-out` on a schedule, not only at import time.
* Never clear a detected opt-out without a consent record you can produce on request.
