> ## 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-Out & Suppression Lists

> How Orbit suppresses opted-out recipients across channels, and how to bulk-import a suppression list from CSV with per-row results and deduplication.

# Opt-Out & Suppression Lists

A **suppression list** is the set of addresses you must never message
again — people who replied STOP, unsubscribed, bounced, or complained.
Honouring it is a legal requirement on every regulated channel, and
Orbit treats it as a hard send-gate: a suppressed address is dropped
before dispatch regardless of campaign, contact import, or API call.

This page covers how suppression works and how to **bulk-import** an
existing suppression list — for example when migrating from another
platform — through a single CSV upload.

All endpoints below are rooted at
`https://api.orbit.devotel.io/api/v1/compliance`.

***

## How suppression happens

An address lands on the suppression list in several ways:

* A contact replies with a **STOP keyword** on SMS/WhatsApp.
* A contact opts out via the [Preference Center](/compliance/send-gates#preference-center).
* You record an opt-out through the
  [Consent API](/compliance/consent-management) (`opt_in: false`).
* You **bulk-import** a list (this page).

Each entry has a **channel scope**. The full scope set is: `all`,
`sms`, `voice`, `whatsapp`, `email`, `push`, `telegram`, `messenger`,
`rcs`.

How the scope is chosen depends on the entry point:

* **Bulk CSV import** infers the scope from the address type of each
  row: phone and WhatsApp addresses default to scope `all` — a STOP
  signal on a phone number suppresses every channel reachable on that
  number — while email addresses are scoped to `email`. A `channel`
  column overrides this per row (see [Bulk CSV import](#bulk-csv-import)).
* **The Consent API and the Preference Center** always suppress scope
  `all`, regardless of whether the recorded identifier is a phone
  number or an email address. An opt-out through either of these entry
  points removes the contact from every channel.

<Note>
  However a phone number is suppressed, the voice and dialer gates honour
  it: a number that opts out on any channel stops receiving calls as well
  as messages. The mechanism differs by entry point. A **bulk CSV import**
  additionally mirrors phone rows onto the DNC list and flags the matching
  contacts. A **STOP keyword**, **Preference Center**, or **Consent API**
  opt-out is instead recorded with the `all` scope, which the voice and
  dialer gates read from the suppression list directly — the call is still
  blocked, but no separate DNC-list row or contact flag is written.
</Note>

***

## Bulk CSV import

`POST /compliance/suppression-list/import` accepts a
`multipart/form-data` upload of a CSV file. It requires an admin or
owner key and is rate-limited to 5 requests/minute.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/compliance/suppression-list/import \
  -H "Authorization: Bearer $ORBIT_API_KEY" \
  -F "file=@suppressions.csv" \
  -F "default_country=US" \
  -F "default_reason=migrated_from_legacy_platform" \
  -F "dry_run=false"
```

### Form fields

| Field             | Type    | Notes                                                                                                  |
| ----------------- | ------- | ------------------------------------------------------------------------------------------------------ |
| `file`            | file    | **Required.** A single CSV, ≤ 25 MB, ≤ 100,000 rows.                                                   |
| `default_country` | string  | ISO-3166-1 alpha-2. Used to normalise national-format phone numbers to E.164.                          |
| `default_reason`  | string  | Applied to every accepted row (≤ 512 chars).                                                           |
| `dry_run`         | boolean | When `true`, parse and classify only — no database writes. Use it to preview a file before committing. |

### CSV format

The first row is a header. Column names are **case-insensitive** and
**position-independent**, and common aliases are accepted:

| Logical column              | Accepted headers                           |
| --------------------------- | ------------------------------------------ |
| Phone                       | `phone`, `phonenumber`, `mobile`, `msisdn` |
| Email                       | `email`, `emailaddress`, `mail`            |
| WhatsApp ID                 | `wa_id`, `whatsapp`, `whatsappid`          |
| Reason (optional)           | `reason`, `note`, `notes`                  |
| Channel (optional override) | `channel`                                  |

Each row must contain **at least one** of phone / email / wa\_id. A
single row may carry several address types — each produces its own
suppression entry. Example:

```csv theme={null}
phone,email,reason
+14155550101,,replied STOP
,jordan@example.com,unsubscribed via email
+442071838750,sam@example.co.uk,complaint
```

If a `channel` column is present it overrides the default scope for
that row and must be one of the scope values listed above.

### Per-row results

The response reports outcomes per row. Accepted rows are written;
others are classified, never silently dropped.

```json theme={null}
{
  "data": {
    "run_id": "supimp_4d…",
    "total_rows": 1000,
    "accepted": 950,
    "duplicates": 30,
    "intra_file_duplicates": 15,
    "invalid": 5,
    "errors": [
      { "status": "invalid", "reason": "invalid_phone", "raw_line": 42 }
    ],
    "by_channel": { "all": 800, "email": 150 },
    "file_sha256": "9b2e…"
  },
  "meta": { "request_id": "…", "timestamp": "2026-06-08T12:00:00.000Z" }
}
```

| Counter                 | Meaning                                                                     |
| ----------------------- | --------------------------------------------------------------------------- |
| `accepted`              | Rows newly written to the suppression list.                                 |
| `intra_file_duplicates` | Rows that repeat an earlier `(channel, address)` **within this same file**. |
| `duplicates`            | Rows already suppressed from a **prior** import (skipped, no-op).           |
| `invalid`               | Rows that failed validation — see `errors[]`.                               |
| `by_channel`            | Accepted counts grouped by channel scope.                                   |
| `file_sha256`           | Content hash of the upload, recorded for audit.                             |

<Note>
  The two duplicate counters are reported **separately and on
  purpose**: `intra_file_duplicates` are repeats inside the file you
  just uploaded, while `duplicates` were already on your list from
  earlier. Neither is an error, and neither is silently swallowed —
  both are counted so your reconciliation adds up.
</Note>

### Validation reasons

Each `errors[]` entry carries a friendly `reason` and the source
`raw_line` so you can fix and re-upload:

| `reason`           | Cause                                     |
| ------------------ | ----------------------------------------- |
| `missing_address`  | The row had no phone, email, or wa\_id.   |
| `invalid_phone`    | Phone could not be normalised to E.164.   |
| `invalid_email`    | Email failed RFC-5321 shape validation.   |
| `invalid_wa_id`    | WhatsApp ID was not a valid E.164 number. |
| `row_too_long`     | A cell exceeded 4096 characters.          |
| `too_many_columns` | The row had more than 32 columns.         |

### Limits

| Limit                | Value                                                                  |
| -------------------- | ---------------------------------------------------------------------- |
| Max file size        | 25 MB                                                                  |
| Max rows per request | 100,000                                                                |
| Max cell length      | 4,096 chars                                                            |
| Max columns per row  | 32                                                                     |
| Max reason length    | 512 chars                                                              |
| Server-side timeout  | 60 s (a partial import returns `408` with the counts processed so far) |

For volumes beyond 100,000 rows, split the file and import in batches —
duplicate detection means re-importing overlapping ranges is safe.

***

## Removing a suppression (re-opt-in)

To bring an address back, record a fresh opt-in through the
[Consent API](/compliance/consent-management) (`opt_in: true`). That
revokes the matching suppression entry and clears the STOP-fence.
Never re-message a previously suppressed contact without a documented,
fresh consent event.

***

## Related references

* [Consent Management](/compliance/consent-management) — record and
  look up per-channel consent.
* [Send Gates](/compliance/send-gates) — quiet hours, DNC, RND, RMD,
  emergency stop, and the preference center.
* [DSAR](/compliance/dsar) — how `delete` / `opt_out` requests reach
  suppression.
* [API Reference → Opt-outs](/api-reference/optouts) — opt-out and
  suppression endpoint schemas.
