contact.opted_out webhook event, the two endpoints for syncing suppression with your own systems, and why enforcement happens at send time instead of at list-build time.
What triggers an opt-out
BlueReacher runs two checks on every inbound message. Keyword match. Case-insensitive, punctuation-tolerant, whitespace-trimmed. A reply is a keyword opt-out when the whole message, after stripping punctuation and emoji, matches one of these:
Plain-language match. Most people do not type STOP. They write a sentence. Every inbound reply is classified for removal intent and the contact is suppressed when the message is a removal request, whatever the wording. These suppress:
- “please take me off your list”
- “don’t text me again”
- “how did you get this number, remove me”
- “stop messaging me, I’m not interested”
- “unsubscribe me from whatever this is”
- “keep texting and I’m reporting this”
- “stop by the office next week” (keyword inside a sentence, not the whole message)
- “we’re looking to stop using our current vendor” (same)
- “not interested” (a soft no, classified as a negative reply, not a removal request)
- “who is this?”
set-opt-out from your reply handler.
What happens the moment a contact opts out
In order, within the same second the reply is processed:- The contact is marked
opted_out: truewith a timestamp and a source. - Every queued and scheduled message addressed to that contact is cancelled, on every line, in every campaign, and moves to
status: "rejected". - The
contact.opted_outwebhook fires. - Any new send addressed to that contact is refused at the API with
403.
imessage, rcs, or sms. The identity being suppressed is the phone number.
A refused send returns:
error.code, not on the message string. The code is stable, the message is not.
The contact.opted_out webhook
Subscribe in the dashboard under Webhooks. The event fires once per opt-out, whether it came from detection, the API, or a dashboard action.
Two rules for the handler. Write the suppression to your system of record in the same run, because this event is the only push signal you get. And deduplicate on
contact_id, because webhook delivery is at-least-once and a retry will arrive with an identical payload.
set-opt-out
Push a suppression from your own systems into BlueReacher, or clear one you added by mistake. Use it when someone unsubscribes by email, tells a rep on a call, or gets flagged Do Not Contact in your CRM.POST https://api.bluereacher.com/functions/v1/set-opt-out
Auth uses the same key as sending: Authorization: Bearer bluereacher_your_api_key. There is no separate compliance scope.
Errors:
Clearing a detected opt-out is allowed, and it is logged with the API key that did it and a timestamp. Do it only when you hold a record of new consent. BlueReacher does not evaluate that record. You own it.
check-opt-out
Read suppression state for a batch of numbers before you import them or open a send window. It creates nothing, changes nothing, and does not count against send volume.POST https://api.bluereacher.com/functions/v1/check-opt-out
Auth header is the same: Authorization: Bearer bluereacher_your_api_key.
Errors:
A
contact_id of null with opted_out: false means no opt-out is on file. That is not the same as consent. It only tells you this number has not asked you to stop.
Cleaning a list before import
- Normalize every number to E.164 before you send it. Numbers that fail parsing land in
invalidand get silently skipped, which reads as a clean list when it is not. - Deduplicate. Duplicate numbers burn batch slots and produce duplicate results.
- Split into batches of 1,000 and call
check-opt-outon each. - For every hit, write the suppression back to the source system. Dropping the row from your CSV loses the reason.
- Import the remainder.
checked_at. A list cleaned Monday and sent Thursday is three days stale. The running webhook handler is what covers that gap, not the import-time check.
Why enforcement happens at send time
Filtering at list-build time is the obvious design: clean the list, then send it. It breaks in four predictable ways. Lists go stale. An opt-out that lands at 10:02 does nothing to a list filtered at 09:00. The larger your campaign, the longer the window between build and last send, and the more messages go out to people who already asked you to stop. Opt-outs are account-wide, lists are not. A contact who opts out on one line is usually sitting in three other lists for other campaigns on other lines. Filtering per list means every list has to learn about every opt-out independently. One missed sync is a message you cannot take back. Scheduled and multi-step sends fire later. Follow-ups, drips, and retries execute hours or days after the list was assembled. A build-time filter cannot see a decision the contact makes in between. Failure directions are not equal. A list-time filter that misses someone fails open: the message ships and you learn about it from the recipient. A send-time check that misbehaves fails closed: the send is refused, you see a403 in your logs, and you can fix it. A refused send is recoverable. A delivered one is not.
Send-time enforcement also preserves the record. Deleting suppressed people from your list destroys your evidence that you honored the request. Keeping the contact with opted_out, opted_out_at, and source means you can show exactly when someone asked you to stop and what happened next.
The tradeoff is real and you have to code for it. Your queued count will not match your delivered count, and 403 contact_opted_out is a normal outcome rather than an exception. Treat it as terminal. Retrying it never succeeds.
Checklist
- Handle
contact.opted_outand write to your system of record in the same run. Deduplicate oncontact_id. - Treat
403 contact_opted_outas a terminal state. No retry, no backoff. - Run
check-opt-outon every list before import, in batches of 1,000, on normalized E.164 numbers. - Push CRM, email, and phone unsubscribes into
set-opt-outon a schedule, not only at import time. - Never clear a detected opt-out without a consent record you can produce on request.