# Webhooks API reference

This page is the management reference for webhook *endpoints* — the CRUD surface
you use to register where ReplyLayer POSTs events, rotate signing secrets, send a
test event, and inspect or retry deliveries.

It does **not** cover what events exist, their payload shapes, signature
verification, or the retry/idempotency contract on the receiving side — those are
the delivery contract, documented for integrators at
[/agents/webhooks](/agents/webhooks). Read that page to build the receiver; read
this one to manage the endpoints.

The machine-readable schema for every operation below is in the OpenAPI spec at
[/docs/openapi.json](/docs/openapi.json).

## Authentication

Every webhook-management operation requires an **admin** API key or a **dashboard
session**. Mailbox-scoped **agent** keys are rejected with `403 INSUFFICIENT_SCOPE`
— an agent cannot create, read, or delete webhooks. This is deliberate: a webhook
subscription is account-wide configuration, not a per-mailbox action.

All examples use the public API base `https://api.replylayer.ai`. Send
`Authorization: Bearer <admin-key>` and `Content-Type: application/json`.

## At a glance

Every operation is scoped to the calling account; cross-account ids resolve to
`404 NOT_FOUND`.

| Operation | Method + path | Classification |
|---|---|---|
| Create | `POST /v1/webhooks` | mutating, secret-revealing |
| List | `GET /v1/webhooks` | read-only |
| Get | `GET /v1/webhooks/:id` | read-only |
| Update | `PATCH /v1/webhooks/:id` | mutating |
| Delete | `DELETE /v1/webhooks/:id` | mutating |
| Rotate secret | `POST /v1/webhooks/:id/rotate-secret` | mutating, secret-revealing |
| Test | `POST /v1/webhooks/:id/test` | mutating |
| List deliveries | `GET /v1/webhooks/:id/deliveries` | read-only |
| Retry delivery | `POST /v1/webhooks/:id/deliveries/:delivery_id/retry` | mutating |

Error-code remediation is not restated per-operation below. Each operation lists
the codes it returns; look them up in the [error reference](/agents/errors).

## Create a webhook

**`POST /v1/webhooks`** — Classification: **mutating, secret-revealing**

Registers an endpoint and returns its signing secret. The `signing_secret` is
returned **only** here and on rotate — there is no way to read it back. Store it
the moment you receive it.

Request fields:

| Field | Type | Required | Notes |
|---|---|---|---|
| `url` | string | yes | Must be HTTPS. Max 2048 chars. Private-network and non-HTTP(S) URLs are rejected (see below). |
| `enabled_events` | string[] | yes | Event types to subscribe to. Unique values. May be empty (effectively delivers nothing). Valid values: see [/agents/webhooks](/agents/webhooks). |
| `description` | string | no | Max 200 chars. |
| `enabled` | boolean | no | Defaults to `true`. |

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

**Response `201`:**

```json
{
  "id": "…",
  "url": "https://your-app.example.com/webhooks/replylayer",
  "description": "prod handler",
  "enabled": true,
  "enabled_events": ["message.received", "message.delivered", "message.bounced"],
  "signing_secret": "whsec_…",
  "created_at": "2026-04-16T12:00:00.000Z"
}
```

**URL validation.** The endpoint URL is checked at create (and update) time. A
rejection returns `400 VALIDATION_ERROR` with an `error` string naming the cause:
`Invalid URL format`, `Webhook URL must use http or https`, `Webhook URL must not
point to a private IP address`, `Webhook URL must not point to localhost`, or
`Webhook URL must use HTTPS`. Private IPv4 ranges, IPv6 ULA/link-local/loopback,
IPv4-mapped private addresses, and `localhost`-by-name are all rejected. (`http://`
and `localhost` are permitted only for local development, never in production.) The
delivery worker additionally re-resolves DNS before each POST and refuses any
hostname that now resolves to a private range — a DNS-rebinding defense.

**Errors:** `400 VALIDATION_ERROR` (bad URL, private IP, non-HTTPS scheme,
localhost, unknown event type, or description too long), `403 TIER_LIMIT` (the
account is at its per-tier webhook-count cap — see [/docs/limits](/docs/limits)),
`403 INSUFFICIENT_SCOPE` (agent key).

## List webhooks

**`GET /v1/webhooks`** — Classification: **read-only**

Returns every webhook on the account, oldest-first. `signing_secret` is never
included on any read.

**Response `200`:**

```json
{
  "webhooks": [
    {
      "id": "…",
      "url": "https://your-app.example.com/webhooks/replylayer",
      "description": "prod handler",
      "enabled": true,
      "enabled_events": ["message.received"],
      "created_at": "2026-04-16T12:00:00.000Z",
      "updated_at": "2026-04-16T12:00:00.000Z",
      "consecutive_failures": 0,
      "last_success_at": "2026-04-17T09:00:00.000Z",
      "last_failure_at": null,
      "last_error": null,
      "disabled_at": null,
      "disabled_reason": null
    }
  ]
}
```

Health fields (`consecutive_failures`, `last_success_at`, `last_failure_at`,
`last_error`, `disabled_at`, `disabled_reason`) are maintained by the delivery
worker. `consecutive_failures` is always present (0 when healthy); the timestamp
and reason fields are `null` until the worker has something to report. `last_error`
is a short classification string (for example `http_500`, `network_error: <msg>`,
`webhook_disabled`).

**Errors:** `403 INSUFFICIENT_SCOPE` (agent key).

## Get a webhook

**`GET /v1/webhooks/:id`** — Classification: **read-only**

Returns a single webhook. Same object shape as a list item (including the health
fields); `signing_secret` is omitted.

**Errors:** `404 NOT_FOUND` (unknown id or not on this account),
`403 INSUFFICIENT_SCOPE` (agent key).

## Update a webhook

**`PATCH /v1/webhooks/:id`** — Classification: **mutating**

Partial update — only the fields you send are changed. All fields are optional, but
the body must contain at least one updatable field or the request is rejected with
`400 VALIDATION_ERROR` (`No updatable fields provided`).

| Field | Type | Notes |
|---|---|---|
| `url` | string | Re-validated with the same SSRF/HTTPS checks as create. |
| `description` | string \| null | Send `null` to clear it. |
| `enabled_events` | string[] | Replaces the current subscription set (not merged). Unique values. |
| `enabled` | boolean | `false` pauses delivery; `true` resumes it. |

**Re-enabling resets failure state.** Setting `enabled: true` on a webhook that was
`enabled: false` also zeroes `consecutive_failures` and clears `disabled_at` /
`disabled_reason` in the same update, so the next abandoned delivery does not
immediately re-trip auto-disable. A no-op toggle (`true → true`) leaves counters
untouched.

A webhook that accumulates 20 consecutive abandoned deliveries auto-disables
(`enabled: false`, `disabled_reason: "auto_disabled_failures"`); re-enable it with a
PATCH that sets `enabled: true`. The full retry and auto-disable semantics live in
the delivery contract at [/agents/webhooks](/agents/webhooks).

**Response `200`:** the full webhook object (health fields included, no
`signing_secret`).

**Errors:** `400 VALIDATION_ERROR` (invalid URL, unknown event type, or empty
body), `404 NOT_FOUND`, `403 INSUFFICIENT_SCOPE` (agent key).

## Delete a webhook

**`DELETE /v1/webhooks/:id`** — Classification: **mutating**

Hard-deletes the webhook and all of its delivery history. This is irreversible.

**Response `200`:**

```json
{ "status": "deleted" }
```

**Errors:** `404 NOT_FOUND`, `403 INSUFFICIENT_SCOPE` (agent key).

## Rotate the signing secret

**`POST /v1/webhooks/:id/rotate-secret`** — Classification: **mutating, secret-revealing**

Generates a fresh signing secret and returns the new plaintext once. The swap is
**atomic**: the old secret stops validating on the very next delivery — there is no
dual-signature grace window, so cut your receiver over to the new secret promptly.

**Response `200`:**

```json
{ "signing_secret": "whsec_…" }
```

**Errors:**

- `404 NOT_FOUND` — unknown id or not on this account.
- `500 ROTATION_READBACK_MISMATCH` — the new secret failed to persist; the change
  was rolled back, your **old secret is still valid**, and it is safe to retry the
  rotation.
- `403 INSUFFICIENT_SCOPE` (agent key).

Use the returned secret to verify inbound delivery signatures — the verification
algorithm is documented at [/agents/webhooks](/agents/webhooks).

## Send a test event

**`POST /v1/webhooks/:id/test`** — Classification: **mutating**

With no body, enqueues the original `webhook.test` delivery to this endpoint. An
optional `event` body requests a real-shaped payload for one of three events:

```json
{ "event": "message.delivered" }
```

Allowed values are `message.delivered`, `message.bounced`, and
`recipient_blocklist.added`. Omitting the body (or omitting `event`) preserves the
original `webhook.test` envelope. Both forms bypass the webhook's `enabled_events`
filter. Poll the deliveries list to see the outcome.

```bash
curl -X POST https://api.replylayer.ai/v1/webhooks/<webhook-id>/test \
  -H "Authorization: Bearer $REPLYLAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event":"message.bounced"}'
```

The event-specific payload uses placeholder identifiers but the same event envelope
and event-data fields as a normal delivery. Use the
[email simulator guide](/docs/guides/simulator) when you also need a stored message
and a delayed outcome.

**Response `200`:**

```json
{ "delivery_id": "…" }
```

**Errors:**

- `404 NOT_FOUND` — unknown id or not on this account.
- `403 INSUFFICIENT_SCOPE` — agent key.
- `409 WEBHOOK_DISABLED` — the webhook is not `enabled` (paused or auto-disabled).
  **No delivery row is created.** Re-enable it first, then test.

## List deliveries

**`GET /v1/webhooks/:id/deliveries`** — Classification: **read-only**

Returns recent delivery attempts, newest-first, with keyset pagination on the
`(created_at, id)` tuple. Use it to see which delivery failed and what your endpoint
returned.

**Query parameters:**

| Param | Notes |
|---|---|
| `limit` | Optional. Default 50, range 1–100. |
| `before_at` + `before_id` | Optional cursor, **both-or-neither**. Pass the `next_before_at` / `next_before_id` from the previous page to fetch strictly older rows. |

**Response `200`:**

```json
{
  "deliveries": [
    {
      "id": "…",
      "event_type": "message.received",
      "status": "delivered",
      "http_status": 200,
      "attempt_count": 1,
      "created_at": "2026-04-17T12:00:00.000Z",
      "delivered_at": "2026-04-17T12:00:00.500Z",
      "failed_at": null,
      "next_retry_at": null,
      "response_preview": null
    }
  ],
  "has_more": true,
  "next_before_at": "2026-04-17T12:00:00.000Z",
  "next_before_id": "…"
}
```

Field notes:

- `status` is one of `pending`, `delivering`, `delivered`, `failed`.
- `response_preview` is the first 200 characters of whatever your endpoint returned
  in its response body — useful for debugging app-side errors. It is `null` when the
  failure happened before a body was received (network error, redirect refusal).
- `next_retry_at` is meaningful only on `pending` rows (the scheduled next attempt);
  on `delivered` / `failed` rows it is a stale leftover — ignore it.
- `next_before_at` / `next_before_id` are present only when `has_more` is `true`.

**Errors:** `400 VALIDATION_ERROR` (limit out of range, or cursor supplied without
both parts), `403 INSUFFICIENT_SCOPE` (agent key), `404 NOT_FOUND`.

## Retry a delivery

**`POST /v1/webhooks/:id/deliveries/:delivery_id/retry`** — Classification: **mutating**

Manually re-queues a single `failed` delivery: the row moves to `status: "pending"`,
`next_retry_at` is set to now, and `attempt_count` is reset to 0 so it gets a fresh
retry budget.

This is **destructive to the prior attempt chain** — the count of previous attempts
is overwritten, and the row's history reads as `1 attempt, failed` going forward.

**Response `200`:**

```json
{ "delivery_id": "…", "status": "pending" }
```

**Errors:**

- `404 NOT_FOUND` — the delivery does not exist or is not on this account.
- `409 DELIVERY_NOT_FAILED` — only a `failed` delivery can be retried.
- `409 WEBHOOK_DISABLED` — the parent webhook is disabled; re-enable it first.
- `403 INSUFFICIENT_SCOPE` — agent key.

## Managing from the CLI and SDKs

The same operations are exposed by the `rly webhook` CLI verbs (`create`, `list`,
`get`, `update`, `delete`, `rotate-secret`, `test`, `deliveries`, `retry`) and by
the TypeScript and Python SDKs (`webhooks.create`, `webhooks.list`,
`webhooks.rotateSecret`, `webhooks.listDeliveries`, and so on). All of them require
an admin key. On `create` and `rotate-secret` the CLI and SDKs surface the signing
secret exactly once — capture it immediately.

To build the receiving side (signature verification, deduping on `event.id`,
branching on the event catalog, and the 7-attempt retry schedule), see the delivery
contract at [/agents/webhooks](/agents/webhooks).
