Troubleshooting: imported opt-outs still get messages — check the scope
You imported a legacy opt-out CSV throughPOST /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.
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.Symptom map
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
allmatches 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
emailmatches email sends only,smsmatches SMS only,voicematches voice only, and so on — a row that carries an email address defaults toemail, and an explicitchannelcolumn narrows further to whatever value it held.
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:
cURL
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:
cURL
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:
-
Export the ledger (or pull
GET /contacts/optoutsabove) and filter to the rows with the wrong scope. -
Build a CSV that carries the same address plus a
channelcolumn set to the scope you actually need gated. Re-import:cURL -
Verify the fence by reading the export (or
GET /contacts/optouts) again — thechannelvalue on the accepted row is now the scope the gate will enforce.
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
channeloverride. A row holding both a phone and an email with nochannelcolumn yields one scope-allphone row and one scope-emailemail row. - The
channelcolumn is an override, not a validation gate — a value that isn’t one of the scope values rejects the row asmissing_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/optoutslive onchannel_preferencesper 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 achannel column
left over from an email-only migration:
by_channel reports them under
email:
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/checkandPOST /api/v1/compliance/dnc/scrubanswer 403 withDNC_SYNC_NOT_ENABLEDwhile your organization has not acknowledged the pre-flight gate — see Troubleshooting: DNC pre-flight returns 403. Until you flipdnc_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.
- The address was revoked after the import. Re-opt-in via the
Consent API revokes the row — a
subsequent send is then legitimate. Export with
status=revokedto check before re-importing.
Verify the fix end to end
- Re-import with the corrected scope (above).
- Export the ledger and confirm the row now carries the scope you
intended and
status: active. - Send a small test message to the address in
sandbox mode: a direct API send to a
suppressed recipient fails synchronously with
422 RECIPIENT_OPTED_OUT, which is the fence proving it fires.
Related references
- Opt-Out & Suppression Lists — the scope table, CSV import contract, and the dashboard import wizard this page’s troubleshooting assumes.
- Consent Management — revoke a row cleanly, and the scope-vs-channel parity on all entry points.
- Opt-Outs API — per-contact, per-channel
opt-out records and the
/contacts/optoutslist this page reads. - Troubleshooting: DNC pre-flight returns 403 DNC_SYNC_NOT_ENABLED — the acknowledgment gate when the failure is not scope.
- Troubleshooting: sender resolution and pool errors — when the gate never fires because no sender resolves.