# 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](/agents/webhooks) — 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

```ts
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

```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

```bash
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.

```bash
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 -->
```ts
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:

```json
{
  "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": "alice@example.com",
    "recipient": "bob@example.com",
    "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`](/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](#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`](/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 -->
```ts
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

```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:

```bash
# 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:

```ts
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:

```sql
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](/docs/api/webhooks#send-a-test-event); the
[email simulator guide](/docs/guides/simulator) 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`.

```ts
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](#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`](/agents/errors). For the SDK install and quickstart, see
[`/docs/sdks`](/docs/sdks); to manage your do-not-contact list, see
[`/docs/guides/suppressions`](/docs/guides/suppressions).
