Suppressions API reference
The suppression list is your account's do-not-contact list. Every outbound send
checks it first: a send to a suppressed recipient is rejected before delivery with
403 RECIPIENT_SUPPRESSED — see send gates for where this
sits in the decision tree and error codes for the code contract.
ReplyLayer auto-populates the list from delivery signal (hard bounces, spam
complaints, one-click unsubscribes); the operations below let you add and remove
entries yourself.
This page is the per-operation reference. For a task-oriented walkthrough (why and
when to add addresses, SDK/CLI/MCP examples), see the
do-not-contact guide. The full machine-readable
schema for every request and response ships at
/docs/openapi.json.
Each operation is tagged with a classification so you know its side effects at a glance:
| Tag | Meaning |
|---|---|
| read-only | No state change. |
| mutating | Changes suppression state; may emit a webhook. |
None of these operations send email, queue human review, or reveal secrets.
Common fields
Every suppression row carries these fields:
| Field | Type | Notes |
|---|---|---|
email | string | The suppressed entry — an exact address ([email protected]) or a bare-domain pattern (@example.com). Always stored normalized (trim().toLowerCase()). |
reason | enum | One of hard_bounce, complaint, unsubscribe, manual. Customer adds are always manual. |
source | string | Opaque origin string. Customer adds are always "customer". Auto-populated rows carry a system signal string (delivery-event or unsubscribe origin) — treat it as informational, not a value to match on. |
created_at | string | null | ISO 8601 timestamp. null in an add response when the row already existed. |
pattern_type | "email" | "domain" | Derived from email. Absent on legacy clients/rows that predate the field. |
added_by_actor_type | enum | null | admin, agent, user (dashboard session), system, or null for rows that predate first-class actor capture. |
added_by_actor_id | string | null | The API key id (admin/agent), the account id (user), or a system source string (system). Nullable for legacy rows. |
Domain patterns
An entry may be an exact address or a bare-domain pattern (@example.com) that
blocks every address at that domain. Matching is exact-domain only — @example.com
does not match [email protected] (add @sub.example.com separately). Only
customer-initiated adds can create @domain rows; auto-populated bounce, complaint,
and unsubscribe writes are always exact-address. Full match semantics and
validation rules are in the do-not-contact guide.
GET /v1/suppressions
List suppressed addresses for the authenticated account, newest first.
- Classification: read-only
- Auth: any account-scoped API key (admin or agent) or dashboard session.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
reason | enum | — | Filter by hard_bounce, complaint, manual, or unsubscribe. |
limit | integer 1..500 | 500 | Page size. |
cursor | string | — | Opaque base64 cursor from a previous next_cursor; fetches rows older than that cursor. |
all | boolean | — | Bypass the default page cap. Server-capped at 10,000 rows — larger accounts must paginate with cursor. |
Without limit, the response returns up to 500 rows plus a next_cursor. Pass the
returned next_cursor back as cursor to page through older rows; it is null
when the result set is exhausted.
Response 200
{
"suppressions": [
{
"email": "[email protected]",
"reason": "hard_bounce",
"source": "provider_webhook",
"created_at": "2026-04-12T18:30:00.000Z",
"added_by_actor_type": "system",
"added_by_actor_id": "provider-webhook",
"pattern_type": "email",
"latest_complaint_at": null,
"complaint_count": 0
}
],
"next_cursor": "MjAyNi0wNC0xMlQxODozMDowMC4wMDBafDVjZjM..."
}The complaint fields drive removal eligibility (see the DELETE operation below):
| Field | Type | Meaning |
|---|---|---|
latest_complaint_at | string | null | Most recent spam-complaint signal. Non-null is the removal lock — a row with this set returns 409 on DELETE. |
complaint_count | number | Count of distinct provider complaint events. A row locked by sync-only signal can legitimately show complaint_count: 0 while latest_complaint_at is set. |
Errors: 400 INVALID_CURSOR (malformed cursor), 401 UNAUTHORIZED. See
error codes.
POST /v1/suppressions
Add a single address (or domain pattern) to the list. Idempotent.
- Classification: mutating
- Auth: admin or agent API key, or dashboard session. (Agents can add; only admins/sessions can remove.)
The server forces reason: "manual" and source: "customer" — you cannot inject a
system-owned reason. The email is normalized (trim().toLowerCase()) and may be
an exact address or an @domain pattern.
Request
{ "email": "[email protected]" }Response 200 — newly inserted
{
"email": "[email protected]",
"reason": "manual",
"source": "customer",
"created_at": "2026-04-17T18:30:00.000Z",
"already_existed": false,
"added_by_actor_type": "admin",
"added_by_actor_id": "<api-key-id-or-account-id>",
"pattern_type": "email"
}Response 200 — already on the list
{
"email": "[email protected]",
"reason": "manual",
"source": "customer",
"created_at": null,
"already_existed": true,
"added_by_actor_type": "admin",
"added_by_actor_id": "<current-caller>"
}A repeat add is a no-op: already_existed: true, created_at: null, and no
second audit entry or webhook delivery. To save a round-trip the response does not
re-query the original adder — added_by_actor_* reflects the current caller,
not the row's original adder. Fetch the row via GET /v1/suppressions if you need
the true first adder.
On a newly-inserted row the account's webhook subscribers receive a
recipient_blocklist.added event — see the webhook catalog.
Errors: 400 INVALID_EMAIL (fails address/domain validation), 401 UNAUTHORIZED,
429 RATE_LIMITED (see rate limiting). See error codes.
POST /v1/suppressions/bulk
Add many addresses in one request with partial-success reporting.
- Classification: mutating
- Auth: admin or agent API key, or dashboard session. Same reason/source semantics as the single add.
Capped at 1,000 emails per request. The server normalizes and dedupes
(case-variant-aware) the input before validation, so counts.total reflects the
deduped input size, not the raw array length.
Request
{
"emails": [
"[email protected]",
"[email protected]",
" [email protected] ",
"@competitor.com",
"not-an-email"
]
}Response 200 — partial success
{
"added": [
{ "email": "[email protected]", "created_at": "2026-04-17T18:30:00.000Z", "pattern_type": "email" },
{ "email": "[email protected]", "created_at": "2026-04-17T18:30:00.000Z", "pattern_type": "email" },
{ "email": "[email protected]", "created_at": "2026-04-17T18:30:00.000Z", "pattern_type": "email" },
{ "email": "@competitor.com", "created_at": "2026-04-17T18:30:00.000Z", "pattern_type": "domain" }
],
"already_existed": [],
"invalid": [
{ "email": "not-an-email", "reason": "invalid_format" }
],
"counts": { "added": 4, "already_existed": 0, "invalid": 1, "total": 5 }
}added[]— newly-inserted rows (each fires onerecipient_blocklist.addedwebhook).already_existed[]— normalized addresses that were already present (no-op, no webhook).invalid[]— entries that failed validation.reasonis open-ended (invalid_formattoday); new values may be added without breaking the shape.counts.total— the deduped input size.
Errors: 400 VALIDATION_ERROR (more than 1,000 emails, or a malformed element),
401 UNAUTHORIZED, 429 RATE_LIMITED — the rate counter increments by the count of
valid, post-dedupe emails. See error codes.
DELETE /v1/suppressions/:email
Remove an address (or domain pattern) from the list.
- Classification: mutating
- Auth: admin API key or dashboard session only. Agent keys receive
403 INSUFFICIENT_SCOPE— undoing a "stop contacting" signal is a per-human decision, not a per-request one.
The :email path segment is the normalized address; URL-encode it (a domain
pattern's leading @ must be percent-encoded as %40). Removal also clears the
address from the delivery provider's own suppression lists on your sending domains
(a no-op for @domain patterns, which the provider does not track). A successful
removal emits recipient_blocklist.removed — see the webhook catalog.
Response 200
{
"status": "unsuppressed",
"email": "[email protected]",
"reason": "hard_bounce",
"source": "provider_webhook",
"created_at": "2026-04-12T18:30:00.000Z",
"pattern_type": "email"
}Complaint lock (409). A row with spam-complaint history cannot be removed from
this API. If the row's latest_complaint_at is non-null (or its reason is
complaint), the delete is refused with:
{
"code": "RECIPIENT_BLOCKLIST_COMPLAINT_LOCKED",
"error": "This recipient has a spam complaint on record and can't be removed here. Contact support if you think this is a mistake."
}Lifting a complaint lock is intentionally out of band — removing a complained-about recipient risks your domain reputation, so it requires support rather than a routine API call.
Errors: 403 INSUFFICIENT_SCOPE (agent key), 404 NOT_FOUND (no such row for
this account — returned in preference to 409 so cross-account existence never
leaks), 409 RECIPIENT_BLOCKLIST_COMPLAINT_LOCKED. See error codes.
Rate limiting
The add endpoints (single and bulk) share a per-account hourly budget that counts emails added, not requests made — a 1,000-row bulk request consumes 1,000 against the budget. The default budget is 5,000 emails added per hour per account.
When the budget is exhausted, adds return 429 RATE_LIMITED with a Retry-After
header and X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset:
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 } }A retried add after a 429 won't double-insert — the add endpoints are safe to
retry once the window resets.
Idempotency and normalization
- Every
emailis normalized (trim().toLowerCase()) before storage, so[email protected]and[email protected]are the same row. - A repeat single add returns
already_existed: truewithcreated_at: null— no second audit entry, no second webhook. Concurrent adds of the same address resolve to a single row. - Bulk add applies the same normalization, then dedupes the input array before
validation;
counts.totalis the deduped size.