Skip to main content

Message Suppression API

Configure content-hash duplicate suppression: a send is silently skipped when the same message body already reached the same contact on the same channel inside a window you choose. This is the “two different campaigns fired the identical promo back-to-back” guard — distinct from frequency caps (which count send firings per contact) and opt-outs (which block a channel entirely). Enforcement happens inside the message send pipeline, before frequency capping, so a duplicate never burns a cap slot. A suppressed send reports as skipped (reason duplicate_content) rather than an error, so campaigns and batches keep moving. Base path: /api/v1/message-suppression Authentication: API key (X-API-Key) or session JWT. Reads require any role; the upsert and delete writes require owner, admin, or developer. Node uses the typed orbit.messageSuppression.* helpers; Python reaches the endpoints through the generic client.request() escape hatch; Go, Ruby, and PHP use plain HTTP clients (no SDK dependency). Each operation carries the full sample group.

Get the policy

Returns the org’s single policy, or null when none has ever been configured.
200 OK — no policy configured When suppression has never been turned on for the organization, data is null (not a 404 — the endpoint always exists).
200 OK — policy set Once a PUT has landed, GET returns the same shape the upsert echoes back.

Create or update the policy

A PUT upserts the single policy: send the full body each time. A bare PUT with only window_seconds turns the policy on (enabled defaults to true).

Delete the policy

Turns suppression off. The operation is idempotent — the endpoint returns 204 No Content whether or not a policy existed, so a cleanup pass can safely re-run.
204 No Content — the policy is gone and the response body is empty, on the first DELETE and on every repeat. A follow-up GET returns the data: null envelope above. The Python SDK is core-scope — it wraps the 8 core resources (messaging, voice, contacts, campaigns, verify, numbers) and reaches everything else through the generic client.request() escape hatch above. See the Python SDK. Node returns the typed ApiResponse envelope. See the SDK index at SDK quickstart.

Policy shape

A single opt-in policy per organization.

Example — dedupe marketing sends within 7 days

200 OK — the configured policy (consistent with GET)
Restricting applies_to_categories to marketing means OTP and transactional resends are never suppressed. Omit channels (or send null) to cover every messaging channel.
Suppression fails open: if no policy applies, or the backing store is briefly unavailable, the send is dispatched normally — a transient blip never silently swallows outbound traffic. Bodies are compared by SHA-256 of the whitespace-normalized text, so cosmetically-identical copy (reflowed spacing) collapses to the same marker while different case or wording does not.

Recipes — cookbook-style

Recipe 1: same body to the same recipient, twice inside the window

A marketing campaign fires; a rerun from a different campaign name, journey step, or operator action replays the same body. Suppression catches the second send before it consumes a slot.
200 OK — first send accepted
Re-send the same payload within the 7-day window and the pipeline returns a soft skip rather than a hard error — the batch keeps moving.
Your campaign batch exporter, webhook workers, and journey enqueues see status: "skipped" with either reason: "duplicate_content" (suppression) or reason: "frequency_capped" (inter-campaign cap skip, campaign sending) and can drop the row from the “true delivery” count.

Recipe 2: any channel blocked by opt-in (still respected)

Suppression is parallel to the opt-out gate, not instead of it. A duplicate_content skip only happens when the contact is otherwise eligible; if they’ve opted out of the channel, the compliance gate blocks the send outright (no skipped reason for compliance — the send is rejected at the front).
400 — opted out (suppression never runs if the opt-out gate rejects)
Restricting applies_to_categories: ["marketing"] in your PUT body keeps transactional re-sends (password resets, receipts, OTPs) outside suppression’s scope while marketing traffic is still deduped.

Recipe 3: bulk on-call import + poll-and-verify

For bulk suppression-file imports (an on-call policy download that should become organization-wide rule), do the write through POST /api/v1/contacts/optouts/bulk — suppression’s PUT is for dedupe policy; opt-outs are the per-contact block list. Pair it with a poll loop on the async contact importer when the row count exceeds a few hundred.
Verify each row landed by polling GET /api/v1/contacts/imports/{id} (for async-import flows) or GET /api/v1/contacts/{id}/channel-state (synchronous verify) — sms.opted_out should be true on the response. Suppress-only-bulk has no separate endpoint; the policy is a single org-level object set via PUT. Use POST /contacts/optouts/bulk when the intent is “suppress this cohort on channel X” (per-contact block) and PUT /api/v1/message-suppression/ when the intent is “dedupe identical bodies within a window for everyone” (org policy). See the Opt-Outs API for the CSV import wizard endpoints.

Classify a message

POST /api/v1/messages/classify scores a message body against your organization’s registered classification model and returns a probability for every label. It sends nothing and writes nothing to the contact: it is a read-only scoring call you make before POST /messages/send, so suppression and frequency caps see one consistent category on the send. Auth scope: messages:read. Rate limit: enforced per API key; a sustained burst returns 429 with a Retry-After header (envelope below).

Built-in vs custom classification

The built-in model ships a fixed 22-label taxonomy (the array in the 200 response below), registered per tenant: every organization scores against its own registered model instance while the label set itself stays platform-stable. Custom classification is what you compose on top of it: pass channel_hint and locale_hint to condition the scores on the send you are about to make, then map top_label onto your own category set in your code. The endpoint only returns scores; it never stores your mapping.

Request body

No SDK ships a typed classification helper, so the snippets above use each language’s generic request escape hatch (the SDK language policy keeps that escape hatch open exactly for endpoints like this one). The Python client.request() call is the same escape hatch the suppression samples use. 200 OK — the full 22-label score array labels always carries all 22 labels, sorted by probability descending, so your code can apply its own threshold; top_label is labels[0].label.

Failed classification

422 — body too long
413 — payload too large
429 — slow down

Worked example — classify, then suppress, then send

Classify first, gate second: score the body, stamp the winning label onto the send as its category, and let the org’s suppression and cap policies act on that category. With the suppression policy from the example above (applies_to_categories: ["marketing"]), a body that scores as verification sails through both gates; a re-run promo that scores as marketing is deduped by suppression and counted by the caps.
The same classify-first pattern composes with frequency caps: evaluate the label before you apply a cap rule, cap marketing, and leave verification and delivery uncapped. An OTP that rides a capped category by mistake is the exact failure this ordering prevents.

Errors

See also