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>. - 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.
<!-- docs-lint:skip-compile -->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.
TypeScript
<!-- docs-lint:skip-compile -->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.
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).
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.
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.