# 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](/docs/guides/suppressions) and
[recipient allowlist](/docs/guides/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](/docs/guides/inbound-firewall). 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; `allowlist` delivers 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 (`alice@corp.com`) 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](/agents/auth) for the key model and [/agents/errors](/agents/errors)
for the full error catalog and remediation. The machine-readable spec for every
operation below is published at [/docs/openapi.json](/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:

```json
{ "mode": "allowlist", "force_empty": false }
```

- `mode` (required) — `"blocklist"` or `"allowlist"`.
- `force_empty` (optional) — required as `true` to flip to `allowlist` while the
  mailbox's allowlist is empty. Passing it acknowledges that **every** incoming
  sender will be firewall-blocked until you add entries.

Response (200):

```json
{
  "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):

```json
{
  "allowlist": [
    {
      "email": "alice@corp.com",
      "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": "alice@corp.com" }` — an exact address or a `@domain`
pattern.

Response (200):

```json
{
  "email": "alice@corp.com",
  "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": ["alice@corp.com", "@partner.com", "not-an-email"] }`.
The server dedupes and normalizes the input before inserting.

Response (200) — partial-success buckets:

```json
{
  "added": [
    { "email": "alice@corp.com", "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):

```json
{
  "status": "removed",
  "email": "alice@corp.com",
  "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:

```json
{
  "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):

```json
{
  "blocklist": [
    {
      "email": "spammer@example.com",
      "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": "spammer@example.com" }` — exact address or `@domain`.
The server forces `reason: "manual"`, `source: "customer"`; any other value in the
body is ignored.

Response (200):

```json
{
  "email": "spammer@example.com",
  "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):

```json
{
  "status": "removed",
  "email": "spammer@example.com",
  "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`](/agents/messages) 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):

```json
{ "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](/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:

```json
"firewall_block": {
  "envelope_sender": "attacker@spam.com",
  "from_address": "attacker@spam.com",
  "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](/agents/webhooks).
