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:

TagMeaning
read-onlyNo state change.
mutatingChanges 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:

FieldTypeNotes
emailstringThe suppressed entry — an exact address ([email protected]) or a bare-domain pattern (@example.com). Always stored normalized (trim().toLowerCase()).
reasonenumOne of hard_bounce, complaint, unsubscribe, manual. Customer adds are always manual.
sourcestringOpaque 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_atstring | nullISO 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_typeenum | nulladmin, agent, user (dashboard session), system, or null for rows that predate first-class actor capture.
added_by_actor_idstring | nullThe 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

ParamTypeDefaultNotes
reasonenumFilter by hard_bounce, complaint, manual, or unsubscribe.
limitinteger 1..500500Page size.
cursorstringOpaque base64 cursor from a previous next_cursor; fetches rows older than that cursor.
allbooleanBypass 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):

FieldTypeMeaning
latest_complaint_atstring | nullMost recent spam-complaint signal. Non-null is the removal lock — a row with this set returns 409 on DELETE.
complaint_countnumberCount 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 one recipient_blocklist.added webhook).
  • already_existed[] — normalized addresses that were already present (no-op, no webhook).
  • invalid[] — entries that failed validation. reason is open-ended (invalid_format today); 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 email is normalized (trim().toLowerCase()) before storage, so [email protected] and [email protected] are the same row.
  • A repeat single add returns already_existed: true with created_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.total is the deduped size.