> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Message suppression: block duplicate message sends

> Stop the same message body from reaching a contact twice in Orbit with a content-hash duplicate guard covering campaigns, flows, and batch API sends.

# 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](/api-reference/message-suppression).

## 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](/api-reference/optouts).

## Enabling a policy

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/message-suppression/ \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "window_seconds": 604800,
    "channels": ["sms", "whatsapp"],
    "applies_to_categories": ["marketing"]
  }'
```

| Field                   | Notes                                                                                                                                                           |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`               | Defaults to `true` — a bare `PUT` with a window turns suppression on.                                                                                           |
| `window_seconds`        | How long a delivered body is remembered before it's eligible to send again, 1 hour to 90 days.                                                                  |
| `channels`              | Restrict suppression to specific channels. Omit for every channel.                                                                                              |
| `applies_to_categories` | Restrict suppression to specific send categories — for example `["marketing"]` so one-time passcodes and receipts are never held back. Omit for every category. |

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:

```json theme={null}
{ "status": "skipped", "reason": "duplicate_content" }
```

## 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:

```json theme={null}
{ "window_seconds": 604800, "applies_to_categories": ["marketing", "promotional"] }
```

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](/guides/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](/guides/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](/guides/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](/guides/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

* [Message Suppression API reference](/api-reference/message-suppression) — full policy schema
* [Frequency Caps](/guides/frequency-caps) — limiting how many sends a contact receives
* [Delivery Log](/guides/delivery-log) — tracing what happened to a specific message
* [Opt-Outs API](/api-reference/optouts) — per-channel consent and blocking
