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

<!-- 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"
  }
}
  • 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.

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

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).

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.