Per-tenant suppression list
In platform mode, each tenant has its own suppression list. A bounce under tenant A doesn't block tenant B from contacting the same address.
How it works
The suppressions table has nullable tenantId:
tenantId = null— org-wide suppression. Affects every tenant under this org.tenantId = "tnt_X"— tenant-scoped suppression. Affects only sends from keys bound to tenant X.
On each send, Sendoka checks the address against the union: (tenantId = null OR tenantId = <sender's tenant>). If any row matches, the send is blocked (or filtered in batch/audience).
Adding a suppression — tenant-bound key
Keys bound to tnt_X can only write tenant-scoped suppressions:
curl -X POST https://www.sendoka.com/api/v1/suppressions \
-H "Authorization: Bearer sok_live_TENANT_X_KEY" \
-H "Content-Type: application/json" \
-d '{
"address": "user@example.com",
"channel": "email",
"reason": "manual"
}'
The row is inserted with tenantId = "tnt_X".
Adding a suppression — platform-root key
Platform-root keys can choose. Omit tenant_id for org-wide, or pass a tenant id:
# Org-wide (every tenant sees it)
curl -X POST .../suppressions \
-H "Authorization: Bearer sok_live_ROOT_KEY" \
-d '{ "address": "spam@example.com", "channel": "email", "reason": "manual" }'
# Tenant-scoped (only tenant X sees it)
curl -X POST .../suppressions \
-H "Authorization: Bearer sok_live_ROOT_KEY" \
-d '{ "address": "a@example.com", "channel": "email", "reason": "manual", "tenant_id": "tnt_X" }'
reason decides more than the audit trail. stop and complaint create a row
that can never be deleted — see Removing — so a row you POST
under either reason is permanent, by any surface, forever. Use manual for a
suppression you may want to lift later.
Listing
Tenant-bound keys see only their tenant's + null-scoped rows. Root keys see all.
curl 'https://www.sendoka.com/api/v1/suppressions?channel=email&limit=100' \
-H "Authorization: Bearer $KEY"
Response rows include tenant_id so you can tell the scope.
Auto-suppression from bounces
When SES reports a bounce or complaint:
- Sendoka looks up the original message.
- Inserts a suppression with that message's
tenantId(null if platform-root send, tenant id if tenant send). - So a bounce under tenant A doesn't block tenant B's subsequent send to the same address.
This is the isolation behavior most platform customers expect. If you want global suppression (one bounce kills the address across all tenants), override by calling POST /api/v1/suppressions with a root key and tenant_id omitted.
Removing
Same scoping. Tenant-bound keys can only delete rows with their tenantId.
curl -X DELETE ".../suppressions?channel=email&value=user%40example.com" \
-H "Authorization: Bearer $KEY"
The target can also go in a JSON body — the same { channel, value } shape
POST takes — which is what most platform integrations already send:
curl -X DELETE ".../suppressions" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{ "channel": "email", "value": "user@example.com" }'
A field missing from the query string is read from the body. Send each field
in one place; if you do send one in both, the two must match (value
case-insensitively) or the call answers 422 QUERY_BODY_CONFLICT and removes
nothing.
Only bounce, manual and unsubscribe rows can be removed. A row with
reason: "stop" (the recipient texted STOP) or reason: "complaint" (the
recipient reported spam) is refused with 409 SUPPRESSION_PROTECTED — by the
API, the dashboard and the MCP tool alike. Those are the recipient's decision;
only a fresh opt-in from them lifts the block, and nothing you call does.
Gotchas
- Tenants share the underlying SES bounce rate. Isolation protects your customer's lists, not your AWS account. One customer with a bad list still threatens your SES reputation.
- Complaints and STOPs stay suppressed forever. Deleting them is refused (
SUPPRESSION_PROTECTED), so don't build a retry around it — the recipient has to opt in again. - The
channelcolumn is indexed. Filter aggressively when listing.