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 separateX-Webhook-Timestamp/X-Webhook-Signature-V2pair. 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, nottype. The event type is in the top-leveleventfield (e.g.payload.event === 'message.received'). There is notypefield.
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> --confirm2. 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"
}
}idis the delivery id — the stable key you dedupe on (notdata.message_id).eventis the discriminator. Branch on it.datashape 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.
| Event | When it fires | Direction |
|---|---|---|
message.received | An inbound message passed scanning and is readable | inbound |
message.delivered | An outbound message was accepted by the sending provider | outbound |
message.bounced | An outbound message hard- or soft-bounced | outbound |
message.quarantined | Scanning quarantined a message (releasable) | either |
recipient_blocklist.added | An address was added to your do-not-contact list | — |
webhook.test | You 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 "", 204Language-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 MISMATCHClock 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
| Response | Outcome |
|---|---|
| 2xx | delivered. Done. |
| 3xx | Abandoned 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. |
| 429 | Retried per the schedule below. |
| 5xx | Retried per the schedule below. |
| Network / timeout | Retried per the schedule below. |
Backoff between attempts:
| 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 |
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:
enabledflips tofalse.disabled_reasonis set toauto_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.removedand 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_modefor envelope-field redaction. - Scanner reason strings are not run through the detection engine. The
reasonon 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>, orwebhook_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_namesonly. 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.