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 itNotes
Add (single + bulk)Agent + admin keys, dashboardServer forces reason='manual' + source='customer'. Idempotent.
List (paginated)Any account-scoped key, dashboardDefault 500 rows + cursor; ?all=true capped at 10,000.
RemoveAdmin keys + dashboard only (agent keys → 403)Agents can add; only humans can lift the block.
Webhookrecipient_blocklist.added + recipient_blocklist.removedAddress 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)

ValueSourceCustomer-removable?
hard_bounceDelivery-provider bounce eventAdmin-only (deliverability footgun)
complaintDelivery-provider spam-complaint eventAdmin-only (same)
unsubscribeRFC 8058 one-click + mailto: replyAdmin-only
manualCustomer add (this surface) or adminAdmin-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_typeadded_by_actor_idMeaning
adminAPI key idAdded via an admin-role API key
agentAPI key idAdded via a mailbox-scoped agent key
useraccount idAdded via the dashboard (session auth)
systemsource string (e.g. provider-webhook, mailto-unsubscribe, one-click-https)Added by ReplyLayer in response to a delivery event or unsubscribe
nullnullLegacy 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 → 403

MCP (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):

EntryBlocksDoes NOT block
[email protected][email protected] onlyeveryone else
@competitor.comany *@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 admin

Validation 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: true with created_at: null. No second audit row, no second webhook delivery. Concurrent adds of the same address resolve safely to a single row.
  • addBulk applies the same normalization, then dedupes the input array before validation. counts.total is 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.