Do-not-contact list (suppressions)
The suppression list is the gate every send goes through. If a recipient is on it,
the API returns 403 RECIPIENT_SUPPRESSED (with details.reason: 'suppressed')
before any message is sent. ReplyLayer auto-populates it from delivery signal
(hard bounces, spam complaints, RFC 8058 unsubscribes). You can also add and
remove entries yourself via the API, CLI, SDKs, MCP, or the dashboard.
At a glance
| Who can do it | Notes | |
|---|---|---|
| Add (single + bulk) | Agent + admin keys, dashboard | Server forces reason='manual' + source='customer'. Idempotent. |
| List (paginated) | Any account-scoped key, dashboard | Default 500 rows + cursor; ?all=true capped at 10,000. |
| Remove | Admin keys + dashboard only (agent keys → 403) | Agents can add; only humans can lift the block. |
| Webhook | recipient_blocklist.added + recipient_blocklist.removed | Address is the operator signal — payloads are exempt from PII redaction. |
Rate limit on the add endpoints: 5,000 emails added per hour per account. It counts emails added, not requests made — a 1,000-row bulk request consumes 1,000 against the budget.
Why a customer would add an address
- A user told you "stop emailing me" outside the unsubscribe-link path. Block them so even an LLM agent that re-attempts can't slip a follow-up through.
- Legal / GDPR / CCPA right-to-erasure: bulk-import affected addresses (up to 1,000 per request) so they're rejected at send time across every code path.
- Migrating from another email platform: import your existing suppression list before you point traffic at ReplyLayer.
- Test recipients you no longer want hit during development: block them so a stale config can't accidentally email them.
Reasons (the reason enum)
| Value | Source | Customer-removable? |
|---|---|---|
hard_bounce | Delivery-provider bounce event | Admin-only (deliverability footgun) |
complaint | Delivery-provider spam-complaint event | Admin-only (same) |
unsubscribe | RFC 8058 one-click + mailto: reply | Admin-only |
manual | Customer add (this surface) or admin | Admin-removable |
Customers who add via POST /v1/suppressions always get reason='manual' +
source='customer'. The API rejects requests that try to inject any other
reason.
Reading the actor fields (added_by_actor_type, added_by_actor_id)
Every row carries first-class actor metadata so you can answer "who blocked this?" directly from the read response:
added_by_actor_type | added_by_actor_id | Meaning |
|---|---|---|
admin | API key id | Added via an admin-role API key |
agent | API key id | Added via a mailbox-scoped agent key |
user | account id | Added via the dashboard (session auth) |
system | source string (e.g. provider-webhook, mailto-unsubscribe, one-click-https) | Added by ReplyLayer in response to a delivery event or unsubscribe |
null | null | Legacy row that predates first-class actor capture |
Quickstart
TypeScript
import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });
// Single add — idempotent
const res = await rl.suppressions.add({ email: '[email protected]' });
if (res.already_existed) {
console.log(`${res.email} was already on the list`);
} else {
console.log(`Added ${res.email} (added_by ${res.added_by_actor_type})`);
}
// Bulk add (up to 1000 per request)
const bulk = await rl.suppressions.addBulk({
emails: ['[email protected]', '[email protected]', 'not-an-email'],
});
console.log(`Added ${bulk.counts.added}, already-on-list ${bulk.counts.already_existed}, invalid ${bulk.counts.invalid}`);
for (const bad of bulk.invalid) {
console.warn(` ${bad.email}: ${bad.reason}`);
}
// List (paginated; the default cap is 500 — use `all: true` up to 10 000)
const page = await rl.suppressions.list({ limit: 100 });
for (const row of page.suppressions) {
console.log(row.email, row.reason, row.added_by_actor_type ?? 'legacy');
}
if (page.next_cursor) {
const next = await rl.suppressions.list({ limit: 100, cursor: page.next_cursor });
// ...
}Python
from replylayer import ReplyLayer
rl = ReplyLayer(api_key=os.environ["REPLYLAYER_API_KEY"])
res = rl.suppressions.add(email="[email protected]")
print(f"already_existed={res['already_existed']}")
bulk = rl.suppressions.add_bulk(emails=["[email protected]", "[email protected]", "not-an-email"])
print(f"added={bulk['counts']['added']} invalid={bulk['counts']['invalid']}")
page = rl.suppressions.list(limit=100)
while True:
for row in page["suppressions"]:
print(row["email"], row["reason"], row.get("added_by_actor_type") or "legacy")
if not page["next_cursor"]:
break
page = rl.suppressions.list(limit=100, cursor=page["next_cursor"])CLI
rly suppressions list # default 100/page
rly suppressions list --reason manual # filter
rly suppressions list --all # paginate everything
rly suppressions add [email protected] # idempotent
rly suppressions remove [email protected] # admin-only; agent keys → 403MCP (for agents)
Two tools are exposed: list_suppressions (for "before I send, am I sure this
isn't blocked?") and add_suppression (for "the user asked me to stop").
Removal is intentionally not exposed via MCP regardless of key scope — undoing
a "stop contacting" decision is a per-human call. Operators run the CLI/SDK or use
the dashboard with an admin key.
Domain entries
Suppression entries can be either exact emails ([email protected]) or
bare-domain patterns (@competitor.com) that block every address at that domain.
Mix both forms freely — the pre-send gate checks both in a single query.
Match semantics (exact-domain only, no wildcards):
| Entry | Blocks | Does NOT block |
|---|---|---|
[email protected] | [email protected] only | everyone else |
@competitor.com | any *@competitor.com | [email protected] — add @sub.competitor.com explicitly |
Response shapes: every suppression read/write surface exposes a derived
pattern_type field — "email" or "domain". Older clients that predate the
field will see it absent; the SDK types treat it as optional.
// TS SDK — block a whole domain.
await rl.suppressions.add({ email: '@competitor.com' });
// Bulk mix.
const bulk = await rl.suppressions.addBulk({
emails: ['[email protected]', '@spam.example', 'not-an-email'],
});
// bulk.added[0].pattern_type === 'email'
// bulk.added[1].pattern_type === 'domain'
// bulk.invalid[0] === { email: 'not-an-email', reason: 'invalid_format' }# Python SDK.
res = rl.suppressions.add(email="@competitor.com")
assert res["pattern_type"] == "domain"# CLI — add command accepts either form; list output shows a TYPE column.
rly suppressions add @competitor.com
rly suppressions list
# ENTRY TYPE REASON SOURCE ADDED ADDED BY
# [email protected] email manual customer 2026-04-19T18:00:00Z admin
# @competitor.com domain manual customer 2026-04-19T19:02:00Z adminValidation rules (shared with the allowlist parser):
- Trim + lowercase before validation.
- Domain patterns must be multi-label ASCII:
@corp.com,@my-company.io,@sub.example.co.uk✓ - Rejected:
@,@.com,@foo,@foo.,@.foo.com,@foo..com,@-corp.com,@_foo.com, any non-ASCII. - Length caps: total domain ≤ 253 chars; each label ≤ 63 chars.
Domain-level blocks are a ReplyLayer construct. Delivery providers only track
exact addresses, so deleting a @domain.com entry always removes cleanly without
provider round-trips.
Webhook subscribers: the address field in recipient_blocklist.added /
recipient_blocklist.removed events may carry @domain.com strings. Handle both
forms.
Provider-driven writes remain exact-email-only: hard-bounce + complaint events
and one-click/mailto unsubscribes always write the exact bouncing/complaining
address — they never produce @domain.com rows. Only customer-initiated adds
(POST routes, CLI, SDK, MCP, dashboard) can create domain patterns.
Idempotency, normalization, and case-variant dedupe
- The server normalizes every email:
trim()+toLowerCase().[email protected]and[email protected]are the same row. - A repeat add of the same email returns
already_existed: truewithcreated_at: null. No second audit row, no second webhook delivery. Concurrent adds of the same address resolve safely to a single row. addBulkapplies the same normalization, then dedupes the input array before validation.counts.totalis the deduped size, not the raw input length.
Rate limiting
The add endpoints share a per-account hourly budget of 5,000 emails added. When tripped:
HTTP/1.1 429 Too Many Requests
Retry-After: 2734
X-RateLimit-Limit: 5000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1745000000
{ "error": "Too many requests", "code": "RATE_LIMITED",
"details": { "retry_after": 2734 } }Both SDKs honor Retry-After up to ~67 minutes so a client can ride out an hour
bucket instead of throwing. Mutating retries on the add endpoints are still gated
to "server rejected, nothing happened" semantics — you won't get duplicate inserts
from a retried 429.
Webhook events
recipient_blocklist.added and recipient_blocklist.removed fire on the
corresponding mutations. Payload shape:
{
"id": "<event-uuid>",
"event": "recipient_blocklist.added",
"account_id": "<account-uuid>",
"occurred_at": "2026-04-17T18:30:00.000Z",
"data": {
"address": "[email protected]",
"reason": "manual",
"added_at": "2026-04-17T18:30:00.000Z"
}
}recipient_blocklist.removed payloads use removed_at instead of added_at. Both
events are exempt from delivery-time PII redaction even when the mailbox has
pii_mode='redacted' — the address is the operator signal, and redacting it would
defeat the event's purpose.
Troubleshooting
"This recipient cannot receive email" / 403 RECIPIENT_SUPPRESSED on send
The recipient is on the suppression list. Run
rly suppressions list --reason hard_bounce (or --reason complaint) to confirm,
and check the added_by_actor_* columns to understand why. Manually-added entries
can be removed by an admin; bounce/complaint rows should generally stay (lifting
them risks domain-reputation damage).
429 RATE_LIMITED on bulk add
You hit the entry-count budget for the hour. Wait the Retry-After window. If you
have a one-time import bigger than the bucket, contact support to raise the limit
for your account.
Agent key got INSUFFICIENT_SCOPE on remove
By design — only admin keys (and dashboard sessions) can remove suppressions. The
CLI surfaces this with a hint pointing at the right key.