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.

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.

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).

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.

{
  "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": "[email protected]",
    "recipient": "[email protected]",
    "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 -->
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();
});
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 for exactly which fields are affected.

Message lifecycle & scan outcomes

EventFires whenKey data fieldsPII
message.receivedInbound message passed scanning and is readablesender, recipient, subject, received_at, subaddress_instance_id, optional safety, optional sender_authentication, agent_safety_context (always the untrusted baseline — see below)Redacted
message.deliveredOutbound delivery confirmed by the delivery providerto, delivered_at, subaddress_instance_id, optional attachments[]Redacted
message.bouncedHard or soft bounceto, bounce_type, failed_at, subaddress_instance_id, optional attachments[]Redacted
message.quarantinedA scanner quarantined the message (releasable)direction, reason, quarantined_at, subaddress_instance_id, effectRedacted
message.scanner_blockedA scanner blocked the message (terminal)direction, reason, blocked_at, subaddress_instance_id, effectRedacted
message.review.queuedOutbound message routed to human review (pending_review)direction (outbound), subject, recipient, summary_reasons (non-empty), origin, trigger_source, effectRedacted
message.review.approvedA reviewer approved a pending_review messagereviewed_by_type, reviewed_at, reason (nullable), prior_stateNo PII fields
message.review.deniedA reviewer denied a pending_review message (→ blocked)reviewed_by_type, reviewed_at, reason (nullable), prior_stateNo PII fields
message.review.expiredThe 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_stateNo PII fields
message.releasedA 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. The effect object is documented under The effect object.

Scheduled send & dispatch

EventFires whenKey data fieldsPII
message.scheduledA draft's send_at is first setdirection, recipient, scheduled_at, attempt_countRedacted (recipient)
message.rescheduledA scheduled draft's send_at shifts by >60sdirection, recipient, from_send_at, to_send_atRedacted (recipient)
message.schedule_cancelledsend_at cleared, or a scheduled draft deleteddirection, recipient, was_scheduled_at, reason? (draft_deleted on delete)Redacted (recipient)
message.dispatch_failedPermanent dispatch failure on a path with no live responsedirection, 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.

Attachment previews

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

EventFires whenKey data fieldsPII
attachment.preview.queuedA derivative was created (pending) and extraction enqueuedattachment_index, variant, queued_atMetadata only
attachment.preview.readyPreview text extracted, scan-passed, and servableattachment_index, variant, ready_at, kind, char_count, page_count, truncatedMetadata only
attachment.preview.blockedExtracted text was rejected by a scannerattachment_index, variant, reason_code, blocked_atMetadata only
attachment.preview.failedExtraction failed after the retry budgetattachment_index, variant, reason_code, failed_atMetadata 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 and /docs/guides/recipient-allowlist.

EventFires whenKey data fieldsPII
recipient_blocklist.addedAddress suppressed (bounce, complaint, unsubscribe, or manual add)address, reason, added_atExempt
recipient_blocklist.removedAddress removed from the suppression listaddress, reason, removed_atExempt
recipient_blocklist.complaint_overrideA complaint-history removal lock was overridden to remove an addressaddress, override_reason, actor_email, latest_complaint_at, complaint_count, overridden_atExempt
recipient_allowlist.addedAddress added to a mailbox's recipient allowlistaddress, added_at, added_by_actor_type, added_by_actor_idExempt
recipient_allowlist.removedAddress removed from a mailbox's recipient allowlistaddress, removed_at, removed_by_actor_type, removed_by_actor_idExempt
recipient_allowlist.blocked_attemptA send was rejected by the allowlist gaterecipient, actor_type, actor_id, origin, draft_id, blocked_atExempt
mailbox.recipient_policy_changedA mailbox's outbound recipient_policy_mode flippedprevious_mode, current_mode, changed_at, actor_type, actor_idExempt
mailbox.policy_changedThe 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_atExempt

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. The allowlist/blocklist mutation events are exempt (customer-authored config); inbound_sender.blocked is not exempt because it carries the sender's observed addresses.

EventFires whenKey data fieldsPII
inbound_sender.blockedAn inbound message was rejected by the firewallenvelope_sender, from_address, matched_field, matched_pattern, mode, reason_code, matched_list, blocked_atRedacted (sender fields)
sender_allowlist.addedAddress added to a mailbox's inbound allowlistaddress, added_at, added_by_actor_type, added_by_actor_idExempt
sender_allowlist.removedAddress removed from a mailbox's inbound allowlistaddress, removed_at, removed_by_actor_type, removed_by_actor_idExempt
sender_blocklist.addedAddress added to the account-wide inbound blocklistaddress, reason, added_atExempt
sender_blocklist.removedAddress removed from the account-wide inbound blocklistaddress, reason, removed_atExempt
mailbox.sender_policy_changedA mailbox's sender_policy_mode flippedprevious_mode, current_mode, changed_at, actor_type, actor_idExempt

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 and /agents/trusted-instructions.

EventFires whenKey data fieldsPII
trusted_source.grantedA trusted instruction source was granted on a mailboxmailbox_id, source_id, grain, value, value_ascii, verified_domain, expires_atNot exempt — value/value_ascii<EMAIL_ADDRESS>, verified_domainnull
trusted_source.revokedA 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.

EventFires whenKey data fieldsPII
legal_hold.appliedA legal hold was placed (freezes data against deletion/purge)legal_hold_id, scope, mailbox_id, case_reference, applied_at, released_atNo PII fields
legal_hold.releasedA legal hold was liftedlegal_hold_id, scope, mailbox_id, case_reference, applied_at, released_atNo PII fields

Test

EventFires whenKey data fieldsPII
webhook.testA test delivery was triggered against a specific webhookwebhook_id, triggered_atExempt

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.

"effect": {
  "effect_status": "blocked",
  "verdict": "blocked",
  "releasable": false,
  "terminal": true,
  "retryable": false,
  "failure_class": "model_judgment"
}
FieldTypeMeaning
effect_statusstring (open enum)sent / held_for_review / held_infrastructure / blocked — the one-read branch discriminator
verdictstring | nullVendor-neutral scanner verdict (clean / warning / review_required / quarantined / blocked), or null
releasablebooleantrue iff human-releasable (held_for_review)
terminalbooleantrue iff no further automatic transition
retryablebooleantrue 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_classstring | nullinference_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_statusheld_for_review / held_infrastructure / blocked, never sent). The verdict vocabulary is defined at /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_codeMeaning
RESCAN_REJECTEDThe authoritative rescan at dispatch time rejected the content (error_code: DRAFT_REJECTED_BY_RESCAN)
ALLOWLISTRecipient is not on the mailbox's recipient allowlist
RECIPIENT_AGENT_CONTAINEDWhen 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
SUPPRESSEDRecipient is suppressed (do-not-contact)
ACCOUNT_SUSPENDED / MAILBOX_SUSPENDED / DOMAIN_SUSPENDEDA suspension was imposed between schedule and dispatch
FORBIDDENSandbox trial expired or another policy-level block
SANDBOX_TRIAL_BUDGET_EXHAUSTEDThe sandbox cumulative-send budget was used up — see /docs/limits
SANDBOX_THROTTLE_EXCEEDEDThe shared-domain outbound throttle rejected the send (or its backend was unreachable and it failed closed)
RECIPIENT_UNDELIVERABLERecipient 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_INVALIDThe recipient fails a strict syntax check beyond basic email-format validation
RECIPIENT_DOMAIN_TYPO_SUSPECTEDThe recipient domain looks like a single-character typo of a common consumer mail provider
RECIPIENT_ROLE_ADDRESSThe recipient is a role/distribution mailbox (noreply@, etc.), not an individual inbox. Exempt on a reply / thread continuation
RECIPIENT_DISPOSABLE_ADDRESSThe recipient domain is a disposable/temporary email provider. Exempt on a reply / thread continuation
STALEDispatch would have occurred long after the requested send_at; treated as permanent
ATTEMPTS_EXHAUSTEDThe transient retry budget was exhausted without a successful dispatch
MAILBOX_DELETEDThe mailbox was deleted between schedule and dispatch
SMTP_REJECTEDDefinitive SMTP rejection on a self-hosted transport send
SELF_HOSTED_DECRYPT_FAILEDA self-hosted transport credential failed to decrypt at dispatch time; the row stays in draft for re-dispatch once resolved
DOMAIN_UNAVAILABLEReserved: direct-send with no viable provider available
PROVIDER_REJECTEDThe delivery provider definitively rejected the send (async path); the row rolls back to draft
OUTBOUND_DISPATCH_WATCHDOG_TIMEOUTAn infrastructure hold (scanner unreachable / queue stalled), not a content verdict; the row rolls back to draft for retry
BILLING_LAPSEDSending is paused because billing lapsed. Carries customer_action_required: true
BILLING_REACTIVATION_PENDINGSending is paused pending a reactivation payment. Carries customer_action_required: true
ATTACHMENTS_REQUIRE_SYNC_SENDAttachments and scheduled send are mutually exclusive; send synchronously via POST /v1/drafts/:id/send
INTERNAL_ERRORAn 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

ResponseOutcome
2xxdelivered. Done.
3xxfailed, 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
429Retry per the schedule below
5xx / timeout / networkRetry 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.

AttemptWait before next
1 → 230 seconds
2 → 32 minutes
3 → 410 minutes
4 → 51 hour
5 → 66 hours
6 → 724 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.

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)
  • nested sender_authentication.from_domain / .signing_domainnull (the categorical verdict and provenance are kept)
  • trusted_source.* value / value_ascii<EMAIL_ADDRESS>, verified_domainnull

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.

See also