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.
The same timestamp and digest are also sent as two separate headers, for receivers whose verifier expects that packaging:
X-Webhook-Timestamp: <unix_ts>
X-Webhook-Signature-V2: <hex>X-Webhook-Signature-V2 is byte-for-byte the v1= component and
X-Webhook-Timestamp the t= component — one HMAC, two packagings, same ±300 s
window. Verify either set; never require both. No delivery-id header
(X-Request-ID, webhook-id, svix-id, X-GitHub-Delivery) is sent: dedupe on
the envelope's id inside your handler, per step 2 above.
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.
Custom request headers
request_headers on POST /v1/webhooks and PATCH /v1/webhooks/:id is a map of
static headers ReplyLayer adds to every delivery attempt for that webhook —
first attempt, every retry, a test delivery, and a manual retry alike. It exists
for a receiver that authenticates by a fixed header rather than by an HMAC.
{
"url": "https://your-host.example.com/hooks/inbound",
"enabled_events": ["message.received"],
"request_headers": { "Authorization": "Bearer your-receiver-token" }
}| Rule | Value |
|---|---|
| Count | at most 8 |
| Name | ^[A-Za-z0-9-]{1,64}$, compared case-insensitively, stored lower-cased |
| Value | 1–1024 characters, printable US-ASCII 0x20–0x7E (no CR, LF, tab, or other control characters) |
| Reserved | content-type, content-length, host, user-agent, x-replylayer-signature, x-webhook-timestamp, x-webhook-signature-v2, transfer-encoding, connection, expect, te, upgrade, keep-alive, trailer, and any name beginning with proxy- |
- Write-only. No read path returns a value. Every webhook response carries
request_header_names: string[]— lower-cased, sorted,[]when none. A configured value appears in no audit record, log line, or data export either. Do not build a flow that reads a configured value back; there is none. - Replace or clear, never merge. On PATCH, the same three-way rule as
description: an object replaces the whole map,nullclears it, and omitting the field leaves it untouched. To rotate a token, send the complete new map. A rejectedrequest_headersfails the whole PATCH and the configured headers stay as they were. - The signature is still sent and still authoritative. A custom header proves the request carries a token the account issued; the signature proves the body was not altered. Verify the signature regardless.
- These deliveries store no response body. A receiver holding a credential
could echo it back, so
response_previewis alwaysnullfor them andlast_erroris restricted tohttp_<status>,http_redirect_forbidden: <status>,request_headers_unreadable,timeout,network_error— no interpolated message. Branch on those codes, not on a body preview. - Errors.
400 WEBHOOK_HEADER_RESERVED(reserved name orproxy-prefix),400 WEBHOOK_HEADER_DUPLICATE(two names differing only by case),400 WEBHOOK_HEADER_INVALID(a name outside the grammar), and400 VALIDATION_ERRORfor what the request schema settles first — more than 8 entries, an empty or over-length value, a control or non-ASCII character in a value, a non-objectrequest_headers. Handle all four: which layer answers a given input is an implementation detail, not a contract. - Out of scope. Per-delivery dynamic headers, header templating, and headers on the inbound path.
OpenClaw. OpenClaw's inbound hooks authenticate by a bearer token header —
Authorization: Bearer <hooks.token>, or x-openclaw-token — reject a token in
the query string, and offer no HMAC. Point the webhook at a mapped hook whose
transform reduces the payload to identifiers (event, the envelope id,
data.message_id, data.mailbox_id) and carry the token in a custom header; the
agent then fetches the message with its own key rather than taking content from
the hook payload. The recipes, and the checks to run before relying on them, are on
the MCP server page.
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 |
Supervised holds. On a Supervised mailbox, once holds are switched on for your
account, an agent's email to anyone not yet approved, or an agent send with a
content warning, is held for the account owner instead of refused. Approving sends that
one email; the owner can also approve and add the person to the mailbox's approved
people, which fires recipient_allowlist.added and lets later emails to them send
straight away. Otherwise every email to that person is held again. It fires
message.review.queued with trigger_source: "mailbox_policy" ("both" when a
content check also asked for review) and a summary_reasons line naming the cause,
such as Held: first email to this recipient. Approval fires message.review.approved
and then message.released. The send response and GET /v1/messages/:id carry the
typed cause in hold_context.review_causes and the owner's review page in
hold_context.review_url. While holds are off, the same email is refused with
403 RECIPIENT_NOT_ON_ALLOWLIST and no event fires.
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 |
verified_recipient.confirmed | A recipient became sendable on a Sandbox account (they confirmed a verification request, or the account attested them) — a held send to them can now be resent | recipient_id, email, confirmed_at, attestation (confirmed or account_attested) | 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_RECIPIENT_NOT_VERIFIED | A Sandbox send's recipient is not one the account may email yet — add them so they confirm by link, or add pay-as-you-go credit — see /agents/send-gates |
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 |
SELF_HOSTED_SEND_BUDGET_EXCEEDED | A self-hosted send was refused before dispatch: its per-send time budget ran out (most often a slow DNS/SMTP host) before the SMTP session ever started, so nothing was sent. Transient on scheduled send — retried with the standard backoff, promoting to ATTEMPTS_EXHAUSTED / STALE if it never recovers, never surfaced under this code on that path. Fires under this code immediately on the async-dispatch (Prefer: respond-async) path, which has no retry loop of its own; the row rolls back to draft |
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 |
ESTATE_CONTINUITY_REPLY_ONLY | The sending mailbox is a trial-era continuity mailbox: it still receives, and may reply to a participant of an existing thread, but cannot start a new one. Reply within the thread, or send from a sending mailbox (any mailbox whose continuity is false; set up at app.replylayer.ai/mailboxes). See /agents/send-gates |
AGENT_SENDING_DISABLED | The mailbox was flipped to read_only / draft_only after this send was enqueued, so the agent-origin dispatch is no longer permitted. The account owner re-enables sending from the dashboard |
AGENT_DRAFTING_DISABLED | Same gate, read_only mailbox: the agent-origin draft this was dispatching for cannot proceed |
SEND_WINDOW_CLOSED | The mailbox's weekly send window (outside_action='block') rejected this agent-origin send. Temporal, not a capability denial — retrying inside the window succeeds |
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
— or the webhook has custom request headers, for which
no response body is ever stored.
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.
- Custom request-header values are write-only, stored encrypted at rest under
the same envelope, and returned by no read path — responses carry
request_header_namesonly. See Custom request headers.
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,
verified_recipient.confirmed,
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