Suppression
The suppression list is a hard block on sending. An address on it receives nothing from your workspace, regardless of lists, segments, or sequences.
It is deliberately separate from a contact’s subscription state: unsubscribing is a preference attached to a contact record, while suppression is a bare address that we refuse to send to even if no contact exists for it. Use suppression for compliance obligations and hard bounces; use subscriptions for “this person doesn’t want the newsletter”.
Required scopes#
| Endpoint | Scope |
|---|---|
GET /api/suppressions | suppression:read |
POST /api/suppressions | suppression:write |
DELETE /api/suppressions | suppression:write |
POST /api/suppressions/bulk | suppression:write |
suppression:write implies suppression:read.
List suppressed addresses#
GET /t/{tenant_slug}/api/suppressions
X-API-Key: sonar_xxxxx
[
{
"tenant_id": "…",
"email": "blocked@example.com",
"reason": "hard_bounce",
"created_at": "2026-07-01T12:00:00.000Z"
}
]
Returns the whole list — there is no pagination on this endpoint today. If yours is large enough for that to matter, tell us.
Suppress an address#
curl -X POST "https://app.example.com/t/acme/api/suppressions" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"email":"blocked@example.com","reason":"complaint"}'
Returns 201 with the created row. reason is free text and optional; it is
for your own auditing, and nothing branches on its value.
The list is keyed on (tenant, email), so re-suppressing an address already on
the list is harmless — it upserts rather than erroring. Note that it replaces
the stored reason, so re-suppressing without one clears the previous value.
Addresses are lowercased and trimmed on the way in, and on lookup. You do not
need to normalize case yourself, and Blocked@Example.com will match a stored
blocked@example.com.
Un-suppress an address#
curl -X DELETE "https://app.example.com/t/acme/api/suppressions?email=blocked@example.com" \
-H "X-API-Key: $API_KEY"
Returns 204. The address goes in the query string, not a path segment,
because it contains @ and dots. Remember to URL-encode it.
Idempotent: removing an address that is not on the list is also a 204. Case is
normalized here too.
Removing a suppression does not resubscribe anyone. It only lifts the block. If the contact is also unsubscribed, they stay unsubscribed — that is a separate piece of state, and lifting a compliance block should never silently opt someone back in.
Suppress many contacts at once#
curl -X POST "https://app.example.com/t/acme/api/suppressions/bulk" \
-H "X-API-Key: $API_KEY" \
-H 'Content-Type: application/json' \
-d '{"contact_ids":["…","…"],"reason":"sunset"}'
{ "suppressed": 2 }
Takes contact IDs, not addresses — it is the “act on a selection of
contacts” path, matching the sunset-candidates flow in the app. For each
contact it adds the address to the suppression list and flips the contact to
unsubscribed.
Limits: 1–500 contact IDs per call. Over 500 returns 400 TOO_MANY; batch
larger jobs client-side.
reason defaults to "sunset" here, unlike the single-address endpoint where
it is simply null.
Choosing the right endpoint#
| You have | Use |
|---|---|
| An email address, no contact record | POST /api/suppressions |
| A contact who asked to be removed entirely | POST /api/suppressions/bulk |
| A contact who wants fewer emails, not none | Subscriptions on the contact — see contacts.md |
Errors#
| Code | Status | Meaning |
|---|---|---|
TOO_MANY | 400 | More than 500 contact_ids in one bulk call. |
INSUFFICIENT_SCOPE | 403 | The key is missing suppression:write. |
See errors.md for the full envelope.