# Webhooks (agent consumption contract)

ReplyLayer POSTs a signed JSON event to your endpoint when inbound mail arrives, an
outbound message changes state, an address is added to a policy list, or a scan
outcome is reached. This page is the **canonical event catalog** and the machine
contract for consuming it. For the setup walkthrough (creating a webhook in the
dashboard, storing the signing secret), see [/docs/webhooks](/docs/webhooks).

Delivery is **at-least-once**. The event type is in the **`event`** field (never
`type`). Dedupe on the envelope's outer **`id`** (the delivery id), never on
`data.message_id`.

## Consumption playbook

Handle every incoming POST in this order. Steps 1–3 are non-negotiable; skipping
signature verification or dedupe is a correctness bug.

1. **Verify the signature against the raw request body.** Recompute
   `HMAC_SHA256(signing_secret, "<unix_ts>.<raw_body>")` and compare to the `v1=`
   component of the `X-ReplyLayer-Signature` header. Verify the *bytes you
   received* — a parse-then-re-serialize round trip mutates whitespace/key order
   and invalidates the signature. Reject with a `4xx` on mismatch.
2. **Dedupe on `event.id`.** The `2xx` you send can be lost after you already
   processed the event; ReplyLayer will retry and you will see the same `event.id`
   again. Persist the id (ideally in the same transaction as your side effect) and
   no-op on a repeat.
3. **Branch on `event.event`.** Treat the enum as **open** for forward-compat:
   fall through unknown event types to a no-op `2xx` instead of erroring, so a
   newly added event never trips your auto-disable counter.
4. **Fetch by id for full content.** Webhook payloads are envelope/metadata only —
   they never carry message bodies, extracted attachment text, or raw
   `scan_results`. When you need the body, verdict detail, or the per-key trust
   basis, do an authenticated `GET /v1/messages/:id`.
5. **Treat every content-derived field as untrusted.** Sender addresses, subjects,
   filenames, and reason strings in a payload originate from a remote party. Do not
   route them into a shell, SQL, an LLM tool call, or a follow-up send without your
   own validation. See the trust contract at
   [/agents/security-model](/agents/security-model).

Acknowledge with any `2xx` (an empty `204` is ideal). Anything else is a delivery
failure: `3xx` and non-429 `4xx` abandon immediately; `429`, `5xx`, timeout, and
network errors retry (see [Retries](#retries-backoff)).

## Envelope

Every event uses the same envelope. `id` is the delivery id (your dedupe key);
`event` is the discriminator; `data` is event-specific. Every id — the delivery
`id`, `account_id`, and the `message_id` / `mailbox_id` inside `data` — is a bare
UUID (v4); do not build prefix-based validation.

```json
{
  "id": "9f8c2b1a-6d4e-4a7b-9c3f-1e2d3c4b5a6f",
  "event": "message.received",
  "occurred_at": "2026-04-17T12:00:00.000Z",
  "account_id": "3b1e4d2c-8a7f-4c6b-b9d0-2e1f3a4c5b6d",
  "data": {
    "message_id": "7a2f9c1d-4b6e-4d8a-9f3c-1b2e3d4c5a6f",
    "mailbox_id": "c4d5e6f7-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
    "sender": "alice@example.com",
    "recipient": "bob@example.com",
    "subject": "Hello",
    "received_at": "2026-04-17T12:00:00.000Z"
  }
}
```

## Signature verification

Header:

```
X-ReplyLayer-Signature: t=<unix_ts>,v1=<hex>
```

where `<hex> = HMAC_SHA256(signing_secret, <unix_ts> + "." + <raw_body>)`. The SDK
helpers verify the HMAC, reject a malformed header, and reject a `t=` more than
**300 seconds** from now (tune with `tolerance`). They **throw** on any failure
rather than returning `false`.

<!-- docs-lint:skip-compile -->
```ts
import { verifyWebhookSignature, WebhookSignatureError } from '@replylayer/sdk';
import express from 'express';

const app = express();
const SECRET = process.env.REPLYLAYER_WEBHOOK_SECRET!;
const seen = new Set<string>(); // back this with a durable store in production

app.post('/webhooks/replylayer', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    // Verify the RAW bytes — not req.body after a JSON parse+re-stringify.
    verifyWebhookSignature(req.body, req.header('X-ReplyLayer-Signature') ?? '', SECRET);
  } catch (err) {
    if (err instanceof WebhookSignatureError) return res.status(400).send('bad signature');
    throw err;
  }

  const event = JSON.parse(req.body.toString('utf-8'));
  if (seen.has(event.id)) return res.status(204).send(); // at-least-once → dedupe
  seen.add(event.id);

  switch (event.event) {
    case 'message.received': /* fetch GET /v1/messages/:id for the body */ break;
    case 'message.dispatch_failed': /* branch on event.data.reason_code */ break;
    default: break; // unknown type → no-op 2xx (forward-compat)
  }
  res.status(204).send();
});
```

```python
from replylayer import verify_webhook_signature
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["REPLYLAYER_WEBHOOK_SECRET"]

@app.route("/webhooks/replylayer", methods=["POST"])
def handle():
    try:
        # request.data is the raw body — NOT request.get_json() (parsed + re-escaped)
        verify_webhook_signature(request.data, request.headers.get("X-ReplyLayer-Signature", ""), SECRET)
    except Exception as err:
        abort(400, description=str(err))
    event = request.get_json()
    # dedupe on event["id"], then dispatch on event["event"]
    return "", 204
```

## Subscribing

A webhook subscribes to a set of event types via `enabled_events` at create/update
time. The signing secret is returned **once** from `POST /v1/webhooks` (and from
rotate-secret) — store it immediately; it cannot be read back. `webhook.test` is
**not** subscribable — it is delivered only when a test is triggered against a
specific webhook. Subscribe only to the events you handle so an unhandled type
never contributes to auto-disable.

## Event catalog

The catalog below is exhaustive. Unless noted, `data` always carries `message_id`
and `mailbox_id` (or the resource id) plus the fields listed. The **PII** column is
the behavior under `pii_mode='redacted'` on the source mailbox — see
[PII redaction](#pii-redaction) for exactly which fields are affected.

### Message lifecycle & scan outcomes

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `message.received` | Inbound message passed scanning and is readable | `sender`, `recipient`, `subject`, `received_at`, `subaddress_instance_id`, optional `safety`, optional `sender_authentication`, `agent_safety_context` (always the untrusted baseline — see below) | Redacted |
| `message.delivered` | Outbound delivery confirmed by the delivery provider | `to`, `delivered_at`, `subaddress_instance_id`, optional `attachments[]` | Redacted |
| `message.bounced` | Hard or soft bounce | `to`, `bounce_type`, `failed_at`, `subaddress_instance_id`, optional `attachments[]` | Redacted |
| `message.quarantined` | A scanner quarantined the message (releasable) | `direction`, `reason`, `quarantined_at`, `subaddress_instance_id`, `effect` | Redacted |
| `message.scanner_blocked` | A scanner blocked the message (terminal) | `direction`, `reason`, `blocked_at`, `subaddress_instance_id`, `effect` | Redacted |
| `message.review.queued` | Outbound message routed to human review (`pending_review`) | `direction` (`outbound`), `subject`, `recipient`, `summary_reasons` (non-empty), `origin`, `trigger_source`, `effect` | Redacted |
| `message.review.approved` | A reviewer approved a `pending_review` message | `reviewed_by_type`, `reviewed_at`, `reason` (nullable), `prior_state` | No PII fields |
| `message.review.denied` | A reviewer denied a `pending_review` message (→ `blocked`) | `reviewed_by_type`, `reviewed_at`, `reason` (nullable), `prior_state` | No PII fields |
| `message.review.expired` | The approval-expiry sweep auto-denied a `pending_review` hold whose `review_expires_at` passed (per the mailbox's `approval_expiry`). Distinct from `.denied`: `reviewed_by_type` is always `system` and `review_outcome` is `expired`. State transitions to `blocked` (terminal); the daily-budget slot stays consumed — no refund, mirroring a manual deny. (`review_reason` is `NULL` by design and is not carried.) | `review_outcome` (`expired`), `reviewed_by_type` (`system`), `reviewed_at`, `prior_state` | No PII fields |
| `message.released` | A held message was released — closes the release-webhook gap (release was previously audit-only). Fires for an **outbound** release (a `quarantined` row via the standalone `/release` or operator bulk release, or a `pending_review` hold via review approval — dispatched via the shared release chokepoint) **and** for an **inbound** release (a `quarantined` row via `/release`, or a `firewall_blocked` row handed back to scanner processing via `POST /v1/messages/:id/firewall-release`). `direction` (`inbound` \| `outbound`) and `prior_state` (the pre-release state — `quarantined` \| `pending_review` \| `firewall_blocked`) are on every emit; `release_context` (`quarantine_release` \| `review_approval` \| `bulk_quarantine_release`) is **outbound-only** — inbound emits omit it (their `prior_state` already distinguishes a quarantine release from a firewall release). | `direction`, `prior_state`, `released_at`, `release_context` (outbound only) | No PII fields |

`direction`, releasable-vs-terminal, and the verdict vocabulary are defined once at
[/agents/messages](/agents/messages). The `effect` object is documented under
[The `effect` object](#the-effect-object).

### Scheduled send & dispatch

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `message.scheduled` | A draft's `send_at` is first set | `direction`, `recipient`, `scheduled_at`, `attempt_count` | Redacted (recipient) |
| `message.rescheduled` | A scheduled draft's `send_at` shifts by >60s | `direction`, `recipient`, `from_send_at`, `to_send_at` | Redacted (recipient) |
| `message.schedule_cancelled` | `send_at` cleared, or a scheduled draft deleted | `direction`, `recipient`, `was_scheduled_at`, `reason?` (`draft_deleted` on delete) | Redacted (recipient) |
| `message.dispatch_failed` | Permanent dispatch failure on a path with no live response | `direction`, `recipient`, `reason_code`, `error_code`, `attempt_count`, `scheduled_at` (nullable), `customer_action_required?`, `dispatch_state?` | Redacted (recipient) |

`message.dispatch_failed` fires only from paths that have **no live HTTP response**
(scheduled send, async `Prefer: respond-async` dispatch, and the release / approve
compensation paths). A synchronous send surfaces the same condition as an HTTP
error on the request instead and emits no webhook. See
[Dispatch-failure reason codes](#dispatch-failure-reason-codes).

### Attachment previews

Preview events are **metadata only** — never extracted text. For the attachment
staging and preview model see [/agents/attachments](/agents/attachments).

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `attachment.preview.queued` | A derivative was created (`pending`) and extraction enqueued | `attachment_index`, `variant`, `queued_at` | Metadata only |
| `attachment.preview.ready` | Preview text extracted, scan-passed, and servable | `attachment_index`, `variant`, `ready_at`, `kind`, `char_count`, `page_count`, `truncated` | Metadata only |
| `attachment.preview.blocked` | Extracted text was rejected by a scanner | `attachment_index`, `variant`, `reason_code`, `blocked_at` | Metadata only |
| `attachment.preview.failed` | Extraction failed after the retry budget | `attachment_index`, `variant`, `reason_code`, `failed_at` | Metadata only |

`attachment.preview.failed.reason_code` is a closed set: `extraction_timeout`,
`parser_failure`, `encrypted`, `unsupported_office_format`, `source_too_large`,
`source_not_found`, `source_read_error`, `scanner_error`, `storage_error`. The
closed `reason_code` is the only failure signal on the payload; the richer internal
`failure_detail` string is a stored column and is **not** carried on the webhook.

### Recipient blocklist (do-not-contact) & recipient allowlist

These are **operator-authored config** events and are **exempt** from PII redaction
— the address is the operational signal. Full model:
[/docs/guides/suppressions](/docs/guides/suppressions) and
[/docs/guides/recipient-allowlist](/docs/guides/recipient-allowlist).

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `recipient_blocklist.added` | Address suppressed (bounce, complaint, unsubscribe, or manual add) | `address`, `reason`, `added_at` | Exempt |
| `recipient_blocklist.removed` | Address removed from the suppression list | `address`, `reason`, `removed_at` | Exempt |
| `recipient_blocklist.complaint_override` | A complaint-history removal lock was overridden to remove an address | `address`, `override_reason`, `actor_email`, `latest_complaint_at`, `complaint_count`, `overridden_at` | Exempt |
| `recipient_allowlist.added` | Address added to a mailbox's recipient allowlist | `address`, `added_at`, `added_by_actor_type`, `added_by_actor_id` | Exempt |
| `recipient_allowlist.removed` | Address removed from a mailbox's recipient allowlist | `address`, `removed_at`, `removed_by_actor_type`, `removed_by_actor_id` | Exempt |
| `recipient_allowlist.blocked_attempt` | A send was rejected by the allowlist gate | `recipient`, `actor_type`, `actor_id`, `origin`, `draft_id`, `blocked_at` | Exempt |
| `mailbox.recipient_policy_changed` | A mailbox's outbound `recipient_policy_mode` flipped | `previous_mode`, `current_mode`, `changed_at`, `actor_type`, `actor_id` | Exempt |
| `mailbox.policy_changed` | The policy-builder-era feed for a mailbox's outbound governance fields (mode, agent authoring, caps, send window, approval expiry, recipient policy, etc.). Fires exactly when a mailbox PATCH inserted a new policy revision (a no-op save is deduped and emits nothing) | `policy_revision_id` (nullable), `changed_at` | Exempt |

`recipient_allowlist.blocked_attempt` is server-side deduped (~60s per
`(account, mailbox, recipient)`) to keep a looping agent from auto-disabling your
webhook; the full history is at
`GET /v1/mailboxes/:id/allowlist/blocked-attempts`. If the dedupe backend is briefly
unavailable it fails open, so occasional duplicates are expected — the aggregated
GET endpoint is authoritative.

**`mailbox.policy_changed` / `mailbox.recipient_policy_changed` overlap note:**
the pre-existing `mailbox.recipient_policy_changed` **continues to fire
unchanged** on `recipient_policy_mode` flips — it is not being removed. This
means a recipient-mode flip that arrives via a policy-builder save emits
**both** events: `mailbox.policy_changed` is the builder-era feed covering every
governed field, and `mailbox.recipient_policy_changed` is the older,
field-specific event kept for backward compatibility. Subscribe to
`mailbox.policy_changed` if you only want one event per governed change; keep
both if you already handle `mailbox.recipient_policy_changed` and don't want to
migrate that handler. The inbound `mailbox.sender_policy_changed` (below) is
**untouched and not subsumed** by `mailbox.policy_changed` — inbound keeps its
own, separate event.

### Inbound firewall (senders)

Full model at [/docs/guides/inbound-firewall](/docs/guides/inbound-firewall). The
allowlist/blocklist mutation events are **exempt** (customer-authored config);
`inbound_sender.blocked` is **not exempt** because it carries the sender's observed
addresses.

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `inbound_sender.blocked` | An inbound message was rejected by the firewall | `envelope_sender`, `from_address`, `matched_field`, `matched_pattern`, `mode`, `reason_code`, `matched_list`, `blocked_at` | Redacted (sender fields) |
| `sender_allowlist.added` | Address added to a mailbox's inbound allowlist | `address`, `added_at`, `added_by_actor_type`, `added_by_actor_id` | Exempt |
| `sender_allowlist.removed` | Address removed from a mailbox's inbound allowlist | `address`, `removed_at`, `removed_by_actor_type`, `removed_by_actor_id` | Exempt |
| `sender_blocklist.added` | Address added to the account-wide inbound blocklist | `address`, `reason`, `added_at` | Exempt |
| `sender_blocklist.removed` | Address removed from the account-wide inbound blocklist | `address`, `reason`, `removed_at` | Exempt |
| `mailbox.sender_policy_changed` | A mailbox's `sender_policy_mode` flipped | `previous_mode`, `current_mode`, `changed_at`, `actor_type`, `actor_id` | Exempt |

`inbound_sender.blocked` is deduped for ~60s (fail-open). `envelope_sender` /
`from_address` are the actual observed addresses (always exact); `matched_pattern`
is the stored entry that matched (an exact email or a `@domain` pattern).

### Trusted instruction sources

**Not exempt** from PII redaction. A trusted-source grant is a security-sensitive
read relaxation whose payload carries a third party's address, so under
`pii_mode='redacted'` the identifying fields are nulled (not passed through). See
the [webhook-vs-read asymmetry](#trusted-instruction-sources-the-webhook-vs-read-asymmetry)
and [/agents/trusted-instructions](/agents/trusted-instructions).

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `trusted_source.granted` | A trusted instruction source was granted on a mailbox | `mailbox_id`, `source_id`, `grain`, `value`, `value_ascii`, `verified_domain`, `expires_at` | Not exempt — `value`/`value_ascii` → `<EMAIL_ADDRESS>`, `verified_domain` → `null` |
| `trusted_source.revoked` | A trusted instruction source was revoked (customer-initiated or auto-revoked) | `mailbox_id`, `source_id`, `grain`, `value` (+ `reason: "auto_revoked"` and `signal` on the auto path) | Not exempt — `value` → `<EMAIL_ADDRESS>` |

On `trusted_source.revoked`, the auto-revoke path adds `reason: "auto_revoked"` and
a `signal` (`scanner_blocked` / `sender_forgery` / `sender_auth_error` /
`verdict_downgrade`) so you can distinguish it from a manual revoke, which omits
both.

### Legal hold

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `legal_hold.applied` | A legal hold was placed (freezes data against deletion/purge) | `legal_hold_id`, `scope`, `mailbox_id`, `case_reference`, `applied_at`, `released_at` | No PII fields |
| `legal_hold.released` | A legal hold was lifted | `legal_hold_id`, `scope`, `mailbox_id`, `case_reference`, `applied_at`, `released_at` | No PII fields |

### Test

| Event | Fires when | Key `data` fields | PII |
|---|---|---|---|
| `webhook.test` | A test delivery was triggered against a specific webhook | `webhook_id`, `triggered_at` | Exempt |

`domain.self_hosted_auth_failed` and `domain.self_hosted_probe_failed` are present
in the subscribable enum but have **no live emit sites** — do not build handling
that depends on receiving them.

## The `effect` object

`message.quarantined`, `message.scanner_blocked`, and `message.review.queued` carry
a **content-free** `effect` object so a webhook-first agent can decide
release / retry / escalate **from the webhook alone**, with no follow-up `GET`.
It is the webhook projection of the outbound-send outcome contract documented at
[/agents/send-outcomes](/agents/send-outcomes).

```json
"effect": {
  "effect_status": "blocked",
  "verdict": "blocked",
  "releasable": false,
  "terminal": true,
  "retryable": false,
  "failure_class": "model_judgment"
}
```

| Field | Type | Meaning |
|---|---|---|
| `effect_status` | string (open enum) | `sent` / `held_for_review` / `held_infrastructure` / `blocked` — the one-read branch discriminator |
| `verdict` | string \| null | Vendor-neutral scanner verdict (`clean` / `warning` / `review_required` / `quarantined` / `blocked`), or `null` |
| `releasable` | boolean | `true` iff human-releasable (`held_for_review`) |
| `terminal` | boolean | `true` iff no further automatic transition |
| `retryable` | boolean | `true` iff an **infrastructure hold** (`held_infrastructure`) — retry later, content never judged. **Never `true` on a genuine content block**, even when a co-occurring infrastructure finding is present |
| `failure_class` | string \| null | `inference_error` (infra hold), `model_judgment` (the content was judged), or `null` |

`effect` is derived only from those structural fields — never from a finding's
reason string, a filename, or an instruction hint — so no content leaks through it.
It is present for both inbound and outbound outcome events (inbound events resolve
`effect_status` ∈ `held_for_review` / `held_infrastructure` / `blocked`, never
`sent`). The verdict vocabulary is defined at [/agents/messages](/agents/messages).

## Dispatch-failure reason codes

`message.dispatch_failed.data.reason_code` is a **closed enum**. Branch on it and
treat any value outside this list as `INTERNAL_ERROR` (forward-compat). Most codes
have a synchronous twin: on an immediate send the same condition returns an HTTP
error on the request, and no webhook fires; the webhook exists for the paths with no
live response (scheduled, async, release/approve).

| `reason_code` | Meaning |
|---|---|
| `RESCAN_REJECTED` | The authoritative rescan at dispatch time rejected the content (`error_code: DRAFT_REJECTED_BY_RESCAN`) |
| `ALLOWLIST` | Recipient is not on the mailbox's recipient allowlist |
| `RECIPIENT_AGENT_CONTAINED` | When agent-send containment is enforced for the mailbox, an agent-origin send to a new / non-thread-participant recipient was blocked. Distinct from `ALLOWLIST`. See [/agents/send-gates](/agents/send-gates) |
| `SUPPRESSED` | Recipient is suppressed (do-not-contact) |
| `ACCOUNT_SUSPENDED` / `MAILBOX_SUSPENDED` / `DOMAIN_SUSPENDED` | A suspension was imposed between schedule and dispatch |
| `FORBIDDEN` | Sandbox trial expired or another policy-level block |
| `SANDBOX_TRIAL_BUDGET_EXHAUSTED` | The sandbox cumulative-send budget was used up — see [/docs/limits](/docs/limits) |
| `SANDBOX_THROTTLE_EXCEEDED` | The shared-domain outbound throttle rejected the send (or its backend was unreachable and it failed closed) |
| `RECIPIENT_UNDELIVERABLE` | Recipient verification confirmed the recipient domain has no mail servers, or deep mailbox verification confirmed it doesn't exist; a transient failure fails open, so this is only ever a confirmed negative |
| `RECIPIENT_ADDRESS_INVALID` | The recipient fails a strict syntax check beyond basic email-format validation |
| `RECIPIENT_DOMAIN_TYPO_SUSPECTED` | The recipient domain looks like a single-character typo of a common consumer mail provider |
| `RECIPIENT_ROLE_ADDRESS` | The recipient is a role/distribution mailbox (`noreply@`, etc.), not an individual inbox. Exempt on a reply / thread continuation |
| `RECIPIENT_DISPOSABLE_ADDRESS` | The recipient domain is a disposable/temporary email provider. Exempt on a reply / thread continuation |
| `STALE` | Dispatch would have occurred long after the requested `send_at`; treated as permanent |
| `ATTEMPTS_EXHAUSTED` | The transient retry budget was exhausted without a successful dispatch |
| `MAILBOX_DELETED` | The mailbox was deleted between schedule and dispatch |
| `SMTP_REJECTED` | Definitive SMTP rejection on a self-hosted transport send |
| `SELF_HOSTED_DECRYPT_FAILED` | A self-hosted transport credential failed to decrypt at dispatch time; the row stays in `draft` for re-dispatch once resolved |
| `DOMAIN_UNAVAILABLE` | Reserved: direct-send with no viable provider available |
| `PROVIDER_REJECTED` | The delivery provider definitively rejected the send (async path); the row rolls back to `draft` |
| `OUTBOUND_DISPATCH_WATCHDOG_TIMEOUT` | An infrastructure hold (scanner unreachable / queue stalled), **not** a content verdict; the row rolls back to `draft` for retry |
| `BILLING_LAPSED` | Sending is paused because billing lapsed. Carries `customer_action_required: true` |
| `BILLING_REACTIVATION_PENDING` | Sending is paused pending a reactivation payment. Carries `customer_action_required: true` |
| `ATTACHMENTS_REQUIRE_SYNC_SEND` | Attachments and scheduled send are mutually exclusive; send synchronously via `POST /v1/drafts/:id/send` |
| `INTERNAL_ERROR` | An unmapped dispatch error. Treat as a platform-side issue and report it with the `message_id` and `scheduled_at` |

`error_code` is a secondary discriminator (e.g. `DRAFT_REJECTED_BY_RESCAN` on a
rescan rejection). Two optional fields refine the lifecycle:

- **`customer_action_required`** (boolean) — `true` when the customer can recover by
  acting (settle billing, fix an undeliverable recipient, adjust an allowlist).
  Set today by `BILLING_LAPSED`, `BILLING_REACTIVATION_PENDING`, and all five
  recipient-verification codes (`RECIPIENT_UNDELIVERABLE`, `RECIPIENT_ADDRESS_INVALID`,
  `RECIPIENT_DOMAIN_TYPO_SUSPECTED`, `RECIPIENT_ROLE_ADDRESS`, `RECIPIENT_DISPOSABLE_ADDRESS`).
- **`dispatch_state`** (`transient` / `billing_hold` / `permanent`) —
  `billing_hold` means the row was rescheduled forward and is recoverable by
  settling billing (a single webhook per first observation, deduped across
  subsequent ticks; it promotes to `permanent` if left unresolved). `permanent`
  means the row is terminal — scheduled paths clear `send_at`; async paths roll the
  row back to `draft`.

Both fields are optional; events that predate them omit both and stay
wire-compatible.

## Retries & backoff

| Response | Outcome |
|---|---|
| `2xx` | `delivered`. Done. |
| `3xx` | `failed`, no retry — a redirect is treated as an SSRF-control violation; the redirect is not followed and its body is not captured |
| `4xx` (non-429) | `failed`, no retry — treated as a config error on your side |
| `429` | Retry per the schedule below |
| `5xx` / timeout / network | Retry per the schedule below |

A delivery gets **7 attempts** (1 initial + 6 retries) over ~31 hours, then is
`abandoned`. Per-attempt HTTP timeout is 10 seconds.

| Attempt | Wait before next |
|---|---|
| 1 → 2 | 30 seconds |
| 2 → 3 | 2 minutes |
| 3 → 4 | 10 minutes |
| 4 → 5 | 1 hour |
| 5 → 6 | 6 hours |
| 6 → 7 | 24 hours |

### Auto-disable

After **20 consecutive abandoned** deliveries, the webhook is auto-disabled:
`enabled` flips to `false`, `disabled_reason` becomes `auto_disabled_failures`, and
the account owner is emailed. While disabled, both a manual retry and a test return
`409 WEBHOOK_DISABLED` and create no delivery row. Re-enable via `PATCH /v1/webhooks/:id
{ "enabled": true }` (or the dashboard Resume button); either resets
`consecutive_failures` to zero. Codes are catalogued at
[/agents/errors](/agents/errors).

### Delivery inspection

`GET /v1/webhooks/:id/deliveries` returns recent attempts newest-first with keyset
pagination on `(created_at, id)`. Each row carries `status`, `last_error` (the
worker's own classification: `http_500`, `http_redirect_forbidden: <status>`,
`ssrf_blocked: <reason>`, `network_error: <msg>`, `webhook_disabled`), and
`response_preview` (the first 200 chars of your response body). If `last_error` is
set and `response_preview` is null, the failure happened before a body was received.

## Endpoint security contract

- **HTTPS required.** `http://` URLs are rejected at create, update, and delivery.
- **Private-network URLs blocked** at both CRUD and delivery — literal private and
  loopback IPv4/IPv6 ranges and `localhost`. The delivery worker **re-resolves the
  hostname before each POST** and rejects if the resolved IP is now private
  (DNS-rebinding defense).
- **No redirect following.** Your endpoint must return `2xx` directly.
- **Signing secret shown once**, stored encrypted at rest. Rotation is an **atomic
  swap** — the old secret stops validating on the next delivery, with no
  dual-signature grace window. If you lose the secret, rotate.

## PII redaction

When the source mailbox is in `pii_mode='redacted'`, the delivery worker redacts a
fixed set of fields **at dispatch time**, per attempt (toggling the mailbox mode
takes effect on in-flight retries immediately). The mode only ever *masks* fields —
it never suppresses an event.

Under `redacted`:

- `sender`, `recipient`, `to`, `envelope_sender`, `from_address`, `matched_pattern`
  → `<EMAIL_ADDRESS>`
- `subject` → `<REDACTED>`
- `subaddress_instance_id` → `<REDACTED>` (present on every `message.*` event;
  routing semantics at [/docs/guides/sub-addressing](/docs/guides/sub-addressing))
- nested `sender_authentication.from_domain` / `.signing_domain` → `null` (the
  categorical `verdict` and `provenance` are kept)
- `trusted_source.*` `value` / `value_ascii` → `<EMAIL_ADDRESS>`,
  `verified_domain` → `null`

**Exempt events** (operator-authored config, address is the signal — never
redacted): `recipient_blocklist.added`, `recipient_blocklist.removed`,
`recipient_blocklist.complaint_override`, `recipient_allowlist.added`,
`recipient_allowlist.removed`, `recipient_allowlist.blocked_attempt`,
`mailbox.recipient_policy_changed`, `mailbox.policy_changed`,
`sender_allowlist.added`, `sender_allowlist.removed`, `sender_blocklist.added`,
`sender_blocklist.removed`, `mailbox.sender_policy_changed`, `webhook.test`.

`message.review.expired` and `message.released` carry no inbound-sourced PII
(operator/system context only) and pass through unchanged like `.approved` /
`.denied`.

Two caveats worth designing around:

- **Payloads never carry message bodies or raw `scan_results`**, so per-detector /
  advanced PII rendering does not apply to webhooks — only the envelope fields above
  are masked. If you need body-level detail, fetch it via `GET /v1/messages/:id`,
  where the advanced controls apply.
- **A scanner `reason` string is not run through PII detection.** It is normally a
  classification (e.g. `phishing_url: …`), but a scanner that echoes matched input
  could quote an address. Treat `data.reason` as untrusted plain text — do not
  auto-linkify it or parse structure out of it.

## Trusted instruction sources: the webhook-vs-read asymmetry

Trusted instruction sources are a **read-path-only** relaxation, and this creates
two deliberate asymmetries a webhook consumer must not misread.

1. **`trusted_source.granted` / `trusted_source.revoked` are NOT exempt from PII
   redaction** — unlike the customer-authored `recipient_allowlist.*` config events.
   A grant is a security-sensitive relaxation whose payload names a third party, so
   under `redacted` mode its `value` / `value_ascii` / `verified_domain` are masked.
   The account owner can always read full detail via the authenticated
   `GET /v1/mailboxes/:id/trusted-sources` API.
2. **A webhook can never carry the trust basis.** The `agent_safety_context` on
   `message.received` is **always the untrusted baseline** (`untrusted_content:
   true` plus the inbound baseline guidance) and **never** carries the
   `instruction_trust` basis — even when the message is from a granted trusted
   sender. The relaxation is computed **per reading key** (it requires a specific
   agent-role key with the trust capability doing the read), and a delivery-time
   webhook is not tied to any reading key, so it cannot compute one. To obtain the
   relaxed context, do a follow-up authenticated `GET /v1/messages/:id` with a
   trusted-capable agent key.

Granting or loosening instruction trust is a **human dashboard action with fresh
re-authentication** — an agent (or any Bearer key) cannot enable, grant, or loosen
trust for itself, and receiving a `verified_aligned` `sender_authentication` verdict
never relaxes the untrusted-body contract. Full consumption model:
[/agents/trusted-instructions](/agents/trusted-instructions).

## See also

- [/docs/webhooks](/docs/webhooks) — human setup walkthrough (create, store secret, rotate)
- [/agents/messages](/agents/messages) — message state machine and verdict vocabulary
- [/agents/send-outcomes](/agents/send-outcomes) — the outbound outcome / `effect` contract
- [/agents/send-gates](/agents/send-gates) — why a send was blocked (decision tree)
- [/agents/security-model](/agents/security-model) — untrusted-content trust contract and per-field taxonomy
- [/agents/errors](/agents/errors) — the error-code catalog
- [/docs/sdks](/docs/sdks) — signature-verification helpers and typed clients
