Skip to main content

Message Suppression

Message suppression solves one specific problem: two different sends — a retried batch, an overlapping campaign, a flow that re-fires — deliver the identical message body to the same contact on the same channel within a short window. Rather than the contact getting the same promo twice, Orbit silently skips the second send. It’s a single opt-in policy per organization, enforced inside the message-send pipeline before frequency capping, so a duplicate never burns a frequency-cap slot. For the full request/response shape, see the Message Suppression API reference.

When to use it

Turn on message suppression if you run overlapping campaigns, retry logic that could double-send, or automations that might fire twice for the same trigger — and you’d rather silently drop the duplicate than have a customer receive the same SMS or email twice in one day. It’s a safety net, not a primary compliance control — for actual consent and channel blocking, see opt-outs.

Enabling a policy

There is one policy per organization: PUT creates or replaces it, GET reads the current policy (null if none is configured), and DELETE turns suppression off.

What a suppressed send looks like

A suppressed send reports as skipped, with reason duplicate_content — not as an error — so a campaign or batch send keeps moving through the rest of its recipient list instead of stopping on the duplicate. The send API returns a success-shaped response:

What counts as a duplicate

Two sends are duplicates of each other when all three match:
  1. Same recipient — the exact phone number or address the message is going to.
  2. Same channel — an SMS and a WhatsApp message with identical text are tracked independently.
  3. Same body — the message text hashes to the same value (details below).
Matching is on the body text only:
  • Whitespace is normalized before hashing — runs of spaces, tabs, and newlines collapse to a single space and the ends are trimmed, so a template engine that reflows line breaks still produces the same hash.
  • Case is preserved. SALE ENDS FRIDAY and Sale ends Friday are not duplicates of each other.
  • metadata, status_callback, and other request fields do not participate in the hash — only the body text does.
  • Sends with no hashable body (an empty or whitespace-only body — a template-only or media-only send) are never treated as duplicates and always pass through.

Pipeline ordering

Each send passes through the gates in this order:
  1. Opt-out checks — a recipient who opted out of the channel is blocked first.
  2. Message suppression — the body-vs-recipient-vs-channel duplicate guard this page describes.
  3. Frequency cap — the per-channel “how many sends in N hours” counter.
  4. Dispatch — the message is handed to the channel provider.
Because suppression sits before frequency capping, a suppressed send does not consume a frequency-cap slot. The reverse order would let duplicates eat into a contact’s cap allowance; they never do.

Edge cases

Two identical sends fire at the same moment. Suppression is atomic per (recipient, channel, body-hash): the check-and-claim is a single compare-and-set, so exactly one of two simultaneous sends wins. The first one dispatches; the second is skipped as a duplicate — even when both arrive within the same millisecond. One accepted trade-off to know about: the content marker is claimed before the provider dispatch, so if the first send then fails at the provider, the marker is still held. A same-body retry inside the window is suppressed even though nothing was delivered. With a marketing-scoped policy this is rare in practice; if you need guaranteed redelivery after a provider failure, resend after the window or with meaningfully different copy. A policy lists more than one channel. With channels: ["sms", "whatsapp"], the duplicate key is per-channel: sms + recipient + body and whatsapp + recipient + body are tracked independently. Sending the same promo over SMS then WhatsApp to the same contact does not suppress the WhatsApp send — suppression only fires on the same body over the same channel to the same recipient. There is no cross-channel “already got this content” mode. The policy changes while traffic is flowing. The policy is read on every send, so a PUT takes effect at the next send — there is no caching lag. A send already dispatched before the PUT is unaffected; a queued-but-not-yet-dispatched send evaluates the new policy when it is dispatched. window_seconds is re-set. The window applies at claim time, per content marker:
  • Markers written while a longer window was configured keep their original lifetime. Shortening window_seconds from 7 days to 1 hour leaves the markers written under the 7-day window alive until their full 7 days elapse.
  • A shorter window does not retroactively re-enable older bodies — a body sent 2 days ago under the old 7-day window stays suppressed until that marker reaches its original 7-day age. New markers are written with the new TTL.
  • Widening the window works the same way in the other direction: new markers get the longer lifetime; existing markers keep the TTL they were written with and expire on their original schedule.
If you ever need every marker gone immediately — for example after a misconfigured window — delete and re-create the policy; suppression stays fully fail-open, so a missing policy never blocks sends. Redis or backend hiccups. Suppression fails open: if the policy read or the marker claim can’t complete, the send is allowed through. A transient backend problem never silently swallows outbound traffic.

What NOT to suppress

Keep transactional traffic out of the policy with applies_to_categories. One-time passcodes, receipts, and appointment reminders are exactly the messages a recipient wants resent — a suppressed OTP read as a broken login. A typical setup restricts the policy to marketing copy:
The category a send is matched against comes from the send’s metadata.category (falling back to metadata.message_type when no category is set). A send with neither set never matches a category-restricted policy, so undeclared traffic is never on the hook. The same category mechanism gates frequency caps, so the categorization you set up there carries over. Suppression is silent by design: a suppressed send returns a success-shaped skipped response and nothing else happens — no webhook event, no callback to status_callback, no retry. If you want to know a send was suppressed, check the send response (or the message’s outcome) — don’t wait for a webhook that will not come.

Dashboard surface

The suppression policy itself is managed through the API only — there is no dashboard screen for creating or editing it. What a suppressed send does leave behind is its skipped outcome on the message, which you can confirm from the Delivery log or the message detail view when you’re tracing “did the second send go out?”.

Troubleshooting

The second send went through anyway. Work through the checklist in order — each is a real way the duplicate guard legitimately doesn’t fire:
  1. Window too short. The first send’s marker expired. If the second send landed more than window_seconds after the first, it is not a duplicate under your policy — widen the window.
  2. Different channel. Same body over SMS then WhatsApp is not a duplicate — the key is per-channel (see Edge cases).
  3. Different category, or no category. The send’s metadata.category didn’t match your applies_to_categories list. A send with no category set never matches a category-restricted policy.
  4. Policy changed after the first send. If the policy was PUT between the two sends, the second send evaluated the new policy — and if that PUT also set enabled: false or replaced the channel/category lists, the second send legitimately passed.
  5. Body wasn’t identical. Case differs (SALE vs Sale), or the second send has no text body at all — media-only and template-only sends are never suppressed.
  6. First send claimed the marker but failed at the provider. The marker is claimed before dispatch, so a same-body retry inside the window is suppressed even though nothing was delivered (see Edge cases). Resend after the window.
To confirm that a given send was actually suppressed, open the Delivery log and look the message up — a suppressed send shows its skipped outcome rather than a provider delivery status. Suppression seems to have stopped working. The policy lives per organization and is fail-open by design. GET /api/v1/message-suppression/ and check the policy is still present and enabled — a missing policy or a Redis-side hiccup disables suppression entirely rather than blocking sends (the same fail-open posture frequency caps take).

Message suppression vs. frequency caps

Both operate on repeated sends, but on different axes:
  • Message suppression cares about the content — same body, same recipient, same channel, inside the window.
  • Frequency caps care about the count — how many sends (of any content) a contact received, regardless of whether the bodies matched.
A tenant that wants both “no more than 3 marketing SMS a day” and “never send the exact same promo twice in a week” configures both — they run independently and don’t interfere with each other’s counters.

See also