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.

OperationMethod + pathClassification
CreatePOST /v1/webhooksmutating, secret-revealing
ListGET /v1/webhooksread-only
GetGET /v1/webhooks/:idread-only
UpdatePATCH /v1/webhooks/:idmutating
DeleteDELETE /v1/webhooks/:idmutating
Rotate secretPOST /v1/webhooks/:id/rotate-secretmutating, secret-revealing
TestPOST /v1/webhooks/:id/testmutating
List deliveriesGET /v1/webhooks/:id/deliveriesread-only
Retry deliveryPOST /v1/webhooks/:id/deliveries/:delivery_id/retrymutating

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:

FieldTypeRequiredNotes
urlstringyesMust be HTTPS. Max 2048 chars. Private-network and non-HTTP(S) URLs are rejected (see below).
enabled_eventsstring[]yesEvent types to subscribe to. Unique values. May be empty (effectively delivers nothing). Valid values: see /agents/webhooks.
descriptionstringnoMax 200 chars.
enabledbooleannoDefaults 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).

FieldTypeNotes
urlstringRe-validated with the same SSRF/HTTPS checks as create.
descriptionstring | nullSend null to clear it.
enabled_eventsstring[]Replaces the current subscription set (not merged). Unique values.
enabledbooleanfalse 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 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:

ParamNotes
limitOptional. Default 50, range 1–100.
before_at + before_idOptional 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:

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

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