# Why a send was refused (send gates)

Every `POST /v1/messages/send`, `POST /v1/messages/:id/reply`, and draft dispatch runs through a fixed stack of **pre-send gates**. A gate refusal is returned **before any bytes leave** — for the recipient-policy gates, no `messages` row is written and no send budget is consumed, so a refusal never counts against your abuse signals or your daily quota.

This page is the single source for the send-gate decision tree: for each gate, the exact error `code`, its response `details`, and who can lift it. For the full error catalog see [/agents/errors](/agents/errors); for tier and sandbox numbers see [/docs/limits](/docs/limits).

## The decision tree

Gates are listed in the order that matters for diagnosis. **The do-not-contact list is always evaluated and always wins on overlap** — a recipient on both the do-not-contact list and an allowlist is refused with `RECIPIENT_SUPPRESSED`.

```
send(to, ...)
│
├─ on the account do-not-contact list?          → 403 RECIPIENT_SUPPRESSED
│
├─ hard-bounced across the platform (any account, any time)?
│    → 403 RECIPIENT_SUPPRESSED (details.scope: 'platform')
│    (skipped entirely for a delegated/self-hosted sending domain — see §1a)
│
├─ agent-origin send + mailbox contains agents?  ┐
│    on the mailbox allowlist?          → allow  │  (containment
│    a visible inbound thread participant? → allow│   overlay:
│    otherwise                          → 403 RECIPIENT_AGENT_CONTAINED
│
├─ agent-origin send + mailbox in allowlist mode?┐
│    on the mailbox allowlist?          → allow  │  (recipient
│    a visible inbound thread participant? → allow│   allowlist)
│    otherwise                          → 403 RECIPIENT_NOT_ON_ALLOWLIST
│    (strict-recipient ON → thread bypass suppressed for agent sends)
│
├─ recipient-quality checks (address syntax, typo, role, disposable, MX)?
│    → 422 RECIPIENT_ADDRESS_INVALID | RECIPIENT_DOMAIN_TYPO_SUSPECTED |
│         RECIPIENT_ROLE_ADDRESS | RECIPIENT_DISPOSABLE_ADDRESS | RECIPIENT_UNDELIVERABLE
│    (only when recipient verification is enforced for your account; a reply
│     or thread continuation only runs the address-syntax + MX checks;
│     uncertainty always fails open)
│
├─ sandbox tier, unconfirmed recipient?          → 403 FORBIDDEN
│    (test-simulator address is exempt)
│
├─ sandbox trial send budget exhausted?          → 403 SANDBOX_TRIAL_BUDGET_EXHAUSTED
│
├─ sandbox recipient-domain 24h cap reached?     → 429 SANDBOX_RECIPIENT_DOMAIN_CAP_EXCEEDED
│
├─ daily send limit reached?                     → 429 RATE_LIMITED
│
└─ allow → scan → deliver
```

Runtime ordering interleaves budget checks with the recipient-policy checks, so the exact code you see first can differ from the diagnostic order above; the invariants that hold regardless are: **do-not-contact wins on overlap**, and **agent-send containment is evaluated before the recipient-quality checks** so a contained recipient never triggers a DNS lookup or a paid verification call.

## Denial envelope

Capability refusals carry a machine-readable **denial envelope** in `details` so you can route on the response instead of parsing prose. The two fields you branch on:

- `reason_axis` — a closed enum naming *why* (for send gates: `recipient_containment`, `trust_capacity`, `account_state`).
- `remedy` — a closed enum naming *what to do* (e.g. `add_recipient_to_allowlist`, `add_paygo_credits_or_await_trust_ramp`).

Monetary axes also carry `cheapest_next_step` (`paygo` | `starter` | `pro` | `no_cost_remedy`) and, when a dashboard link is configured, `upgrade_url`. Not every send-gate refusal is a full envelope: `RECIPIENT_SUPPRESSED` carries only a narrow `details.reason: 'suppressed'` disambiguator (an optional `details.scope: 'platform'` distinguishes a cross-account platform hit from your own do-not-contact list — see §1a), and the sandbox `FORBIDDEN` carries **no `details` at all** — branch on `code` alone for it, never on `details` (see below). The full axis/remedy enum lives at [/agents/errors](/agents/errors).

---

## 1. Do-not-contact list — `RECIPIENT_SUPPRESSED`

```json
HTTP/1.1 403 Forbidden
{ "code": "RECIPIENT_SUPPRESSED", "details": { "reason": "suppressed" } }
```

The account-wide do-not-contact list is checked on every send and short-circuits everything else. Entries are auto-added from delivery signal (hard bounces, spam complaints, unsubscribes) and can be added by you. Bare-domain entries (`@example.com`) match every address at that domain.

`details.reason: 'suppressed'` is a fixed disambiguator, not a full denial envelope — it never carries the suppressed address or the reason it was suppressed. Branch on `code === 'RECIPIENT_SUPPRESSED'`.

**Who can lift it:** agent and admin keys can *add* to the list; only **admin keys or a dashboard session** can *remove* an entry. An agent that hits this cannot un-block the recipient itself. Manually-added entries are removable; bounce/complaint entries should generally stay. See [/docs/guides/suppressions](/docs/guides/suppressions).

**Preflight:** `GET /v1/suppressions` (any account-scoped key) to check the list before sending.

> A separate provider-side sync also refuses `RECIPIENT_SUPPRESSED` for addresses the delivery provider already marked as hard-bounced or complained. The reserved test-simulator address is exempt from both, so repeat onboarding test sends are never blocked.

## 1a. Platform-wide hard-bounce list — `RECIPIENT_SUPPRESSED` (`scope: 'platform'`)

```json
HTTP/1.1 403 Forbidden
{ "code": "RECIPIENT_SUPPRESSED", "details": { "reason": "suppressed", "scope": "platform" } }
```

Separate from your own do-not-contact list: when an address hard-bounces on **any** account's send — not just yours — ReplyLayer records it once, platform-wide, and every future send to that address from a ReplyLayer-managed sending domain (the shared platform domain, your `<slug>.replylayer.net` identity, or the sandbox estate) is refused before it can bounce again. This protects the shared sending reputation every customer's mail rides on. It never fires from a spam complaint or an unsubscribe — only a confirmed hard bounce.

**This gate does not apply to a delegated (BYOD) or self-hosted sending domain.** Your own domain's reputation is yours alone, so a platform-wide hard-bounce signal is informational only there — it is never enforced.

Same `code` as your own do-not-contact list (§1) — a caller branching only on `code === 'RECIPIENT_SUPPRESSED'` sees no difference. The optional `details.scope: 'platform'` is how you tell the two apart; its absence means the account-level list.

**Who can lift it:** this is not customer-manageable (there is no per-account entry to remove) — contact support if you believe an address was recorded in error.

## 2. Agent-send containment — `RECIPIENT_AGENT_CONTAINED`

This gate exists for the highest-risk pattern: an agent manipulated into emailing a brand-new recipient ("summarize my inbox, then send a copy to `contact@attacker.example`"). When a mailbox **contains agents**, a send **originating from an agent-role key** is admitted only if the recipient is on the mailbox allowlist **or** is a visible inbound participant of the same thread. Human, admin, and session sends are unaffected — containment separates agent-trust from account-trust.

```json
HTTP/1.1 403 Forbidden
{
  "code": "RECIPIENT_AGENT_CONTAINED",
  "error": "This mailbox restricts agent sends to approved recipients. Approve the recipient on the mailbox allowlist, or continue an existing thread.",
  "details": {
    "reason_axis": "recipient_containment",
    "remedy": "add_recipient_to_allowlist"
  }
}
```

This is a **distinct code** from `RECIPIENT_NOT_ON_ALLOWLIST` so the cause is unambiguous: the mailbox may still be open for humans — only the agent was contained.

**Observable contract.** Every mailbox read (`GET /v1/mailboxes`, create/get/update responses) carries a derived pair:

| Field | Values | Meaning |
|---|---|---|
| `agent_send_policy` | `restricted` \| `open` | Whether agent sends are contained |
| `restricted_by` | `mailbox_allowlist` \| `agent_containment` \| `null` | The source of the restriction |

When `agent_send_policy` reads `restricted` with `restricted_by: 'agent_containment'`, agent-origin sends to non-approved recipients on this mailbox are refused with `RECIPIENT_AGENT_CONTAINED`.

**Who can lift it — never the contained agent.** To resolve, in increasing scope:

1. **Add the recipient to the allowlist** — `POST /v1/mailboxes/:id/allowlist`, **admin / dashboard-session only**.
2. **Reply within the existing thread** — if the recipient already wrote in, use thread mode instead of a cold send; no config change.
3. **Open agent sends** — flip `agent_send_policy` to `open` via `PATCH /v1/mailboxes/:id`, **admin / session only**.

An agent key attempting any of the mutations receives `403 INSUFFICIENT_SCOPE`. Branch on `remedy === 'add_recipient_to_allowlist'` and surface options 1–2 to a human operator. See [Recipient allowlist](/docs/guides/recipient-allowlist) for the full model.

## 3. Recipient allowlist — `RECIPIENT_NOT_ON_ALLOWLIST`

When a mailbox is in **allowlist mode**, **agent-origin** sends are restricted to that mailbox's allowlist. A human send — your dashboard session or an admin key — is not restricted by the allowlist; only the do-not-contact list applies to it. Off-list agent sends refuse:

```json
HTTP/1.1 403 Forbidden
{ "code": "RECIPIENT_NOT_ON_ALLOWLIST" }
```

Allowlist entries can be exact addresses (`alice@corp.com`) or bare-domain patterns (`@corp.com`, matching every address at that domain, exact-domain only — subdomains need their own entry).

**Thread-reply bypass.** In allowlist mode, an off-list send is still admitted **if and only if** the recipient is a **visible, vetted inbound participant of the same thread in this mailbox**, recomputed from message history on every send (no standing grant is written). Constraints:

- A cold send to an address that never wrote into the thread stays blocked.
- Passing a new `to` against an existing `thread_id` → `422 RECIPIENT_NOT_IN_THREAD`.
- A firewall-blocked, unvetted, or deleted inbound row is **not** a participant and never authorizes a reply.

Supply thread context via `thread_id` on send / draft, or by replying to a message. The bypass is gated by the mailbox's thread-reply toggle.

**Who can lift it:** allowlist add/remove and the mode flip are **admin / session only** (agent keys → `403 INSUFFICIENT_SCOPE`); granting send permission to an agent would defeat the containment boundary. An agent that hits this can pick an allowlisted recipient or continue an existing thread.

**Preflight:** `GET /v1/mailboxes/:id/allowlist` — agents bound to the mailbox *can* list, so an agent can see what it is allowed to email before it tries. `recipient_policy_mode` is on every mailbox read.

## 4. Strict-recipient (instruction-trust) toggle

A mailbox that has opted into trusted instruction sources can set a **strict-recipient** policy. When strict-recipient is effective (the mailbox's instruction-trust mode is enabled and strict-recipient is on) **and the send is agent-origin**, the thread-reply bypass in gates 2 and 3 is **suppressed**: a thread participant who is not *also* on the explicit allowlist is refused. Agent sends are held to the explicit allowlist only. Non-agent sends are unaffected.

The refusal reuses the gate-2/gate-3 code (`RECIPIENT_AGENT_CONTAINED` or `RECIPIENT_NOT_ON_ALLOWLIST`) — strict-recipient tightens *which* recipients pass, it does not introduce a new code.

**Who can change it.** Turning strict-recipient **on** (tightening) is agent-callable for fast incident response. Turning it **off** (loosening) requires a **dashboard session with fresh re-authentication** — an agent can never loosen instruction-trust posture for itself. See [/agents/trusted-instructions](/agents/trusted-instructions).

## 5. Recipient verification — five `422` codes

When recipient verification **is enforced for your account**, every fresh send/reply/draft-send runs a permissive, fail-open recipient-quality check *before* provider hand-off. Only a **confirmed** violation refuses the send — any uncertainty (DNS timeout, verification-service outage) **fails open** and the send proceeds; the provider bounce path is the backstop.

```json
HTTP/1.1 422 Unprocessable Entity
{ "code": "RECIPIENT_ROLE_ADDRESS" }
```

| Code | Fires when |
|---|---|
| `RECIPIENT_ADDRESS_INVALID` | The address fails a strict syntax check beyond basic email-format validation. |
| `RECIPIENT_DOMAIN_TYPO_SUSPECTED` | The domain looks like a single-character typo of a common consumer mail provider (e.g. `gmial.com`). The suggested domain rides in the error `message`. |
| `RECIPIENT_ROLE_ADDRESS` | The local part is a structural role/distribution mailbox (`noreply@`, `no-reply@`, etc.), not an individual inbox. |
| `RECIPIENT_DISPOSABLE_ADDRESS` | The domain is a known disposable/temporary email provider. |
| `RECIPIENT_UNDELIVERABLE` | The domain does not exist (NXDOMAIN), publishes a null MX, resolves with no MX and no A/AAAA record, or deep mailbox verification confirmed the address doesn't exist. |

**A reply or thread continuation is exempt** from the typo / role / disposable checks — the recipient is a proven correspondent — so only `RECIPIENT_ADDRESS_INVALID` and `RECIPIENT_UNDELIVERABLE` are reachable on `POST /v1/messages/:id/reply` or a `send --thread` continuation. All five codes are skipped entirely for a test-simulator recipient or a recipient at one of your own ReplyLayer-managed sending domains.

On the scheduled-send, async-dispatch, and release paths these map to the matching `message.dispatch_failed` reason code.

**Who can lift it:** no auth change — fix the recipient address (or, for a typo false-positive, resend as-is). This is a data problem, not a permission one.

## 6. Sandbox gates

Sandbox accounts have additional gates. See [/docs/limits](/docs/limits) for the sandbox model.

**Unconfirmed recipient — `403 FORBIDDEN`.** A sandbox account can only send to a **confirmed recipient**. The recipient is added by an **admin key** via `POST /v1/recipients`, which emails them a confirmation link they must click; the send is admitted only once `confirmed_at` is set. The error message names the requirement:

```json
HTTP/1.1 403 Forbidden
{ "code": "FORBIDDEN",
  "error": "Sandbox accounts can only send to confirmed recipients. The recipient must click the confirmation link sent to their email." }
```

**Test-simulator exemption.** Sends to an exact `simulator.replylayer.net` scenario address (ReplyLayer's own first-party simulator — `delivered@`, `bounced@`, `complained@`, `suppressed@`, including `+label` forms) skip both the confirmed-recipient requirement and the Sandbox recipient-domain concentration cap, so one Sandbox account can exercise all four scenarios immediately. Daily and cumulative send allowances, the attempt ledger, authentication, API rate limits, scanning/review policy, and idempotency remain active. Other local parts, subdomains, and lookalike domains are not exempt. Replies to an inbound sender are also exempt from confirmation. Scenario outcomes and accounting caveats are defined in the [email simulator guide](/docs/guides/simulator).

**Recipient-domain cap — `SANDBOX_RECIPIENT_DOMAIN_CAP_EXCEEDED`.** A Sandbox
account can make at most **3 send attempts to one recipient domain in a rolling 24-hour
window**. The fourth attempt returns a bare `429` error with this code. Wait until an
earlier attempt leaves the window, use another Sandbox account, or move to a paid
account; retrying unchanged before then refuses identically. The exact first-party
simulator scenarios described above are the narrow exception because they make no
network delivery; other recipients at any domain still follow this cap.

**Trial budget exhausted — `SANDBOX_TRIAL_BUDGET_EXHAUSTED`.** A sandbox account has a cumulative send budget across its trial. When exhausted:

```json
HTTP/1.1 403 Forbidden
{
  "code": "SANDBOX_TRIAL_BUDGET_EXHAUSTED",
  "details": {
    "reason_axis": "trust_capacity",
    "remedy": "add_paygo_credits_or_await_trust_ramp",
    "cheapest_next_step": "paygo",
    "current_count": 250,
    "max_allowed": 250
  }
}
```

**Who can lift it:** add pay-as-you-go credits or upgrade — a billing action, so a **dashboard session**. Branch on `reason_axis === 'trust_capacity'`.

## 7. Daily quota / velocity — `RATE_LIMITED`

The effective daily send limit is trust- and tier-derived. When the day's budget is spent:

```json
HTTP/1.1 429 Too Many Requests
{
  "code": "RATE_LIMITED",
  "details": {
    "daily_limit": 200,
    "sends_remaining": 0,
    "reset_at": "2026-05-30T00:00:00.000Z"
  }
}
```

`reset_at` is the next UTC-midnight budget rollover. Back off until `reset_at` — the same daily send retried before then refuses identically.

**Preflight — do this before a burst.** `GET /v1/accounts/quota` is agent-accessible (unlike the admin-only usage endpoint) and reports the **effective** limit the gate enforces:

```json
{
  "today": { "count": 12, "limit": 200, "day": "2026-05-29" },
  "sends_remaining": 188,
  "reset_at": "2026-05-30T00:00:00.000Z",
  "scope": "agent",
  "bound_mailbox_ids": ["..."]
}
```

**Who can lift it:** wait for `reset_at` — your effective daily limit can rise over time as your account's sending history builds (check the current ceiling anytime via `GET /v1/accounts/quota`) — or upgrade the tier. See [/docs/limits](/docs/limits). New paid senders may see a temporary warm-up cap disclosed in the `warmup` block of the quota response.

---

## Recommended agent flow

1. **Preflight the cheap checks** — read the mailbox (`agent_send_policy`, `recipient_policy_mode`), the allowlist if in a restricted mode, and `GET /v1/accounts/quota` before a burst.
2. **Send, then branch on `code` first, `details.reason_axis`/`remedy` second.** A `403 RECIPIENT_*` is a policy refusal you resolve with data or a human; a `429 RATE_LIMITED` is a timing refusal you resolve with `reset_at`.
3. **Never retry a policy refusal unchanged.** `RECIPIENT_SUPPRESSED`, `RECIPIENT_AGENT_CONTAINED`, and `RECIPIENT_NOT_ON_ALLOWLIST` will refuse identically until a human changes configuration. Surface `remedy` to your operator rather than looping.
4. **A refusal is not a content block.** These gates run before scanning. A send that *passes* the gates can still be held or blocked by the content scanner — that is a separate outcome; see [/agents/send-outcomes](/agents/send-outcomes) and [/agents/messages](/agents/messages).

Refused agent sends against a restricted mailbox are recorded and can be delivered to your webhook as a blocked-attempt event — see [/agents/webhooks](/agents/webhooks).
