Inbound sender policy (firewall) API
The inbound firewall gates incoming email by sender. It is the symmetric counterpart to the outbound do-not-contact list and recipient allowlist: where those decide who your mailboxes may send to, the firewall decides who may reach you. For the conceptual model — modes, matching, and how a blocked message flows — read the inbound firewall guide. This page is the curated API reference for the resource.
Three moving parts:
- A per-mailbox mode (
sender_policy_mode):blocklist(default) delivers everyone except deny-listed senders;allowlistdelivers only allow-listed senders and firewall-blocks the rest. - Two lists: a per-mailbox allowlist (senders always admitted to that mailbox) and an account-wide blocklist (senders rejected everywhere).
- A release action: a message the firewall held sits in state
firewall_blocked; releasing it re-enters content scanning.
Both list entries accept an exact address ([email protected]) or a bare-domain
pattern (@corp.com, which matches every address at that domain). Entries are
normalized (trimmed + lowercased) and de-duplicated; adds are idempotent. Every
write surface returns a derived pattern_type of "email" or "domain".
Authentication
Every route accepts a Bearer admin key, a Bearer agent key, or a
dashboard session. Unlike the outbound suppression list, agent keys may both
read and mutate here — including delete — because the firewall protects you from
external senders rather than containing your own agents. Mailbox-scoped routes
enforce the calling agent key's mailbox binding: a key bound to a different mailbox
gets 403 MAILBOX_ACCESS_DENIED. The account-wide blocklist routes are not
mailbox-scoped and work with any admin or agent key for the account.
See /agents/auth for the key model and /agents/errors for the full error catalog and remediation. The machine-readable spec for every operation below is published at /docs/openapi.json.
Operation classification
Each operation is tagged so you can see its side effects at a glance:
- read-only — returns data, changes nothing.
- mutating — changes stored policy or message state.
None of the firewall operations send email, queue a human review, or reveal a secret, so only these two tags appear on this page.
Rate limits
The allowlist add endpoints share one per-account hourly budget; the blocklist add
endpoints share a separate one. Each budget is 5,000 entries added per hour and
counts entries, not requests — a 1,000-entry bulk call consumes 1,000. Exhausting a
budget returns 429 RATE_LIMITED with Retry-After.
:mailboxId in the paths below accepts either the mailbox UUID or the mailbox name.
PATCH /v1/mailboxes/:mailboxId/sender-policy
Flip a mailbox between blocklist and allowlist mode.
Classification: mutating. Auth: admin or agent (mailbox-bound).
Request body:
{ "mode": "allowlist", "force_empty": false }mode(required) —"blocklist"or"allowlist".force_empty(optional) — required astrueto flip toallowlistwhile the mailbox's allowlist is empty. Passing it acknowledges that every incoming sender will be firewall-blocked until you add entries.
Response (200):
{
"mailbox_id": "…",
"sender_policy_mode": "allowlist",
"previous_mode": "blocklist",
"changed_at": "2026-04-25T20:35:06Z"
}A real flip fires the mailbox.sender_policy_changed webhook; a no-op flip
(setting the mode it already has) is suppressed and fires nothing.
Notable errors: 409 SENDER_POLICY_FLIP_EMPTY_ALLOWLIST (flip to allowlist with
an empty list and no force_empty), 404 NOT_FOUND, 403 MAILBOX_ACCESS_DENIED.
GET /v1/mailboxes/:mailboxId/inbound-allowlist
List the mailbox's allowlist, newest first. Classification: read-only. Auth: admin or agent (mailbox-bound).
Query params: limit (1–500, default 500), cursor (opaque, from a prior
next_cursor), all=true (bypass the page default up to 10,000 rows).
Response (200):
{
"allowlist": [
{
"email": "[email protected]",
"mailbox_id": "…",
"created_at": "2026-04-20T18:00:00Z",
"added_by_actor_type": "agent",
"added_by_actor_id": "…",
"pattern_type": "email"
}
],
"next_cursor": null
}next_cursor is non-null when more rows remain — pass it back as cursor.
added_by_actor_type is admin, agent, user (dashboard), or null for
legacy rows. Errors: 400 INVALID_CURSOR, 404 NOT_FOUND, 403 MAILBOX_ACCESS_DENIED.
POST /v1/mailboxes/:mailboxId/inbound-allowlist
Add one allowlist entry. Idempotent. Classification: mutating. Auth: admin or agent (mailbox-bound). Rate-limited (see above).
Request body: { "email": "[email protected]" } — an exact address or a @domain
pattern.
Response (200):
{
"email": "[email protected]",
"mailbox_id": "…",
"created_at": "2026-04-20T18:00:00Z",
"already_existed": false,
"added_by_actor_type": "agent",
"added_by_actor_id": "…",
"pattern_type": "email"
}A repeat add returns already_existed: true with created_at: null and writes no
second audit row or webhook. A newly inserted entry fires sender_allowlist.added.
Errors: 400 INVALID_EMAIL (unparseable address/pattern), 429 RATE_LIMITED,
404 NOT_FOUND, 403 MAILBOX_ACCESS_DENIED.
POST /v1/mailboxes/:mailboxId/inbound-allowlist/bulk
Add up to 1,000 entries in one call. Classification: mutating. Auth: admin or agent (mailbox-bound). Rate-limited by entry count.
Request body: { "emails": ["[email protected]", "@partner.com", "not-an-email"] }.
The server dedupes and normalizes the input before inserting.
Response (200) — partial-success buckets:
{
"added": [
{ "email": "[email protected]", "created_at": "2026-04-20T18:00:00Z", "pattern_type": "email" }
],
"already_existed": ["@partner.com"],
"invalid": [{ "email": "not-an-email", "reason": "invalid_format" }],
"counts": { "added": 1, "already_existed": 1, "invalid": 1, "total": 3 }
}counts.total is the deduped input size. Each newly added entry fires
sender_allowlist.added. Errors: 429 RATE_LIMITED, 404 NOT_FOUND,
403 MAILBOX_ACCESS_DENIED.
DELETE /v1/mailboxes/:mailboxId/inbound-allowlist/:email
Remove one allowlist entry. Classification: mutating. Auth: admin or agent (mailbox-bound).
Query param: force_empty=true overrides the last-entry guard. If the mailbox is
in allowlist mode and removing this entry would empty the list, the request is
rejected with 409 INBOUND_ALLOWLIST_LAST_ENTRY unless force_empty=true — this
prevents accidentally firewall-blocking every future sender.
Response (200):
{
"status": "removed",
"email": "[email protected]",
"mailbox_id": "…",
"created_at": "2026-04-20T18:00:00Z",
"pattern_type": "email"
}Fires sender_allowlist.removed. Errors: 409 INBOUND_ALLOWLIST_LAST_ENTRY,
404 NOT_FOUND, 403 MAILBOX_ACCESS_DENIED.
GET /v1/mailboxes/:mailboxId/inbound-allowlist/blocked-attempts
Read the log of incoming messages the firewall rejected for this mailbox. Classification: read-only. Auth: admin or agent (mailbox-bound).
Query params: aggregate (default true), within_days (1–365 recency filter),
plus limit/cursor/all on the raw form.
Aggregated response (default) groups by sender, matched field, mode, and reason:
{
"attempts": [
{
"sender": "@spam.com",
"matched_field": "envelope",
"mode": "blocklist",
"reason_code": "SENDER_BLOCKED",
"count": 17,
"first_attempted_at": "2026-04-20T…",
"last_attempted_at": "2026-04-25T…"
}
],
"next_cursor": null
}Raw response (?aggregate=false) returns one row per attempt with cursor
pagination — fields attempted_at, envelope_sender, from_address,
matched_field (envelope | from), matched_pattern, reason_code
(SENDER_BLOCKED | SENDER_NOT_ON_ALLOWLIST), mode, matched_list
(account_blocklist | mailbox_allowlist — which list matched),
and message_id. Errors: 400 INVALID_CURSOR, 404 NOT_FOUND,
403 MAILBOX_ACCESS_DENIED.
GET /v1/inbound-blocklist
List the account-wide blocklist, newest first. Classification: read-only.
Auth: admin or agent. Same pagination shape as the allowlist list
(limit 1–500 default 500, cursor, all=true up to 10,000).
Response (200):
{
"blocklist": [
{
"email": "[email protected]",
"reason": "manual",
"source": "customer",
"created_at": "2026-04-20T18:00:00Z",
"added_by_actor_type": "admin",
"added_by_actor_id": "…",
"pattern_type": "email"
}
],
"next_cursor": null
}Customer-created entries are always reason: "manual", source: "customer".
Errors: 400 INVALID_CURSOR.
POST /v1/inbound-blocklist
Add one sender to the account-wide blocklist. Idempotent. Classification: mutating. Auth: admin or agent. Rate-limited (see above).
Request body: { "email": "[email protected]" } — exact address or @domain.
The server forces reason: "manual", source: "customer"; any other value in the
body is ignored.
Response (200):
{
"email": "[email protected]",
"reason": "manual",
"source": "customer",
"created_at": "2026-04-20T18:00:00Z",
"already_existed": false,
"added_by_actor_type": "admin",
"added_by_actor_id": "…",
"pattern_type": "email"
}A repeat add returns already_existed: true with created_at: null. A new entry
fires sender_blocklist.added. Errors: 400 INVALID_EMAIL, 429 RATE_LIMITED.
POST /v1/inbound-blocklist/bulk
Add up to 1,000 senders to the account-wide blocklist in one call. Classification: mutating. Auth: admin or agent. Rate-limited by entry count.
Request body: { "emails": [...] }. Same partial-success response shape as the
allowlist bulk endpoint — added / already_existed / invalid / counts. Each
new entry fires sender_blocklist.added. Errors: 429 RATE_LIMITED.
DELETE /v1/inbound-blocklist/:email
Remove one account-wide blocklist entry. Classification: mutating.
Auth: admin or agent — note this diverges from the outbound
DELETE /v1/suppressions/:email, which is admin-only.
Response (200):
{
"status": "removed",
"email": "[email protected]",
"reason": "manual",
"source": "customer",
"created_at": "2026-04-20T18:00:00Z",
"pattern_type": "email"
}Fires sender_blocklist.removed. Errors: 404 NOT_FOUND.
POST /v1/messages/:id/firewall-release
Release a message the firewall held (state firewall_blocked) back into normal
processing. Classification: mutating. Auth: admin or agent (mailbox-bound).
The move to scanning and the scanner re-run are enqueued atomically, so a release
either fully takes or fully rolls back. The endpoint returns immediately; the
resulting verdict — available, quarantined, or blocked — is observed by
polling GET /v1/messages/:id or via the matching lifecycle
webhook. Releasing runs the content scanner; it does not blanket-approve the
message.
Request body: {} (empty in v1).
Response (202):
{ "message_id": "…", "state": "scanning" }Errors: 409 INVALID_STATE (message is not firewall_blocked — the current state
is echoed in the error message), 404 NOT_FOUND, 403 MAILBOX_ACCESS_DENIED. See
/agents/messages for the full state machine and verdict
vocabulary.
Inspecting a block: the firewall_block field
Message reads (GET /v1/messages/:id, the mailbox message list, and the thread
endpoints) carry a firewall_block object that is populated only while the message
is firewall_blocked and null otherwise. It tells you exactly why the firewall
held the message before you decide whether to release it:
"firewall_block": {
"envelope_sender": "[email protected]",
"from_address": "[email protected]",
"matched_field": "envelope",
"matched_pattern": "@spam.com",
"reason_code": "SENDER_BLOCKED",
"matched_list": "account_blocklist",
"mode": "blocklist"
}reason_code is SENDER_BLOCKED (matched the blocklist) or
SENDER_NOT_ON_ALLOWLIST (mailbox in allowlist mode, sender not listed);
matched_field is envelope or from; matched_list names which list matched
(account_blocklist or mailbox_allowlist).
Under a mailbox's pii_mode: "redacted", envelope_sender, from_address, and
matched_pattern are returned as <REDACTED>; the categorical fields pass through.
Firewall-blocked messages are excluded from the default mailbox message list
and thread endpoints — opt them back in with ?include_firewall_blocked=true so
existing integrations don't silently start seeing the new state.
Webhooks
Policy changes emit customer webhooks: sender_allowlist.added /
sender_allowlist.removed, sender_blocklist.added / sender_blocklist.removed,
mailbox.sender_policy_changed, and inbound_sender.blocked when the firewall
rejects an incoming message. The full payload shapes, delivery/retry semantics, and
which events are exempt from PII redaction are documented once in the
webhook consumption guide.