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. 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.
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.
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. |
description | string | no | Max 200 chars. |
enabled | boolean | no | Defaults to true. |
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:
{
"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),
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:
{
"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.
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:
{ "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:
{ "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.
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:
{ "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.
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 when you also need a stored message and a delayed outcome.
Response 200:
{ "delivery_id": "…" }Errors:
404 NOT_FOUND— unknown id or not on this account.403 INSUFFICIENT_SCOPE— agent key.409 WEBHOOK_DISABLED— the webhook is notenabled(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:
{
"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:
statusis one ofpending,delivering,delivered,failed.response_previewis the first 200 characters of whatever your endpoint returned in its response body — useful for debugging app-side errors. It isnullwhen the failure happened before a body was received (network error, redirect refusal).next_retry_atis meaningful only onpendingrows (the scheduled next attempt); ondelivered/failedrows it is a stale leftover — ignore it.next_before_at/next_before_idare present only whenhas_moreistrue.
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:
{ "delivery_id": "…", "status": "pending" }Errors:
404 NOT_FOUND— the delivery does not exist or is not on this account.409 DELIVERY_NOT_FAILED— only afaileddelivery 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.