# 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](/agents/send-gates) for where this
sits in the decision tree and [error codes](/agents/errors) 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](/docs/guides/suppressions). The full machine-readable
schema for every request and response ships at
[`/docs/openapi.json`](/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 (`alice@example.com`) 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 `eve@sub.example.com` (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](/docs/guides/suppressions).

---

## 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`**

```json
{
  "suppressions": [
    {
      "email": "bounced@example.com",
      "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](/agents/errors).

---

## 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**

```json
{ "email": "stop-emailing-me@example.com" }
```

**Response `200` — newly inserted**

```json
{
  "email": "stop-emailing-me@example.com",
  "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**

```json
{
  "email": "stop-emailing-me@example.com",
  "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](/agents/webhooks).

**Errors:** `400 INVALID_EMAIL` (fails address/domain validation), `401 UNAUTHORIZED`,
`429 RATE_LIMITED` (see [rate limiting](#rate-limiting)). See [error codes](/agents/errors).

---

## 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**

```json
{
  "emails": [
    "one@example.com",
    "Two@Example.COM",
    "  three@example.com  ",
    "@competitor.com",
    "not-an-email"
  ]
}
```

**Response `200` — partial success**

```json
{
  "added": [
    { "email": "one@example.com",   "created_at": "2026-04-17T18:30:00.000Z", "pattern_type": "email" },
    { "email": "two@example.com",   "created_at": "2026-04-17T18:30:00.000Z", "pattern_type": "email" },
    { "email": "three@example.com", "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](/agents/errors).

---

## 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](/agents/webhooks).

**Response `200`**

```json
{
  "status": "unsuppressed",
  "email": "bounced@example.com",
  "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:

```json
{
  "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](/agents/errors).

---

## 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
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
  `Foo@Bar.com` and `foo@bar.com` 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.
