Skip to main content

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.
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 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:
cURL
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:
cURL
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:
    cURL
  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:
The import accepts both rows, but by_channel reports them under email:
Every send to either number still goes through, because the scoped rows cover email only. The fix is the re-import from the Fix 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. 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.
  • 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=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: 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.