# Recipient allowlist API

This page is the per-operation reference for two recipient-scoped resources that
gate outbound email:

- **Recipient allowlist** — an opt-in, per-mailbox containment list. When a
  mailbox is in `allowlist` mode, the API accepts sends only to recipients on
  that mailbox's list. It is a hard boundary: an agent (or a leaked key) cannot
  email an address that is not on the list. The **blocked-attempts** log records
  every off-list send the gate rejected.
- **Verified recipients** — the sandbox-tier confirmation list. Sandbox accounts
  can only send to recipients who have clicked a confirmation link.

Both compose with the account-wide do-not-contact (suppression) list, which
always runs first: a recipient on both the suppression list and the allowlist is
still rejected. See [Suppressions](/docs/guides/suppressions) for that resource
and [Recipient allowlist guide](/docs/guides/recipient-allowlist) for the
conceptual walkthrough, thread-reply bypass, and migration workflow.

The send-time rejection codes (`RECIPIENT_NOT_ON_ALLOWLIST`,
`RECIPIENT_AGENT_CONTAINED`, `RECIPIENT_SUPPRESSED`) fire on the **send**
endpoints, not on the CRUD endpoints below — see the
[send-gate decision tree](/agents/send-gates) and
[Messages API](/docs/api/messages). The full error catalog with remediation
lives at [/agents/errors](/agents/errors); the machine-readable spec for every
operation here is [/docs/openapi.json](/docs/openapi.json).

**Path params accept a name or a UUID.** Every route whose URL contains
`:mailboxId` accepts either the mailbox's UUID or its name; unknown names return
`404 NOT_FOUND` in the same shape as unknown UUIDs.

## Operation classification

Every operation below carries one classification tag:

| Tag | Meaning |
|---|---|
| `read-only` | No state change; safe to retry. |
| `mutating` | Changes stored configuration. |
| `send-triggering` | Dispatches an outbound email as a side effect. |
| `human-review-possible` | May route to human review before completing. |
| `secret-revealing` | Returns a credential or secret in the response. |

No operation on this page is `human-review-possible` or `secret-revealing`.

---

## Enable or disable allowlist mode

`PATCH /v1/mailboxes/:mailboxId`

**Classification:** `mutating`
**Auth:** Admin key or dashboard session. Agent keys receive
`403 INSUFFICIENT_SCOPE`, so a contained agent cannot un-contain itself.

The allowlist is enabled by flipping the mailbox's `recipient_policy_mode`. This
is one field on the mailbox-update endpoint — see
[Mailboxes API](/docs/api/mailboxes) for the full field set (thread-reply
bypass, agent-send containment, and the other mailbox controls). Only the
allowlist-relevant fields are described here.

**Request fields (allowlist-relevant):**

| Field | Type | Notes |
|---|---|---|
| `recipient_policy_mode` | `"blocklist"` \| `"allowlist"` | Flipping is idempotent — a PATCH with the current value is a no-op (no audit row, no webhook). |
| `force_empty` | boolean | Required to flip to `allowlist` while the list is empty. Without it the server returns `400 ALLOWLIST_EMPTY`. |

**Errors:**

| Code | HTTP | When |
|---|---|---|
| `ALLOWLIST_EMPTY` | 400 | Flip to `allowlist` on an empty list without `force_empty: true`. A concurrent delete landing between the pre-check and commit can also return this. |
| `INSUFFICIENT_SCOPE` | 403 | Agent-role key. |

Populate the list **before** flipping the mode, or pass `force_empty: true` only
if you specifically want to lock the mailbox down while you populate it.

---

## List allowlist entries

`GET /v1/mailboxes/:mailboxId/allowlist`

**Classification:** `read-only`
**Auth:** Admin key, dashboard session, or an agent key bound to the mailbox. An
agent bound to a different mailbox receives `403 MAILBOX_ACCESS_DENIED`. Agents
may list so a runtime can see what it is allowed to email.

**Query params:**

| Param | Type | Notes |
|---|---|---|
| `limit` | integer, 1–500 | Default 500. |
| `cursor` | string | Opaque base64 `(created_at, id)` tuple from `next_cursor`. |
| `all` | boolean | Bypasses the default page cap; hard-capped at 10,000 server-side. |

**Response 200:**

```json
{
  "allowlist": [
    {
      "email": "partner@corp.com",
      "mailbox_id": "<uuid>",
      "created_at": "2026-04-18T12:00:00.000Z",
      "added_by_actor_type": "admin",
      "added_by_actor_id": "<api-key-uuid>",
      "pattern_type": "email"
    }
  ],
  "next_cursor": null
}
```

Rows are ordered newest-first. `pattern_type` is `"email"` or `"domain"` (see
[Entry formats](#entry-formats)). When more rows remain, `next_cursor` is a
non-null token to pass back as `cursor`.

**Errors:** `400 INVALID_CURSOR`, `403 MAILBOX_ACCESS_DENIED`, `404 NOT_FOUND`
(mailbox missing or soft-deleted). Remediation: [/agents/errors](/agents/errors).

---

## Add a recipient

`POST /v1/mailboxes/:mailboxId/allowlist`

**Classification:** `mutating`
**Auth:** Admin key or dashboard session. Agent keys receive
`403 INSUFFICIENT_SCOPE` — allowlist mode only holds if agents cannot edit the
list they are contained by.

**Request body:**

```json
{ "email": "partner@corp.com" }
```

The `email` field accepts an exact address (`alice@corp.com`) or a bare-domain
pattern (`@corp.com`). See [Entry formats](#entry-formats).

**Response 200:**

```json
{
  "email": "partner@corp.com",
  "mailbox_id": "<uuid>",
  "created_at": "2026-04-18T12:00:00.000Z",
  "already_existed": false,
  "added_by_actor_type": "admin",
  "added_by_actor_id": "<api-key-uuid>",
  "pattern_type": "email"
}
```

Idempotent: a repeat add returns `already_existed: true` with
`created_at: null`, no second audit row, and no second webhook. The server
normalizes every address (`trim` + lowercase) before storing, so `Foo@Bar.com`
and `foo@bar.com` are the same entry.

**Errors:**

| Code | HTTP | When |
|---|---|---|
| `INVALID_EMAIL` | 400 | Malformed address or domain pattern (message: `"Invalid email or domain pattern"`). |
| `INSUFFICIENT_SCOPE` | 403 | Agent-role key. |
| `NOT_FOUND` | 404 | Mailbox missing or soft-deleted. |
| `RATE_LIMITED` | 429 | Hourly add budget exceeded — see [Rate limits](#rate-limits). |

**Webhook:** a newly-inserted row fires `recipient_allowlist.added`.

---

## Bulk add recipients

`POST /v1/mailboxes/:mailboxId/allowlist/bulk`

**Classification:** `mutating`
**Auth:** Admin key or dashboard session. Agent keys receive
`403 INSUFFICIENT_SCOPE`.

**Request body** (up to 1,000 addresses per request):

```json
{ "emails": ["a@corp.com", "@partner.com", "b@corp.com"] }
```

The input array is normalized and deduped before insert. Partial success is the
norm — each address lands in exactly one bucket.

**Response 200:**

```json
{
  "added": [
    { "email": "a@corp.com", "created_at": "...", "pattern_type": "email" },
    { "email": "@partner.com", "created_at": "...", "pattern_type": "domain" }
  ],
  "already_existed": ["b@corp.com"],
  "invalid": [{ "email": "bad", "reason": "invalid_format" }],
  "counts": { "added": 2, "already_existed": 1, "invalid": 1, "total": 4 }
}
```

`counts.total` is the deduped input size, not the raw array length. Each newly
inserted row fires one `recipient_allowlist.added` webhook.

**Errors:** `403 INSUFFICIENT_SCOPE`, `404 NOT_FOUND`, `429 RATE_LIMITED` — a
1,000-address request consumes 1,000 against the hourly budget. Remediation:
[/agents/errors](/agents/errors).

---

## Remove a recipient

`DELETE /v1/mailboxes/:mailboxId/allowlist/:email`

**Classification:** `mutating`
**Auth:** Admin key or dashboard session. Agent keys receive
`403 INSUFFICIENT_SCOPE` — only a human can lift a containment entry.

`:email` is the exact stored string. A domain entry is deleted URL-encoded:
`@corp.com` → `%40corp.com`. The delete trusts the caller's string, so an entry
always removes cleanly even if a future validator would reject the exact form.

**Query params:**

| Param | Type | Notes |
|---|---|---|
| `force_empty` | boolean | Acknowledges deleting the last entry while the mailbox is in `allowlist` mode. |

**Response 200:**

```json
{
  "status": "removed",
  "email": "partner@corp.com",
  "mailbox_id": "<uuid>",
  "created_at": "2026-04-18T12:00:00.000Z",
  "pattern_type": "email"
}
```

**Errors:**

| Code | HTTP | When |
|---|---|---|
| `INSUFFICIENT_SCOPE` | 403 | Agent-role key. |
| `NOT_FOUND` | 404 | Mailbox or entry missing. |
| `ALLOWLIST_LAST_ENTRY` | 409 | The delete would empty the list while the mailbox is in `allowlist` mode; retry with `?force_empty=true`. In `blocklist` mode the guard does not fire. |

**Webhook:** fires `recipient_allowlist.removed`.

---

## List blocked send attempts

`GET /v1/mailboxes/:mailboxId/allowlist/blocked-attempts`

**Classification:** `read-only`
**Auth:** Admin key, dashboard session, or an agent key bound to the mailbox
(mirrors listing the allowlist — an agent seeing its own rejection history is
legitimate self-observability). Agents bound elsewhere receive
`403 MAILBOX_ACCESS_DENIED`.

Every off-list send rejection is recorded append-only. This endpoint reads that
log. It has two shapes: an aggregated default (collapse volume) and a raw
per-attempt view (forensic drill-in). These rejections do **not** count toward
abuse or reputation signals — the gate fires before any message row is written.

**Query params:**

| Param | Type | Notes |
|---|---|---|
| `aggregate` | boolean | Default `true`. Set `false` for raw per-attempt rows. |
| `within_days` | integer, 1–365 | Optional recency filter, e.g. `7` for "blocked this week". Applies to both shapes. |
| `limit` | integer, 1–500 | Default 500. |
| `cursor` | string | Opaque `(created_at, id)` tuple. Raw view only. |
| `all` | boolean | Enumerate; raw view only, hard-capped at 10,000. |

**Aggregated response (default), 200:**

```json
{
  "attempts": [
    {
      "recipient": "stranger@example.com",
      "actor_type": "agent",
      "actor_id": "<api-key-uuid>",
      "count": 12,
      "first_attempted_at": "2026-04-19T14:23:05.211Z",
      "last_attempted_at": "2026-04-19T18:47:31.004Z",
      "origins": ["send", "reply"]
    }
  ],
  "next_cursor": null
}
```

Groups by `(recipient, actor_id)` with a count, first/last timestamps, and the
set of send origins, ordered by most-recent attempt. `next_cursor` is always
`null` in aggregated mode. Use `?aggregate=false` to enumerate exhaustively.

**Raw response (`?aggregate=false`), 200:**

```json
{
  "attempts": [
    {
      "attempted_at": "2026-04-19T14:23:05.211Z",
      "recipient": "stranger@example.com",
      "actor_type": "agent",
      "actor_id": "<api-key-uuid>",
      "origin": "send",
      "draft_id": null
    }
  ],
  "next_cursor": null
}
```

`origin` is `send`, `reply`, or `draft_dispatch`; `draft_id` is set only when the
rejection came from dispatching a scheduled draft.

**Errors:** `400 INVALID_CURSOR`, `403 MAILBOX_ACCESS_DENIED`, `404 NOT_FOUND`.

**Webhook:** the gate also fires `recipient_allowlist.blocked_attempt` at
rejection time, deduped to at most one delivery per `(account, mailbox,
recipient)` per 60 seconds so a runaway agent produces one alert, not hundreds.
This endpoint keeps full history regardless.

---

## Verified recipients (sandbox tier)

Sandbox accounts can only send to confirmed recipients. Adding a recipient
validates the address and dispatches a confirmation email; the recipient becomes
usable only after they click the emailed link. The click happens on a tokenized
browser page, not via an API call you make — there is no customer-facing confirm
endpoint. This gate is independent of the recipient allowlist; in sandbox both
must pass. See [Limits](/docs/limits) for the sandbox tier model.

### Add a verified recipient

`POST /v1/recipients`

**Classification:** `send-triggering` (dispatches a confirmation email)
**Auth:** Admin key or dashboard session. Sandbox tier only.

**Request body:**

```json
{ "email": "friend@example.com" }
```

**Response 201:**

```json
{ "id": "<uuid>", "email": "friend@example.com", "status": "pending", "created_at": "..." }
```

`status` stays `pending` until the recipient confirms. Per-account limits: at
most 10 adds per hour and 25 recipients total (pending + confirmed).

**Errors:**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 400 | Malformed address after normalization. |
| `CONFLICT` | 409 | Already pending or confirmed, or a per-account cap is hit. |
| `RECIPIENT_VALIDATION_FAILED` | 422 | The address failed deliverability validation. |
| `RECIPIENT_VALIDATION_UNAVAILABLE` | 503 | The validation check was unavailable while validation is enforced. |
| `RATE_LIMITED` | 429 | More than 10 adds in the hour. |

### List verified recipients

`GET /v1/recipients`

**Classification:** `read-only`
**Auth:** Any account-scoped key (admin, agent, or session).

**Response 200:**

```json
{
  "recipients": [
    { "id": "<uuid>", "email": "friend@example.com", "status": "confirmed", "created_at": "...", "confirmed_at": "..." },
    { "id": "<uuid>", "email": "other@example.com", "status": "pending", "created_at": "...", "confirmed_at": null }
  ]
}
```

`status` is `confirmed`, `pending`, or `expired` (the confirmation link expired
after 48 hours without a click).

### Resend a confirmation email

`POST /v1/recipients/:id/resend`

**Classification:** `send-triggering` (re-dispatches a confirmation email)
**Auth:** Admin key or dashboard session.

Regenerates the token and extends expiry by 48 hours. Shares the 10-per-hour add
budget. A pending recipient with stale validation metadata is revalidated before
the new email is sent.

**Response 200:** `{ "status": "sent" }` — or `{ "status": "already_confirmed" }`
if the recipient is already confirmed.

**Errors:** `404 NOT_FOUND`, `422 RECIPIENT_VALIDATION_FAILED`,
`429 RATE_LIMITED`, `503 RECIPIENT_VALIDATION_UNAVAILABLE`.

### Remove a verified recipient

`DELETE /v1/recipients/:id`

**Classification:** `mutating`
**Auth:** Admin key or dashboard session.

**Response 200:** `{ "status": "deleted" }`

**Errors:** `404 NOT_FOUND`.

---

## Entry formats

Allowlist entries are exact addresses or bare-domain patterns; mix both freely
in one list.

| Entry | Matches | Does not match |
|---|---|---|
| `alice@corp.com` | `alice@corp.com` only | everyone else |
| `@corp.com` | any `*@corp.com` | `eve@sub.corp.com` — add `@sub.corp.com` explicitly |

Every allowlist read/write surface exposes a derived `pattern_type` of `"email"`
or `"domain"`. Matching is exact and case-insensitive; sub-addresses are
distinct (`user@corp.com` and `user+tag@corp.com` are separate entries).

**Validation** (applied server-side, shared with suppressions):

- Input is trimmed and lowercased before validation.
- Domain patterns must be multi-label ASCII: `@corp.com`, `@my-company.io`,
  `@sub.example.co.uk` are valid.
- Rejected: `@`, `@.com`, `@foo`, `@foo.`, `@.foo.com`, `@foo..com`,
  `@-corp.com`, `@_foo.com`, and any non-ASCII.
- Length caps: total domain ≤ 253 chars; each label ≤ 63 chars.
- Subdomain wildcards (`@*.corp.com`) and IDN/punycode are not supported.

## Rate limits

The allowlist add endpoints (single + bulk) share a per-account hourly budget of
5,000 entries added — it counts entries, not requests, so a 1,000-address bulk
request consumes 1,000. Every add response carries the observable budget in the
`X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers;
a `429 RATE_LIMITED` also returns a `Retry-After` header and
`details.retry_after` (seconds). Verified-recipient adds are capped separately at
10 per hour.

## Webhook events

Four events accompany the operations above. Payload shapes and the delivery
contract are documented once at [/agents/webhooks](/agents/webhooks) — subscribe
there, do not restate here.

| Event | Fires on |
|---|---|
| `recipient_allowlist.added` | A newly-inserted allowlist entry (single or bulk). |
| `recipient_allowlist.removed` | An allowlist delete. |
| `mailbox.recipient_policy_changed` | A `recipient_policy_mode` flip (only when the mode actually changes). |
| `recipient_allowlist.blocked_attempt` | The allowlist gate rejects a send (deduped, one per recipient per 60 s). |

These four are exempt from delivery-time PII redaction — the address is
operator-authored configuration, so redacting it would defeat the event's
purpose.
