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
allowlistmode, 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 for that resource and Recipient allowlist guide 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 and
Messages API. The full error catalog with remediation
lives at /agents/errors; the machine-readable spec for every
operation here is /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 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:
{
"allowlist": [
{
"email": "[email protected]",
"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). 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.
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:
{ "email": "[email protected]" }The email field accepts an exact address ([email protected]) or a bare-domain
pattern (@corp.com). See Entry formats.
Response 200:
{
"email": "[email protected]",
"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 [email protected]
and [email protected] 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. |
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):
{ "emails": ["[email protected]", "@partner.com", "[email protected]"] }The input array is normalized and deduped before insert. Partial success is the norm — each address lands in exactly one bucket.
Response 200:
{
"added": [
{ "email": "[email protected]", "created_at": "...", "pattern_type": "email" },
{ "email": "@partner.com", "created_at": "...", "pattern_type": "domain" }
],
"already_existed": ["[email protected]"],
"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.
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:
{
"status": "removed",
"email": "[email protected]",
"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:
{
"attempts": [
{
"recipient": "[email protected]",
"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:
{
"attempts": [
{
"attempted_at": "2026-04-19T14:23:05.211Z",
"recipient": "[email protected]",
"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 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:
{ "email": "[email protected]" }Response 201:
{ "id": "<uuid>", "email": "[email protected]", "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:
{
"recipients": [
{ "id": "<uuid>", "email": "[email protected]", "status": "confirmed", "created_at": "...", "confirmed_at": "..." },
{ "id": "<uuid>", "email": "[email protected]", "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 |
|---|---|---|
[email protected] | [email protected] only | everyone else |
@corp.com | any *@corp.com | [email protected] — 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 ([email protected] and [email protected] 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.ukare 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 — 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.