> ## 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.

# Troubleshooting: imported opt-outs still get messages — check the scope

> Diagnose why a recipient on your suppressions still receives messages (or an over-blocked recipient cannot be reached): find the per-row scope your CSV import recorded, re-import with the scope you intended, and recognise when a different gate (DNC acknowledgment, sender scrub toggle) is the real culprit.

# Troubleshooting: imported opt-outs still get messages — check the scope

You imported a legacy opt-out CSV through
`POST /compliance/suppression-list/import`, the run reported `accepted`
rows, and yet the address still receives messages — or the opposite: a
recipient you only wanted off one channel is now blocked everywhere.
Both failures are one mechanism: the **per-row channel scope** your
import recorded does not match the channel you intended.

This page walks you from the symptom to the exact row's scope value,
the fix, and — when the scope turns out to be correct — the other gates
to rule out before you re-import.

<Note>
  Suppression scope is a **tenant-owned** control. The import infers a
  scope from each row's address type unless a `channel` column overrides
  it, and send gates honour exactly the scope you recorded. Nothing on
  the platform imposes a scope for you — you chose it at import time,
  and you can re-import to change it.
</Note>

***

## Symptom map

| Symptom                                                         | What the scope tells you                                                                                                                                                                  |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A recipient on the list still receives messages on a channel    | The row landed with a **restricted** scope (`email` on a phone row, or a `channel` column narrowed it), and the channel you're sending on isn't the one it blocks                         |
| A recipient is blocked on channels you never meant to gate      | The row landed with scope `all` — the default for phone and WhatsApp rows — and blocks every channel                                                                                      |
| The import reported the row accepted but sends still go through | The row's scope is narrower than the channel being sent on, or the gate never fired because a different gate is off (see [when scope is not the culprit](#when-scope-is-not-the-culprit)) |

***

## Why a row's scope decides every gate

`POST /compliance/suppression-list/import` writes one suppression entry
per address per CSV row, keyed `(channel, address)`. Every send path —
campaign, flow, or a direct API call — checks the scope of the matching
row **before it checks suppression**:

* Scope `all` matches every channel — the default for phone and
  WhatsApp rows, because a STOP on a phone number is taken to cover every
  channel reachable on that number.
* Scope `email` matches email sends only, `sms` matches SMS only,
  `voice` matches voice only, and so on — a row that carries an
  email address defaults to `email`, and an explicit `channel` column
  narrows further to whatever value it held.

So the gate that fired (or didn't) is whichever scope the row holds:
an `email`-scoped row never fences an SMS send, and a `sms`-scoped row
never fences an email send. Quiet-hours, DNC, and sender-resolution
gates run **after** this filter — and only if the scoped row survives
them all does the message move.

The scope set a row can carry: `all`, `sms`, `voice`, `whatsapp`,
`email`, `push`, `telegram`, `messenger`, `rcs`.

***

## Diagnose: read the scope of the row that fired (or didn't)

The suppression ledger is the source of truth every send gate reads,
so diagnose on the ledger rather than the campaign report.

**Option A — the contact-scoped opt-outs list.** When the recipient
exists as a contact, `GET /api/v1/contacts/optouts` (filtered by
`channel` and searched by phone or email) returns the contact row with
the `channel_preferences` map the gate consults:

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/contacts/optouts?search=%2B14155550101" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```json theme={null}
{
  "data": [
    {
      "id": "con_abc",
      "phone": "+14155550101",
      "channel_preferences": {
        "email": { "opted_out": true }
      }
    }
  ]
}
```

Here the opt-out is scoped to `email` only — SMS to this number goes
through. The ledger read is the same conclusion the send gate draws.

**Option B — the suppression-list export.** For addresses without a
matching contact (the common case for legacy imports), export the
ledger and read the row's `channel` value directly:

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/compliance/suppression-list/export?status=active&format=json&limit=50000" \
  -H "Authorization: Bearer $ORBIT_API_KEY"
```

Each row returns `suppression_id`, `channel`, `address`, `status`,
`reason`, `source`, and timestamps. The `channel` value is the row's
scope; `status: active` is the row set the gate reads. If the export
caps out (the JSON response sets `truncated: true`), narrow by the
`channel` filter or a `from`/`to` date window to reach the row.

**What the import run told you.** The `by_channel` breakdown in the
original `POST /compliance/suppression-list/import` response already
grouped the accepted rows by scope — if the counts look like
`{ "email": 950 }` on a phone-number file, the file's rows were
narrowed by a `channel` column at import time.

***

## Fix: re-import with the scope you intended

The import is idempotent on `(channel, address)`, so a re-import with a
corrected `channel` column (or none at all, letting the default
infer) replaces the scoped row with the scope you want. For a partial
fix you can re-import only the affected rows.

**Re-import the affected rows with explicit scope:**

1. Export the ledger (or pull `GET /contacts/optouts` above) and filter
   to the rows with the wrong scope.

2. Build a CSV that carries the same address plus a `channel` column
   set to the scope you actually need gated. Re-import:

   ```csv theme={null}
   phone,channel,reason
   +14155550101,all,opted out across all channels
   +442071838750,sms,opted out of SMS only
   ```

   ```bash cURL theme={null}
   curl -X POST https://api.orbit.devotel.io/api/v1/compliance/suppression-list/import \
     -H "Authorization: Bearer $ORBIT_API_KEY" \
     -F "file=@corrected-scopes.csv" \
     -F "default_reason=scope correction"
   ```

3. Verify the fence by reading the export (or `GET /contacts/optouts`)
   again — the `channel` value on the accepted row is now the scope the
   gate will enforce.

**Use scope `all` when a STOP should be absolute.** A STOP reply on a
phone number, the Preference Center, and the Consent API all record
`all` — the same scope a cross-channel import should land on. A
channel-scoped re-import is the right shape only when you genuinely
want the recipient reachable on the remaining channels.

**Scope-vs-channel edge cases (read before you "fix" an over or under-block):**

* An **identity-scoped** row (`all`) fences every channel; a
  **channel-scoped** row (`sms`, `email`, …) fences exactly that
  channel. Neither is wrong by itself — the wrongness is only when the
  row's scope disagrees with the channel you send on.
* A single CSV row can carry **several address types** (phone + email +
  wa\_id); each address gets its own scope from the address-type default
  or the row's `channel` override. A row holding both a phone and an
  email with no `channel` column yields one scope-`all` phone row and
  one scope-`email` email row.
* The `channel` column is an **override**, not a validation gate — a
  value that isn't one of the scope values rejects the row as
  `missing_address`, so clean the column (or drop it to accept the
  default) rather than leaving a bad value in place.
* Existing contact opt-outs on `GET /api/v1/contacts/optouts` live on
  `channel_preferences` per contact — a re-import through the CSV path
  updates the same underlying suppression fence the gate reads, so you
  do not need to touch both.

***

## Worked example — scope narrowed at import

Your legacy list holds phone numbers but carries a `channel` column
left over from an email-only migration:

```csv theme={null}
phone,channel,reason
+14155550101,email,migrated_from_legacy
+442071838750,email,migrated_from_legacy
```

The import accepts both rows, but `by_channel` reports them under
`email`:

```json theme={null}
{
  "data": {
    "run_id": "supimp_4d…",
    "total_rows": 2,
    "accepted": 2,
    "duplicates": 0,
    "invalid": 0,
    "by_channel": { "email": 2 },
    "errors": []
  }
}
```

Every send to either number still goes through, because the scoped
rows cover email only. The fix is the re-import from the
[Fix](#fix-re-import-with-the-scope-you-intended) section above with
`channel` dropped or set to `all`.

***

## When scope is NOT the culprit

If the row's scope matches the channel you were sending on and sends
still go through, the gate never fired. Rule these out first:

* **Tenant DNC acknowledgment off.** `GET /api/v1/compliance/dnc/check`
  and `POST /api/v1/compliance/dnc/scrub` answer **403 with
  `DNC_SYNC_NOT_ENABLED`** while your organization has not acknowledged
  the pre-flight gate — see
  [Troubleshooting: DNC pre-flight returns 403](/troubleshooting/dnc-check-gated). Until you flip `dnc_sync_enabled`, the
  read endpoints refuse — but your tenant-curated suppression list is
  checked on the actual send path either way, so a suppression gate on
  sends is unrelated to the acknowledgment gate.
* **Scrub disabled in sender/resolution settings.** The send gate only
  fences a channel while the scrub on that channel's sender settings is
  enabled — check **Settings → Sender resolution** (or the channel's
  sender pool) that the pre-send scrub is on. When scrub is off, a
  correctly-scoped row never gets consulted. See
  [Troubleshooting: sender resolution errors](/troubleshooting/sender-resolution-errors).
* **The address was revoked after the import.** Re-opt-in via the
  [Consent API](/compliance/consent-management) revokes the row — a
  subsequent send is then legitimate. Export with `status=revoked` to
  check before re-importing.

***

## Verify the fix end to end

1. Re-import with the corrected scope (above).
2. Export the ledger and confirm the row now carries the scope you
   intended and `status: active`.
3. Send a small test message to the address in
   [sandbox mode](/sandbox/magic-numbers): a direct API send to a
   suppressed recipient fails synchronously with `422 RECIPIENT_OPTED_OUT`,
   which is the fence proving it fires.

The two checks answer different questions — step 2 proves scope,
step 3 proves enforcement.

***

## Related references

* [Opt-Out & Suppression Lists](/compliance/opt-out-suppression) —
  the scope table, CSV import contract, and the dashboard import wizard
  this page's troubleshooting assumes.
* [Consent Management](/compliance/consent-management) — revoke a
  row cleanly, and the scope-vs-channel parity on all entry points.
* [Opt-Outs API](/api-reference/optouts) — per-contact, per-channel
  opt-out records and the `/contacts/optouts` list this page reads.
* [Troubleshooting: DNC pre-flight returns 403 DNC\_SYNC\_NOT\_ENABLED](/troubleshooting/dnc-check-gated) —
  the acknowledgment gate when the failure is not scope.
* [Troubleshooting: sender resolution and pool errors](/troubleshooting/sender-resolution-errors) —
  when the gate never fires because no sender resolves.
