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.
- Verify the signature against the raw request body. Recompute
HMAC_SHA256(signing_secret, "<unix_ts>.<raw_body>")and compare to thev1=component of theX-ReplyLayer-Signatureheader. Verify the bytes you received — a parse-then-re-serialize round trip mutates whitespace/key order and invalidates the signature. Reject with a4xxon mismatch. - Dedupe on
event.id. The2xxyou send can be lost after you already processed the event; ReplyLayer will retry and you will see the sameevent.idagain. Persist the id (ideally in the same transaction as your side effect) and no-op on a repeat. - Branch on
event.event. Treat the enum as open for forward-compat: fall through unknown event types to a no-op2xxinstead of erroring, so a newly added event never trips your auto-disable counter. - 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 authenticatedGET /v1/messages/:id. - 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.
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 "", 204Subscribing
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
| 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. The effect object is documented under
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.
Attachment previews
Preview events are metadata only — never extracted text. For the attachment staging and preview model see /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 and /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. 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
and /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.
"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.
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 |
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 |
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) —truewhen the customer can recover by acting (settle billing, fix an undeliverable recipient, adjust an allowlist). Set today byBILLING_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_holdmeans the row was rescheduled forward and is recoverable by settling billing (a single webhook per first observation, deduped across subsequent ticks; it promotes topermanentif left unresolved).permanentmeans the row is terminal — scheduled paths clearsend_at; async paths roll the row back todraft.
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.
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
2xxdirectly. - 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 everymessage.*event; routing semantics at /docs/guides/sub-addressing)- nested
sender_authentication.from_domain/.signing_domain→null(the categoricalverdictandprovenanceare 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 viaGET /v1/messages/:id, where the advanced controls apply. - A scanner
reasonstring 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. Treatdata.reasonas 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.
trusted_source.granted/trusted_source.revokedare NOT exempt from PII redaction — unlike the customer-authoredrecipient_allowlist.*config events. A grant is a security-sensitive relaxation whose payload names a third party, so underredactedmode itsvalue/value_ascii/verified_domainare masked. The account owner can always read full detail via the authenticatedGET /v1/mailboxes/:id/trusted-sourcesAPI.- A webhook can never carry the trust basis. The
agent_safety_contextonmessage.receivedis always the untrusted baseline (untrusted_content: trueplus the inbound baseline guidance) and never carries theinstruction_trustbasis — 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 authenticatedGET /v1/messages/:idwith 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
- /docs/webhooks — human setup walkthrough (create, store secret, rotate)
- /agents/messages — message state machine and verdict vocabulary
- /agents/send-outcomes — the outbound outcome /
effectcontract - /agents/send-gates — why a send was blocked (decision tree)
- /agents/security-model — untrusted-content trust contract and per-field taxonomy
- /agents/errors — the error-code catalog
- /docs/sdks — signature-verification helpers and typed clients