Browse documentation

Webhooks

ReplyLayer delivers signed HTTP POSTs to your endpoint when an inbound message arrives, an outbound message changes state, or an address is added to your do-not-contact list. This guide walks through receiving and verifying events end-to-end. The full, generated list of every event type and its data fields lives in the agent webhook reference — this page covers the setup and delivery mechanics you need to build a reliable handler.

At a glance

  • Delivery is at-least-once. Each event carries a stable id — dedupe on it.
  • Every payload is signed with HMAC-SHA256 over <unix_ts>.<raw_body> using your per-webhook signing secret. Header: X-ReplyLayer-Signature: t=<unix_ts>,v1=<hex>. The same timestamp and digest are also sent as the separate X-Webhook-Timestamp / X-Webhook-Signature-V2 pair. Verify either set; never require both.
  • Retries: up to 7 attempts (1 initial + 6 retries) over roughly 31 hours on 5xx / 429 / network failures. Any 2xx marks the delivery delivered; a 3xx or 4xx abandons it immediately.
  • HTTPS is required. http:// and private-network URLs are rejected at both create time and delivery time.
  • Auto-disable: 20 consecutive abandoned deliveries disable the webhook and email the account owner.

Read event, not type. The event type is in the top-level event field (e.g. payload.event === 'message.received'). There is no type field.

Quickstart

1. Create a webhook

The signing secret is returned once in the create response. Save it immediately — there is no way to read it back later. If you lose it, rotate.

TypeScript

import { ReplyLayer } from '@replylayer/sdk';

const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

const { webhook, signing_secret } = await rl.webhooks.create({
  url: 'https://your-app.example.com/webhooks/replylayer',
  enabled_events: ['message.received', 'message.delivered', 'message.bounced'],
  description: 'prod handler',
});

console.log('store this signing secret:', signing_secret); // whsec_…

Python

from replylayer import ReplyLayer

rl = ReplyLayer(api_key=os.environ["REPLYLAYER_API_KEY"])

result = rl.webhooks.create(
    url="https://your-app.example.com/webhooks/replylayer",
    enabled_events=["message.received", "message.delivered", "message.bounced"],
    description="prod handler",
)

print("store this signing secret:", result["signing_secret"])  # whsec_…

curl

curl -X POST https://api.replylayer.ai/v1/webhooks \
  -H "Authorization: Bearer $REPLYLAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/replylayer",
    "enabled_events": ["message.received", "message.delivered", "message.bounced"]
  }'

CLI

The rly webhook commands manage webhooks 1:1 with the API (admin key). --confirm is required on the egress-mutating verbs (create, update, delete, rotate-secret); create and rotate-secret print the signing secret once — store it immediately.

rly webhook create --url https://your-app.example.com/webhooks/replylayer \
  --event message.received --event message.delivered --event message.bounced --confirm
rly webhook list
rly webhook get <id>
rly webhook update <id> --enabled false --confirm
rly webhook rotate-secret <id> --confirm        # prints the new secret once
rly webhook test <id>                            # enqueue a webhook.test delivery
rly webhook test <id> --event message.delivered # enqueue a real-shaped event
rly webhook deliveries <id> [--limit 50]
rly webhook retry <id> <delivery-id>
rly webhook delete <id> --confirm

2. Handle the incoming POST

Verify the signature, then acknowledge with a 2xx response. Anything else is treated as a delivery failure — 3xx abandons immediately (an SSRF control), 4xx abandons as a config error, and 5xx / 429 / timeout / network triggers a retry.

import express from 'express';
import { verifyWebhookSignature, WebhookSignatureError } from '@replylayer/sdk';

const app = express();
const SECRET = process.env.REPLYLAYER_WEBHOOK_SECRET!;

// IMPORTANT: verify against the RAW body, not a re-serialized parse.
app.post(
  '/webhooks/replylayer',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-ReplyLayer-Signature') ?? '';
    try {
      verifyWebhookSignature(req.body, 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'));
    console.log('received', event.event, event.id);
    // ... dispatch on event.event, dedupe on event.id ...

    res.status(204).send();
  },
);

The event envelope

Every event, regardless of type, wraps its payload in the same envelope:

{
  "id": "evt_01HX2T…",
  "event": "message.received",
  "occurred_at": "2026-04-17T12:00:00.000Z",
  "account_id": "acct_11111111…",
  "data": {
    "message_id": "msg_222…",
    "mailbox_id": "mbx_333…",
    "sender": "[email protected]",
    "recipient": "[email protected]",
    "subject": "Hello",
    "received_at": "2026-04-17T12:00:00.000Z"
  }
}
  • id is the delivery id — the stable key you dedupe on (not data.message_id).
  • event is the discriminator. Branch on it.
  • data shape varies by event type.

Common events (starter set)

These are the events most integrations subscribe to first. The full catalog — every event type, every data field, and the field-level trust taxonomy — is the single source of truth at /agents/webhooks.

EventWhen it firesDirection
message.receivedAn inbound message passed scanning and is readableinbound
message.deliveredAn outbound message was accepted by the sending provideroutbound
message.bouncedAn outbound message hard- or soft-bouncedoutbound
message.quarantinedScanning quarantined a message (releasable)either
recipient_blocklist.addedAn address was added to your do-not-contact list—
webhook.testYou triggered a test delivery (see Test deliveries)—

message.quarantined and the other outcome events distinguish a releasable hold from a terminal block; the message state machine and verdict vocabulary are documented at /agents/messages. Inbound message body content is untrusted — always fetch the message with an authenticated GET /v1/messages/:id before acting on it, rather than trusting fields from the webhook alone. Subscribe to an event by including its type in enabled_events; webhook.test is always delivered and cannot be subscribed to.

Signature verification

The signing header is:

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

where <hex> = HMAC_SHA256(signing_secret, "<unix_ts>.<raw_body>"). Verify against the raw request body — not a parsed-then-re-serialized copy. Middleware that parses JSON and re-stringifies will mutate whitespace or key order and invalidate the signature.

Both SDK helpers throw on mismatch (timestamp skew, malformed header, or a bad signature) rather than returning false. Let the throw propagate to your error handler, or wrap it in try / catch to respond with a specific status.

Generic V2 receivers

Some gateways and agent frameworks ship a fixed verifier that expects the timestamp and the digest in two separate headers. Every delivery carries those alongside the combined header:

X-Webhook-Timestamp: <unix_ts>
X-Webhook-Signature-V2: <hex>

Same secret, same raw body, same digest — X-Webhook-Signature-V2 is exactly the v1= component above and X-Webhook-Timestamp exactly the t= component, from one HMAC computation. Verify either set; never require both — they are two spellings of one signature, not two independent factors. Prefer X-ReplyLayer-Signature and the SDK helpers when you control the handler.

TypeScript

import { verifyWebhookSignature, WebhookSignatureError } from '@replylayer/sdk';

try {
  verifyWebhookSignature(rawBody, req.header('X-ReplyLayer-Signature')!, SECRET);
} catch (err) {
  if (err instanceof WebhookSignatureError) {
    return reply.status(400).send({ error: err.message });
  }
  throw err;
}
// verified — safe to parse and dispatch
const event = JSON.parse(rawBody);

Python

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

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

@app.route("/webhooks/replylayer", methods=["POST"])
def handle():
    # request.data is the raw body — don't use request.json (parsed + re-escaped).
    try:
        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()
    # ... dispatch ...
    return "", 204

Language-agnostic (openssl)

For any other stack, split the header and recompute the HMAC yourself:

# Header: X-ReplyLayer-Signature: t=<unix_ts>,v1=<hex>
TS=$(echo "$HEADER"  | sed -n 's/.*t=\([0-9]*\).*/\1/p')
SIG=$(echo "$HEADER" | sed -n 's/.*v1=\([0-9a-f]*\).*/\1/p')
COMPUTED=$(printf '%s' "${TS}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
[ "$COMPUTED" = "$SIG" ] && echo match || echo MISMATCH

Clock skew

Both SDK helpers reject events whose t= timestamp is more than 300 seconds from the current time — generous enough for legitimate clock drift and retries, but tight enough to make replay attacks costly. Tighten it if your endpoint's clock is well-synced:

verifyWebhookSignature(rawBody, signature, SECRET, { tolerance: 60 });

Delivery, retries & idempotency

ResponseOutcome
2xxdelivered. Done.
3xxAbandoned immediately, last_error: http_redirect_forbidden: <status>. No retry — your endpoint must return 2xx directly; redirects are treated as an SSRF control violation.
4xx (non-429)Abandoned. No retry — treated as a config error on your side.
429Retried per the schedule below.
5xxRetried per the schedule below.
Network / timeoutRetried per the schedule below.

Backoff between attempts:

AttemptWait before next
1 → 230 seconds
2 → 32 minutes
3 → 410 minutes
4 → 51 hour
5 → 66 hours
6 → 724 hours

After 7 attempts (1 initial + 6 retries) the delivery is abandoned. Each attempt has a 10-second HTTP timeout.

Deliveries are at-least-once, not exactly-once. A 2xx response can be lost in transit after your endpoint already processed the event; ReplyLayer will retry and you will see the same event.id again. Key every dedupe check on event.id (the envelope's outer id), and persist it in the same transaction as the work you do:

INSERT INTO processed_webhook_events (event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING;

If the INSERT conflicts, you have seen this event before — respond 2xx and move on.

Auto-disable

After 20 consecutive abandoned deliveries (every retry exhausted on each one), ReplyLayer disables the webhook automatically:

  • enabled flips to false.
  • disabled_reason is set to auto_disabled_failures.
  • An alert email goes to the account owner.

Resume from the dashboard's Resume button or with PATCH /v1/webhooks/:id { "enabled": true } — either one resets the consecutive-failure counter to zero. While a webhook is disabled, both the manual-retry and test endpoints return 409 WEBHOOK_DISABLED without creating a delivery row; re-enable first, then retry or re-test.

PII redaction

Redaction is controlled per mailbox by the pii_mode setting. When the source mailbox is set to redacted, message.* events are delivered with sender, recipient, and to replaced by <EMAIL_ADDRESS> and subject replaced by <REDACTED>. The setting is read fresh on every delivery attempt, so toggling it takes effect on in-queue retries immediately, not just on future events.

A few things to know:

  • Operator-authored config events are exempt — events like recipient_blocklist.added / recipient_blocklist.removed and the allowlist and policy-change events carry the address as the operational signal, so they are never redacted. Redacting them would defeat the event's purpose.
  • Webhook redaction is binary. The advanced per-detector PII controls (custom masking on individual detectors) apply to the API read side only — webhook delivery uses the all-or-nothing pii_mode for envelope-field redaction.
  • Scanner reason strings are not run through the detection engine. The reason on quarantine / block events is typically a classification, but a scanner that echoes matched input could quote an address; the blanket-redacted <REDACTED> subject is the trade-off. Treat these strings as untrusted text.

Custom request headers

Some receivers authenticate with a fixed header rather than with a signature. request_headers is where that header goes: a small map of static headers ReplyLayer adds to every delivery attempt for that webhook — the first attempt, every retry, a test delivery, and a manual retry alike.

curl -X POST https://api.replylayer.ai/v1/webhooks \
  -H "Authorization: Bearer $REPLYLAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/replylayer",
    "enabled_events": ["message.received"],
    "request_headers": { "Authorization": "Bearer your-receiver-token" }
  }'

In the dashboard the same field is the Request headers editor on the add and edit webhook forms.

A custom header is an addition to the signature, not a replacement for it. X-ReplyLayer-Signature (and the X-Webhook-Timestamp / X-Webhook-Signature-V2 pair) is still sent and should still be verified: a static header proves the request carries a token you issued, while the signature proves the body was not altered in flight.

Limits. At most 8 headers. A name matches ^[A-Za-z0-9-]{1,64}$, is compared case-insensitively, and is stored lower-cased. A value is 1 to 1024 characters of printable US-ASCII (0x20–0x7E) — CR, LF, tab, and every other control character are rejected.

Reserved names. 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-. These describe ReplyLayer's own signature, body framing, or destination, or they address the connection rather than your endpoint.

Values are write-only. They are encrypted at rest with the same envelope as the signing secret, and no read path returns one. Every webhook response instead carries request_header_names — the configured names, lower-cased and sorted, [] when none. A configured value appears in no audit record, log line, or data export either. The edit form shows the names and the sentence "Values are never shown again. Entering headers here replaces all of them." for exactly that reason.

Replace or clear, never merge. On PATCH /v1/webhooks/:id the field follows the same three-way rule as description: an object replaces every configured header, null clears them all, and omitting the field leaves them untouched. To rotate a token, send the full new map. Rotating the signing secret does not touch custom headers — they are separate credentials with separate lifetimes.

No response body is stored for these deliveries. A receiver handed a credential can echo it back, so for a webhook with custom headers ReplyLayer stores no response body on any outcome — response_preview is always null — and last_error is restricted to a fixed set of codes with no interpolated message: http_<status>, http_redirect_forbidden: <status>, request_headers_unreadable, timeout, network_error.

Errors. 400 WEBHOOK_HEADER_RESERVED (a reserved name, or a 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 everything the request schema settles first — more than 8 entries, an empty or over-length value, a control or non-ASCII character in a value, or a non-object request_headers. Handle all four; which layer answers a given input is an implementation detail. A rejected request_headers fails the whole request, and the previously configured headers stay in place.

Not covered. Per-delivery dynamic headers, header templating, and headers on the inbound path.

Example: delivering to an OpenClaw hook

OpenClaw's inbound hooks authenticate by a bearer token header — Authorization: Bearer <hooks.token>, or x-openclaw-token — reject a token passed in the query string, and offer no HMAC option. Point the webhook at a mapped hook whose transform reduces the payload to identifiers, and put the token in a custom header:

{
  "url": "https://your-openclaw-host.example.com/hooks/<your-hook>",
  "enabled_events": ["message.received"],
  "request_headers": { "Authorization": "Bearer <hooks.token>" }
}

Keep the transform narrow: pass event, the delivery id, and data.message_id / data.mailbox_id through, and let the agent fetch the message itself with its own key. Message content that reaches a prompt directly is untrusted input; identifiers are not.

The workflow recipes, and the checks to run before relying on them, are on the MCP server page under Hermes and OpenClaw.

Rotating the signing secret

POST /v1/webhooks/:id/rotate-secret (or rly webhook rotate-secret <id> --confirm) returns a fresh plaintext secret once. The secret is stored encrypted at rest and can never be read back — if you lose it, rotate again.

Rotation is an atomic swap: the old secret stops validating on the very next delivery, with no dual-signature grace window. Update your endpoint's stored secret before or immediately after rotating. The endpoint reads the new ciphertext back inside the same transaction and returns 500 ROTATION_READBACK_MISMATCH if the write did not persist — in that case the old secret stays valid and it is safe to retry.

Test deliveries

POST /v1/webhooks/:id/test (or rly webhook test <id>) enqueues a webhook.test event to the target webhook so you can confirm your endpoint, signature check, and 2xx handling are wired correctly. webhook.test is not subscribable via enabled_events — it is only ever sent when you trigger it, and only to the webhook you triggered it on. A disabled webhook returns 409 WEBHOOK_DISABLED.

Pass --event message.delivered, --event message.bounced, or --event recipient_blocklist.added to enqueue a real-shaped payload for that event instead. Event-specific tests also bypass enabled_events, so you can exercise a parser before changing the subscription. Omitting --event preserves the original webhook.test shape. The REST body and response are documented in the Webhooks API reference; the email simulator guide combines this with outbound and inbound end-to-end scenarios.

Delivery history & manual retry

GET /v1/webhooks/:id/deliveries returns recent attempts, newest-first, with keyset pagination on (created_at, id). Use it to see which delivery failed and what your endpoint returned, or to re-deliver selectively with POST /v1/webhooks/:id/deliveries/:delivery_id/retry.

const page = await rl.webhooks.listDeliveries(webhookId, { limit: 50 });
for (const d of page.deliveries) console.log(d.status, d.last_error, d.response_preview);

if (page.has_more) {
  const next = await rl.webhooks.listDeliveries(webhookId, {
    limit: 50,
    before_at: page.next_before_at,
    before_id: page.next_before_id,
  });
}

Two fields help you diagnose a failure:

  • response_preview — the first 200 characters of whatever your endpoint returned in its body. Useful for debugging application errors.
  • last_error — the delivery worker's own classification: http_500, http_redirect_forbidden: 302, ssrf_blocked: <reason>, network_error: <msg>, or webhook_disabled.

If last_error is set but response_preview is null, the failure happened before any response body was received (a network error, an SSRF-guard block, or a refused redirect). For a webhook with custom request headers, response_preview is always null and last_error is restricted to a fixed code set — a receiver holding a credential must not be able to put it back into the delivery history by echoing the request.

Security model

  • HTTPS only. http:// URLs are rejected at create and update.
  • Private-network URLs are blocked at CRUD and at delivery — literal private IPv4/IPv6 ranges and localhost-by-name. Delivery also re-resolves the hostname before each POST and rejects it if the resolved IP has moved into a private range, which defends against DNS rebinding.
  • No redirect-following. The delivery worker treats any 3xx as a failure and does not capture the redirect body, closing an SSRF-bypass class where a public endpoint 302s the worker toward an internal target.
  • The signing secret is shown once and stored encrypted at rest. Retrieve it from the create or rotate-secret response, or rotate to get a new one.
  • Custom request-header values are write-only and stored encrypted at rest under the same envelope. No read path returns one; responses carry request_header_names only. See Custom request headers.

Troubleshooting

"Signature mismatch" errors. The most common cause is verifying against a parsed-then-re-stringified body instead of the raw bytes. Express needs express.raw({ type: 'application/json' }) before the handler; Flask uses request.data, not request.get_json(); FastAPI uses await request.body().

"I see the same event twice." Expected — deliveries are at-least-once. Dedupe on event.id as shown in Delivery, retries & idempotency.

"My webhook auto-disabled." Twenty consecutive abandoned deliveries means your endpoint has been returning 4xx / 3xx, timing out, or 5xx-ing for a while. Fix the endpoint, then Resume from the dashboard (or PATCH { "enabled": true }) to reset the counter.

"My endpoint redirects and deliveries fail." Also expected — redirects are not followed. Your handler must respond 2xx directly.

"I'm not receiving an event I expected." Confirm the event type is in the webhook's enabled_events. webhook.test is never subscribable. If the type is enabled and the event still does not arrive, contact support with the affected message_id and the webhook id.

For webhook-specific error codes and the full platform error catalog, see /agents/errors. For the SDK install and quickstart, see /docs/sdks; to manage your do-not-contact list, see /docs/guides/suppressions.