Suppression and opt-outs
Manage do-not-contact entries, how STOP suppresses automatically, and how suppression supports POPIA.
Why it matters (POPIA)
The suppression list is your do-not-contact register. A single send to an identifier on it (a phone number or email address) is refused with validation_failed ("Recipient is on the opt-out list for this channel"); in a campaign that recipient is skipped and recorded as failed with the reason suppressed. Either way, you never message someone who has opted out.
South Africa's POPIA requires that you honour opt-outs and stop contacting people who have withdrawn consent. Suppression makes that the default: once an identifier is suppressed, Blueticked enforces it at send time, so compliance does not depend on each integration remembering to check. An entry has a scope. Its topic is all, marketing or transactional: a STOP, a bounce or a spam report blocks everything, while an unsubscribe link, a preference-centre choice or a CSV import blocks marketing only, so order confirmations still arrive. An entry with a business_id blocks only that business.
STOP auto-suppresses
When a customer replies STOP (or another recognised opt-out keyword) on WhatsApp or SMS, Blueticked adds them to the suppression list automatically with a reason of stop_keyword. Email bounces (all but the transient ones) and spam complaints suppress automatically too, as bounce and spam_report. You do not need to wire any of this up: it happens server-side.
List entries
Cursor-paginated, newest first. Optionally filter by channel:
const page = await bt.suppression.list({ limit: 100 });
for (const entry of page.data) {
// entry.identifier, entry.channel, entry.reason, entry.created_at
console.log(entry.identifier, entry.reason);
}
// Only email opt-outs:
const emailOptOuts = await bt.suppression.list({ channel: "email" });Add an opt-out
Add an entry yourself when someone opts out through your own channels (a CRM, a call centre, an unsubscribe form). Use channel: "all" to block every channel, or a specific channel to opt out of just that one. It is idempotent: a new entry answers 201, and re-adding the same one (the same identifier, channel, topic and business) answers 200 with the existing entry and already_present: true:
const entry = await bt.suppression.add({
identifier: "+27821234567",
channel: "all",
reason: "manual",
reason_detail: "Opted out via CRM",
});
console.log(entry.id); // a UUID: "9f1c2a30-8b4e-4d6a-9a11-2f7c5b9e0d33"The API accepts nine reason values: manual, manual_add, stop_keyword, unsubscribe_link, preference_center, bounce, complaint, spam_report and csv_import (the SDK's add() types the four you would send yourself). Left out, it is stored as manual_add; for your own additions use manual. Two of them are normalised on write: manual is stored as manual_add and complaint as spam_report, so the reason you read back can differ from the one you sent. Compare against the stored value, not the one you posted.
Remove an entry
Removing an entry makes that identifier contactable again, so removal is deliberately not symmetric with adding. A number that texted STOP can stay blocked for SMS on the platform-wide list even after you remove your entry: the response says so with sms_still_globally_blocked. What happens depends on why the entry exists, and for most real entries a bare remove() is refused:
| Stored reason | Removal |
|---|---|
bounce, spam_report | 403 forbidden, permanently. These mark mailboxes that rejected or reported this sender; resuming sends to them damages the whole workspace's deliverability. No header lifts this: a person removes it in the dashboard or not at all. |
stop_keyword, unsubscribe_link, preference_center | The person asked to stop, so the API needs you to say you have their re-consent: pass confirmReconsent (the X-Confirm-Reconsent: true header). Without it, 400 validation_failed. |
manual_add, csv_import | Deletes outright, with no confirmation header needed. |
// An entry you added yourself deletes outright:
await bt.suppression.remove(entry.id);
// A STOP / unsubscribe / preference-centre entry needs explicit re-consent.
// Only set this once the recipient has actually asked to hear from you again:
await bt.suppression.remove(entry.id, { confirmReconsent: true });
// Raw HTTP is the same header:
// DELETE /api/v1/suppression/{id}
// X-Confirm-Reconsent: trueThe id is the entry's UUID, from list() or add(). Anything that is not a UUID is rejected with 400 validation_failed before the lookup runs. Read the stored reason first and branch on it: that is the only way to know which of the three rows above you are in.
Suppression vs. the contact gate
The suppression list is keyed by identifier (a phone or email), so it applies even before a contact exists. A contact also has its own suppressed flag (see Contacts) that blocks every channel for that one contact record. Both are checked at send time; either one is enough to refuse a single send (or skip the recipient in a campaign).