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; 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 ([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 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):

{
  "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.