---
title: "Opt-outs"
description: "Your organization's do-not-contact list: numbers that asked not to be texted or called."
---

The opt-out list holds numbers that asked your organization not to text them,
not to call them, or both. ThunderPhone checks it before every outbound text
and call:

- **Texts** to a number opted out of `sms` are refused: [`POST /v1/sms`](/api-reference/sms),
  the agent's `send_sms` tool, post-call follow-ups, retries and
  [scheduled texts](/api-reference/scheduled-sends).
- **Calls** to a number opted out of `call` are refused: [`POST /v1/call`](/api-reference/outbound-calls),
  [campaigns](/api-reference/campaigns) and scheduled calls.
- Adding a number skips the queued [scheduled sends](/api-reference/scheduled-sends)
  to it on that channel, with status `skipped` and reason `opted_out`.

Use these endpoints to keep the list in step with your own CRM's opt-outs.

## Endpoints

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/opt-outs` | List opt-outs |
| `POST` | `/v1/opt-outs` | Add a number |
| `DELETE` | `/v1/opt-outs` | Remove a number |

Authenticate with an organization API key. These endpoints are not available
to OAuth clients. In a dashboard session any member can list and add numbers;
removing one requires an admin or owner, because it lets your organization
contact someone who asked it not to. Organization API keys are not limited by
role.

## Opt-out object

```json
{
  "id": 318,
  "number": "+14155550199",
  "channel": "sms",
  "source": "api",
  "source_reference": "api_key:12",
  "blocks": true,
  "created_at": "2026-11-01T18:02:11.204Z",
  "updated_at": "2026-11-01T18:02:11.204Z"
}
```

| Field | Type | Description |
|-------|------|-------------|
| `id` | integer | Id of the row |
| `number` | string | E.164 |
| `channel` | string | `sms` or `call`. A number opted out of both has one row for each |
| `source` | string | Where it came from: `api`, `manual` (added in the dashboard) or `carrier`. `inbound_stop` (the person replied STOP) is reserved for inbound SMS support and not recorded yet |
| `source_reference` | string | What added it, such as `api_key:<id>`, `user:<id>`, or `sms:<message id>` for a carrier refusal |
| `blocks` | boolean | Whether ThunderPhone refuses texts or calls for this row. `false` for `carrier` rows |
| `created_at`, `updated_at` | timestamp | ISO 8601 UTC |

### Carrier opt-outs

When Twilio or Telnyx refuses a text because the recipient opted out with the
carrier (error code `recipient_opted_out`), ThunderPhone records a `carrier`
row for that number. The carrier enforces these itself, so a `carrier` row is
recorded but not enforced by ThunderPhone (`blocks: false`): it skips the
scheduled texts already queued to that number, but new texts are passed to the
carrier as usual. That way a person who opts back in with the carrier, for
example by texting START, is not kept blocked here. A `carrier` row is removed
when the carrier next accepts a text to that number.

Adding the same number through the API or dashboard turns a `carrier` row into
a blocking one.

## List opt-outs

```bash
curl 'https://api.thunderphone.com/v1/opt-outs?channel=sms' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

| Parameter | Description |
|-----------|-------------|
| `number` | One number, in any format the API can read as a phone number |
| `channel` | `sms` or `call` |
| `source` | `api`, `manual`, `carrier` or `inbound_stop` |
| `limit` | 1–500, default 50 |
| `offset` | Default 0 |

Results are newest first.

```json
{
  "count": 1,
  "results": [
    {
      "id": 318,
      "number": "+14155550199",
      "channel": "sms",
      "source": "api",
      "source_reference": "api_key:12",
      "blocks": true,
      "created_at": "2026-11-01T18:02:11.204Z",
      "updated_at": "2026-11-01T18:02:11.204Z"
    }
  ]
}
```

## Add a number

```bash
curl -X POST https://api.thunderphone.com/v1/opt-outs \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"number": "+14155550199", "channel": "both"}'
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `number` | string | yes | The number, E.164 |
| `channel` | string | no | `sms`, `call` or `both` (default) |

Returns the number's row for each channel, `201 Created` when any row is new
and `200 OK` when the number was already on the list:

```json
{
  "results": [
    { "id": 318, "number": "+14155550199", "channel": "sms", "source": "api", "source_reference": "api_key:12", "blocks": true, "created_at": "2026-11-01T18:02:11.204Z", "updated_at": "2026-11-01T18:02:11.204Z" },
    { "id": 319, "number": "+14155550199", "channel": "call", "source": "api", "source_reference": "api_key:12", "blocks": true, "created_at": "2026-11-01T18:02:11.204Z", "updated_at": "2026-11-01T18:02:11.204Z" }
  ]
}
```

A number that is not a valid phone number returns `400` `invalid_number`.

## Remove a number

```bash
curl -X DELETE 'https://api.thunderphone.com/v1/opt-outs?number=%2B14155550199&channel=sms' \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY"
```

`number` is required; `channel` is `sms`, `call` or `both` (default). Remove a
number only when the person has opted back in.

```json
{ "deleted": 1 }
```

`deleted` is the number of rows removed, `0` when the number was not on the
list.

## What a refusal looks like

| Request | Response |
|---------|----------|
| [`POST /v1/sms`](/api-reference/sms#send-a-message) | `409` with `error.code` `contact_opted_out` |
| [`POST /v1/scheduled`](/api-reference/scheduled-sends#errors) | `409` with `error.code` `contact_opted_out` |
| [`POST /v1/call`](/api-reference/outbound-calls#blocked-destinations) | `400` `destination_not_allowed` with `reason` `contact_opted_out` |
| Campaign contact | The contact fails; the campaign keeps running |
| Scheduled send already queued | `skipped` with `status_reason` `opted_out` |
