Browse documentation

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.

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

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" }
}
RuleValue
Countat most 8
Name^[A-Za-z0-9-]{1,64}$, compared case-insensitively, stored lower-cased
Value1–1024 characters, printable US-ASCII 0x20–0x7E (no CR, LF, tab, or other control characters)
Reservedcontent-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, null clears it, and omitting the field leaves it untouched. To rotate a token, send the complete new map. A rejected request_headers fails 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_preview is always null for them and last_error is restricted to http_<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 or proxy- prefix), 400 WEBHOOK_HEADER_DUPLICATE (two names differing only by case), 400 WEBHOOK_HEADER_INVALID (a name outside the grammar), and 400 VALIDATION_ERROR for 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-object request_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

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

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

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
verified_recipient.confirmedA 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 resentrecipient_id, email, confirmed_at, attestation (confirmed or account_attested)Exempt
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_domain → null
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_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_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_RECIPIENT_NOT_VERIFIEDA 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_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
SELF_HOSTED_SEND_BUDGET_EXCEEDEDA 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_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
ESTATE_CONTINUITY_REPLY_ONLYThe 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_DISABLEDThe 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_DISABLEDSame gate, read_only mailbox: the agent-origin draft this was dispatching for cannot proceed
SEND_WINDOW_CLOSEDThe mailbox's weekly send window (outside_action='block') rejected this agent-origin send. Temporal, not a capability denial — retrying inside the window succeeds
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 — 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 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.
  • 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_names only. 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 every message.* event; routing semantics at /docs/guides/sub-addressing)
  • nested sender_authentication.from_domain / .signing_domain → null (the categorical verdict and provenance are kept)
  • trusted_source.* value / value_ascii → <EMAIL_ADDRESS>, verified_domain → null

Exempt events (operator-authored config, address is the signal — never redacted): recipient_blocklist.added, recipient_blocklist.removed, recipient_blocklist.complaint_override, recipient_allowlist.added, recipient_allowlist.removed, recipient_allowlist.blocked_attempt, 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 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