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

# Opt-Outs API: per-contact channel consent and CSV sync

> Manage per-contact, per-channel opt-outs across 9 canonical channels with CRUD endpoints, bulk CSV import and export, and audit-ready records.

# Opt-Outs API

Per-contact, per-channel opt-out records. When a contact opts out of a channel, future outbound sends on that channel for that contact are blocked at the compliance gate — across every campaign, flow, and direct-send path in the tenant.

This API powers the dashboard's bulk-CSV import wizard (one batched request replaces what used to be 50 000 sequential round-trips) and the export flow for compliance evidence.

**Base path:** `/api/v1/contacts/optouts`

**Authentication:** Clerk session (`Authorization: Bearer <token>`) or API key (`X-API-Key`).

**Scopes:** `contacts:read` for `GET`, `contacts:write` for everything else (with role gate `owner` / `admin` / `developer`).

***

## Using the SDKs

Prefer the typed client, but this page's endpoint has no helper yet — the generic `request()` keeps auth/retries and the `{ data, meta }` envelope identical:

```ts theme={null}
import { Orbit } from "@devotel-orbit/node";

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! });

const { data } = await orbit.request<unknown>(
  "GET",
  "/contacts/optouts"
);
```

**Python (same call via the SDK's escape hatch):**

```python theme={null}
from orbit_sdk import OrbitClient

client = OrbitClient.from_env()  # reads ORBIT_API_KEY

res = client.request("GET", "/contacts/optouts")
```

The Python SDK is core-scope — it wraps the 8 core resources (messaging, voice, contacts, campaigns, verify, numbers) and reaches everything else through the generic `client.request()` escape hatch above. See the [Python SDK](/sdks/python).

Raw curl in the body of this page works identically. Full SDK index at [SDK quickstart](/sdks).

## Canonical channels

Opt-outs are scoped to one of the **9 canonical contact channels**. These are the channels surfaced in the contact-facing UI (preferences, opt-outs, consent records). The full platform supports more channels (push, web\_chat, agent, video, fax) which are NOT consumer-facing in the same way.

Consent state is stored per-channel on the contact's `channel_preferences` object as a boolean — `channel_preferences[<channel>].opted_out`. There is no separate timestamp or channel-array column on the contact row; a channel is opted out when its `opted_out` flag is `true`, and opted in (or never set) when the flag is absent or `false`.

```json theme={null}
"channel_preferences": {
  "sms": { "opted_out": true },
  "email": { "opted_out": false }
}
```

<Note>
  The per-opt-out **reason** and the **time** each opt-out happened are recorded on the `contact.opted_out` / `contact.opted_in` audit-log entries (see [Audit + observability](#audit--observability)), not on the contact row. Pull the audit log when you need a timestamped, reasoned trail for compliance evidence.
</Note>

| Channel     | Notes                                                       |
| ----------- | ----------------------------------------------------------- |
| `sms`       | SMS messages (10DLC, short codes, international long-code). |
| `whatsapp`  | WhatsApp Business messages.                                 |
| `email`     | Transactional + marketing email.                            |
| `rcs`       | RCS Business Messaging.                                     |
| `viber`     | Viber Business Messages.                                    |
| `voice`     | Outbound voice (calls + voicemail drops).                   |
| `messenger` | Facebook Messenger.                                         |
| `instagram` | Instagram DMs.                                              |
| `telegram`  | Telegram bot messages.                                      |

<Note>
  The channels above are validated via Zod enum — any value not in the list is rejected at the request boundary with `422 VALIDATION_ERROR`. Bulk-CSV imports normalize channel values case-insensitively and trim surrounding whitespace before enum validation.
</Note>

***

## List opt-outs

<Note>
  `GET /api/v1/contacts/optouts`
</Note>

**Scope:** `contacts:read`.

Cursor-paginated list of all opt-outs in the tenant.

**Query parameters**

| Name      | Type            | Notes                                                                 |                                                               |
| --------- | --------------- | --------------------------------------------------------------------- | ------------------------------------------------------------- |
| `limit`   | integer (1–200) | Default 25. Values above 200 are clamped to 200.                      |                                                               |
| `cursor`  | string          | Opaque base64url cursor from the previous response. Plaintext \`\<ts> | \<id>\` cursors are still accepted during the rollout window. |
| `channel` | enum            | Optional filter — one of the 9 canonical channels.                    |                                                               |
| `search`  | string          | Free-text match against the contact's phone / email.                  |                                                               |

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/contacts/optouts?channel=sms&limit=50" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

**200 OK**

```json theme={null}
{
  "data": [
    {
      "id": "con_abc",
      "phone": "+14155552671",
      "email": null,
      "display_name": "Ada Lovelace",
      "channel_preferences": {
        "sms": { "opted_out": true }
      },
      "updated_at": "2026-05-22T10:30:00Z"
    }
  ],
  "pagination": {
    "limit": 50,
    "total": 1,
    "cursor": "MjAyNi0wNS0yMlQxMDozMDowMC4wMDBafGNvbl9hYmM="
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-05-24T12:00:00Z" }
}
```

***

## Create opt-out

<Note>
  `POST /api/v1/contacts/optouts`
</Note>

**Scope:** `contacts:write`. **Role:** `owner` / `admin` / `developer`.

Opts a single contact out of a single channel by setting `channel_preferences[<channel>].opted_out` to `true`. Idempotent — re-applying an opt-out the contact already has returns `200 OK` (instead of `201 Created`) with `already_opted_out: true`, and does not write a duplicate audit entry or re-fire the webhook.

<ParamField body="contact" type="string" required>
  Contact identifier — either an E.164 phone (`+14155552671`) or a normalized email address. The platform resolves it to an existing `contacts` row or creates a stub row scoped to the tenant.
</ParamField>

<ParamField body="channel" type="enum" required>
  One of the 9 canonical channels listed above.
</ParamField>

<ParamField body="reason" type="string" required>
  Why the contact opted out — surfaced in audit logs and compliance exports. 1–500 chars.
</ParamField>

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/optouts" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "contact": "+14155552671",
    "channel": "sms",
    "reason": "Customer reply STOP"
  }'
```

**201 Created**

Returns the updated contact row, including the merged `channel_preferences`, plus an `already_opted_out` flag (`false` on a genuine create, `true` on an idempotent re-apply).

```json theme={null}
{
  "data": {
    "id": "con_abc",
    "phone": "+14155552671",
    "email": null,
    "channel_preferences": {
      "sms": { "opted_out": true }
    },
    "already_opted_out": false,
    "updated_at": "2026-05-24T12:00:00Z"
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-05-24T12:00:00Z" }
}
```

Also writes a `contact.opted_out` audit log with the channel + reason — that entry is where the reason and timestamp for this specific opt-out are recorded.

***

## Bulk-CSV opt-out

<Note>
  `POST /api/v1/contacts/optouts/bulk`
</Note>

**Scope:** `contacts:write`. **Role:** `owner` / `admin` / `developer`.

Batched opt-out for the dashboard's CSV-import wizard. The pre-fix path issued one HTTP request per row — a 1 000-row file = 1 000 round-trips. This endpoint accepts up to 500 rows per call; the dashboard chunks larger files client-side so a 50 000-row CSV uploads in \~100 batches instead of 50 000.

**Per-row isolation:** A malformed row does NOT roll back the batch — each row is attempted independently, and the response carries `{succeeded, skipped, failed, results[]}` so the dashboard can surface actionable per-row errors. Partial success is preferable to silent total-loss.

**Counts:** `total` always equals `succeeded + skipped + failed`. A row is counted as `skipped` (not `succeeded`) when the contact was already opted out of that channel — the import is idempotent, so re-uploading the same CSV reports those rows as skipped and does not write a duplicate audit entry or re-fire the webhook. Reconcile against `total` rather than assuming `succeeded + failed === total`.

<ParamField body="rows" type="array" required>
  Array of `{ contact, channel, reason }` objects. 1–500 entries per request. Each `channel` is normalized (lowercased + trimmed) before enum validation.
</ParamField>

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/optouts/bulk" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "contact": "+14155552671", "channel": "sms",      "reason": "CSV import" },
      { "contact": "+14155552672", "channel": "WhatsApp", "reason": "CSV import" },
      { "contact": "user@example.com", "channel": "email", "reason": "CSV import" }
    ]
  }'
```

**207 Multi-Status**

```json theme={null}
{
  "data": {
    "total": 3,
    "succeeded": 2,
    "skipped": 1,
    "failed": 0,
    "results": [
      { "contact": "+14155552671",    "channel": "sms",      "ok": true,  "status": "succeeded" },
      { "contact": "+14155552672",    "channel": "whatsapp", "ok": true,  "status": "skipped" },
      { "contact": "user@example.com","channel": "email",    "ok": true,  "status": "succeeded" }
    ]
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-05-24T12:00:00Z" }
}
```

Each row carries a `status` — one of `succeeded` (newly opted out), `skipped` (already opted out; deduplicated), or `failed`. The `ok` boolean is retained for backwards compatibility: it is `true` for both `succeeded` and `skipped`, and `false` for `failed`. A failed row also carries an `error` message string.

<Note>
  **Channel normalization.** `"SMS"`, `"sms "`, and `"Sms"` all map to the canonical `sms`. A value that isn't one of the 9 canonical channels (even after normalization) fails request validation — the whole request returns `422 VALIDATION_ERROR` rather than failing that row alone. Per-row isolation applies to processing failures (such as a contact that doesn't yet exist), not to channel validation, so clean the channel column before upload. The dashboard previously substituted `sms` as a fallback for unknown channel strings — that silent fallback is now reserved for explicit operator confirmation in the wizard's "review" step rather than the API.
</Note>

***

## Re-opt-in (delete opt-out)

<Note>
  `DELETE /api/v1/contacts/optouts/{id}/{channel}`
</Note>

**Scope:** `contacts:write`. **Role:** `owner` / `admin` / `developer`.

Removes the opt-out record for one channel on one contact. The contact becomes eligible for outbound sends on that channel again, **subject to consent records** — re-opt-in does not by itself create the affirmative consent some channels require (e.g. WhatsApp 24h window, RCS marketing).

Path parameters:

* `id` — the contact id (`con_*`)
* `channel` — one of the 9 canonical channels

```bash cURL theme={null}
curl -X DELETE "https://api.orbit.devotel.io/api/v1/contacts/optouts/con_abc/sms" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

**200 OK** — returns the updated contact row.

Also writes a `contact.opted_in` audit log with the channel.

<Note>
  **Platform-scope only.** This endpoint updates consent within Orbit — the contact's `channel_preferences`, the suppression list, and (if you've connected one) the CRM/marketing tools consent is propagated to. It does not reach into the upstream SMS carrier network. For a US/CA toll-free number, a carrier can apply its own opt-out block independently of Orbit's records; re-opting in here does not clear that carrier-level block. If a contact re-consents outside a messaging channel (a web form, email, or preference center) and still can't be reached on SMS after this call, the block may be held at the carrier and needs to be resolved through your toll-free verification / carrier support channel, not through this API.
</Note>

***

## Export opt-outs

<Note>
  `POST /api/v1/contacts/optouts/export`
</Note>

**Scope:** `contacts:write` (export is treated as a write because it produces an artifact and is rate-limited as such).

Returns up to 10 000 opted-out contacts as JSON. Use for compliance evidence (e.g. carrier registration review, FCC complaint response).

<ParamField body="channel" type="enum">
  Optional filter — one of the 9 canonical channels. Omit to export all opt-outs.
</ParamField>

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/optouts/export" \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms" }'
```

**200 OK**

```json theme={null}
{
  "data": {
    "contacts": [
      {
        "id": "con_abc",
        "phone": "+14155552671",
        "email": null,
        "display_name": "Ada Lovelace",
        "channel_preferences": {
          "sms": { "opted_out": true }
        },
        "updated_at": "2026-05-22T10:30:00Z"
      }
    ],
    "total": 1
  },
  "meta": { "request_id": "req_abc123", "timestamp": "2026-05-24T12:00:00Z" }
}
```

For tenants with >10 000 opt-outs, use the paginated `GET /optouts` endpoint and iterate `cursor`.

<Note>
  The export returns current opt-out **state** per contact. For a timestamped, reasoned history of when each opt-out or opt-in occurred — the trail most compliance reviews ask for — read the audit log (`contact.opted_out` / `contact.opted_in` entries).
</Note>

***

## Errors

| Status | `error.code`       | Cause                                                                                                     |
| ------ | ------------------ | --------------------------------------------------------------------------------------------------------- |
| `422`  | `VALIDATION_ERROR` | `channel` not in canonical list, `contact` empty, `reason` missing/too long, or `rows` count exceeds 500. |
| `404`  | `NOT_FOUND`        | Contact id not found in the tenant (`DELETE` path).                                                       |
| `403`  | `FORBIDDEN`        | API key lacks `contacts:write` or caller role is `viewer`.                                                |
| `429`  | `RATE_LIMITED`     | Per-API-key rate limit exceeded: bulk endpoints allow 10/minute, export endpoints allow 5/minute.         |

***

## Audit + observability

Every state change writes an audit log:

* `contact.opted_out` on `POST /optouts` and per row on `POST /optouts/bulk`.
* `contact.opted_in` on `DELETE /optouts/:id/:channel`.

Each entry carries `{ channel, reason }` (and `bulk: true` on bulk-imports). Per-row audit writes during a bulk import are best-effort — a single audit-write failure does NOT roll the batch back, but the warning is logged structurally.

## Cross-channel STOP/START

A STOP reply received on one channel automatically fans out to a configured set of related channels via the inbound webhook handler (`handleOptOut`). See [Compliance overview](/compliance/recording-consent) for the cross-channel fan-out matrix.

## See also

* [Contacts API](/api-reference/endpoints/contacts)
* [Webhook events — `contact.opted_out` / `contact.opted_in`](/webhooks/events)
* [Compliance — TCPA & quiet hours](/voice/emergency-calling)
