Skip to content

Webhooks

Included free on every install, up to 25 outbound webhooks per tenant with signed delivery and the default retry policy. More webhooks, payload templates, filters, retry policies, dead-letter replay, delivery search and incoming webhooks need a license with the webhook-pro feature. See pricing.

A webhook calls a URL you choose each time an entry is created, updated or deleted, so a build, a cache purge or a sync starts on its own. Each request is signed with your secret and recorded, and a failed one can be retried. Incoming webhooks work the other way: another service posts to LyEve and an entry is created.

StepWhat happens
ChangeAn entry is created, updated or deleted, and the write is saved.
MatchEach enabled webhook of the tenant checks the event, the content type and its filters.
DeliverA POST goes to the URL, signed when the webhook has a secret, with 10 seconds to answer. Any 2xx is a success.
RetryA failure is retried in the background by the webhook's retry policy.
Dead letterA delivery that runs out of attempts waits in the dead-letter queue, where you replay or dismiss it.

A webhook fires after the change is saved, so a slow or failing endpoint never delays or blocks a write. A tenant's webhooks see only that tenant's content.

You need an admin token in TOKEN. The quickstart shows how to get one. Creating a webhook needs the super_admin role. The example assumes a content type named post, such as the one in Create your first content type.

  1. Create a webhook. Replace the URL with an HTTPS address you control. Its host must resolve to a public address when you save, so the placeholder below answers 400 invalid input until you change it:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/webhooks \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
    "name": "Rebuild site",
    "url": "https://build.example.com/hooks/lyeve",
    "events": ["after_create", "after_update", "after_delete"],
    "schemas": ["post"],
    "include_fields": ["id", "title", "slug"],
    "secret": "replace-with-a-long-random-secret"
    }'

    The answer is 201 with the webhook, which never shows the secret. Copy its id into WEBHOOK_ID.

  2. Send a test delivery to your endpoint:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/test \
    -H "Authorization: Bearer $TOKEN"
    { "success": true, "status_code": 200, "message": "OK" }

    When your endpoint refuses it, success is false and message names the status, such as HTTP 405: webhook test failed. The test body carries "event": "test", "schema": "*" and "data": {"message": "webhook test from LyEve"}, trimmed by the field lists, so with the include_fields above data arrives empty.

  3. Read how deliveries have gone:

    Terminal window
    curl http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/deliveries/stats \
    -H "Authorization: Bearer $TOKEN"
    { "total": 1, "success_count": 1, "failure_count": 0, "success_rate": 100, "avg_latency_ms": 111, "last_24h": 1 }
  4. Open Delivery > Webhooks in the admin console. Your webhook is listed, and Delivery history shows the test.

To write the receiver that checks the signature, follow Add a webhook.

FreeWith webhook-pro
Outbound webhooks per tenant25, enabled or notUnlimited
Each webhookCreate, update, delete, test, rotate the secret, schemas, events, headers and the field listsAlso payload_template, field_filters, jsonpath_filter and its own retry numbers
RetriesThe default retry policy, which today sends no retryA policy of your own per webhook
Dead lettersList, read and deleteAlso replay and dismiss
HistoryDeliveries, stats, attempts and healthAlso delivery search, the payload preview and retrying one delivery
Incoming webhooksList, read, delete, and receive on the ones you holdAlso create and update

The license is read on every request. A webhook set up with webhook-pro keeps delivering with its template, filters and retry policy after a license lapses, incoming webhooks keep receiving, and an update that resends what it already stores is accepted. The 26th webhook is refused:

{"error": "cap_exceeded", "cap": "webhook.endpoints", "limit": 25, "current": 25, "upgrade_url": ""}

A paid option on a free install answers 402 with {"error": "payment_required", "plugin": "webhook", "feature": "feature:webhook-pro", "upgrade_url": ""}.

Without a payload template, the body is this envelope:

{
"event": "after_update",
"schema": "post",
"data": { "id": "8f14e45f-ea2c-4b1d-9f3a-2c1b0e7d6a55", "slug": "pricing", "title": "Pricing update" },
"old_data": { "slug": "pricing", "title": "Pricing" },
"timestamp": "2026-10-01T10:00:00Z"
}

event is after_create, after_update, after_delete, or test for a test delivery. On after_delete, data is the entry as it was. On after_update, old_data carries every value the entry had before the change, and it is left out on the other events. The field lists trim data only, so old_data arrives whole. timestamp is RFC 3339 in UTC.

HeaderWhenValue
Content-Typealwaysapplication/json
X-Webhook-EventalwaysThe event, such as after_create
X-Webhook-SchemaalwaysThe content type
X-Webhook-Timestampwith a secretUnix seconds when the request was signed
X-Webhook-Noncewith a secretA random UUID, new for every request
X-Webhook-Algorithmwith a secretsha256
X-Webhook-Signaturewith a secretsha256= and the HMAC described below
X-Webhook-Retryon a retrytrue
X-Webhook-Replayon a dead-letter replaytrue

The signature is HMAC-SHA256, keyed with the secret's bytes as you typed it, over the string <timestamp>.<nonce>.<raw body>: the X-Webhook-Timestamp value, a period, the X-Webhook-Nonce value, a period, and the body bytes exactly as received. The header carries sha256= followed by the lowercase hex digest. Every attempt, retry and replay is signed again with a fresh timestamp and nonce. Add a webhook has receivers in Node.js and Python that check it.

Your endpoint has 10 seconds to answer, so reply first and do slow work afterward. At most 3 redirects are followed. Delivery is at least once, so deduplicate on a stable key in data, such as id with the event and timestamp.

POST /api/admin/webhooks creates a webhook and PUT /api/admin/webhooks/{id} replaces it with the same body. A field the API does not know is refused with 400 invalid JSON.

Every field
FieldMeaningDefault
nameA label.required
urlhttp:// or https://. The host must resolve to a public address, checked on save and again before every send.required
eventsAny of after_create, after_update and after_delete. A webhook with no events never fires.none
schemasContent types to fire for. Left empty, the webhook also fires when a content type itself changes, with "schema": "sys_schemas", the type's name and an action in data. A deleted type sends after_delete, and every other change after_create.every content type
secretSigning secret, at least 16 characters. Encrypted at rest and never returned.none, so unsigned
enabledWhether it fires.true
headersExtra request headers. Values are templates, like payload_template. A header whose name contains auth, token, secret, key, signature, password, session or cookie reads back as [redacted]. Send [redacted] back on update to keep the stored value.none
payload_templateA template that replaces the envelope. It must render JSON. Needs webhook-pro.the envelope
field_filtersFire only when each named field equals the given string, such as {"featured": "true"}. Needs webhook-pro.none
jsonpath_filterFire only when an expression on the envelope holds, such as $.data.total > 100. Needs webhook-pro.none
include_fields, exclude_fieldsFields to keep in, or drop from, data. Use one or the other.all fields
max_response_sizeBytes of your endpoint's answer to read.1 MiB
max_retries, retry_delay_secondsStored with the webhook. Retries follow the retry policy, so set that instead. Needs webhook-pro.none

An admin token sees every header value as [redacted], and a URL with a path or query as its scheme and host followed by /[redacted].

Narrow which changes fire, and shape what is sent. The filters, the template and the preview need webhook-pro, and the rest is free:

  • schemas and events pick the content types and the kinds of change.
  • field_filters compares field values as text, so {"featured": "true"} fires only for featured entries.
  • jsonpath_filter takes one comparison on the envelope: ==, !=, >, >=, < or <=, after a path that starts with $.event, $.schema, $.data, $.old_data or $.timestamp. A path alone fires when its value is set and not false or 0.
  • include_fields or exclude_fields trims data.

A payload_template replaces the envelope with your own JSON. It sees the entry as .Data and the previous values as .OldData, and it has the helpers toJSON, defaultVal, ifSet, ifCond, ifEq, ifNeq and dict. This template posts a chat message:

{ "payload_template": "{\"text\": \"New post: {{.Data.title}}\"}" }

Preview it with POST /api/admin/webhooks/{id}/preview, which renders the template and the headers against the event, schema, data and old_data you send. Send {} to use sample values. An empty request answers 400 invalid JSON. The preview applies the field lists but not the filters.

A failed delivery (an error, a timeout, or a status outside 2xx) is retried in the background. The worker looks for due retries every 30 seconds. With webhook-pro, set the policy per webhook. Without it the defaults below apply:

Terminal window
curl -X PUT http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/retry-config \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"max_attempts": 5, "base_delay_ms": 5000, "max_delay_ms": 20000, "strategy": "exponential", "enabled": true}'
FieldMeaningDefaultRange
max_attemptsAttempts before the delivery moves to the dead-letter queue51 to 100
base_delay_msThe unit every wait is built from. The first retry waits twice this with exponential or linear, and this with fixed.30000up to 3600000
max_delay_msLongest single wait600000up to 86400000
strategyexponential doubles the wait each time, linear adds base_delay_ms each time, fixed keeps itexponential
enabledWhether failed deliveries are retriedtrue

A 0 or a missing number takes the default. A value above its range answers 400 invalid input, and a negative one a validation failed error. GET on the same path reads the policy in force. A retry is sent at the first check after its wait ends, so the times in the history are rounded up to those checks.

A delivery that runs out of attempts lands in the dead-letter queue:

Terminal window
curl "http://localhost:3001/api/admin/webhook-dead-letters?status=pending" \
-H "Authorization: Bearer $TOKEN"

Each entry carries the webhook, the event, the body that was sent, the last status code and error, and total_attempts. status is pending, replayed or dismissed.

Replaying and dismissing need webhook-pro. Reading and deleting an entry are free.

  • POST /api/admin/webhook-dead-letters/{id}/replay sends the same body again, signed afresh. The answer is {"id": "...", "status": "replayed", "http_status": 200}. Read http_status: the entry is marked replayed only when it is 2xx, and stays pending otherwise.
  • POST /api/admin/webhook-dead-letters/{id}/dismiss marks it handled without sending.
  • Either answers 409 dead letter entry is not pending once the entry has left pending.

The console's Dead letters button on the Webhooks page shows the same queue.

Every attempt is recorded with its status code, duration, request body, retry count and error.

History and health routes
RouteReturns
GET /api/admin/webhooks/{id}/deliveriesThe latest deliveries as a list, newest first. limit defaults to 50, at most 500.
GET /api/admin/webhooks/{id}/deliveries/searchNeeds webhook-pro. {"total", "limit", "offset", "items"}, filtered by event, schema, q, success, since, until (RFC 3339), limit (at most 200) and offset.
GET /api/admin/webhooks/{id}/deliveries/statstotal, success_count, failure_count, success_rate, avg_latency_ms and last_24h. since narrows it.
GET /api/admin/webhooks/{id}/attemptsEach attempt of a failed delivery's retry chain, with attempt_num and status, paginated.
GET /api/admin/webhooks/{id}/healthAttempts, successes, failures, exhausted retries, dlq_pending, the average duration and healthy.
GET /api/admin/webhooks/healthHealthy and unhealthy webhooks, pending retries, and dead letters in total and pending.
POST /api/admin/webhooks/{id}/deliveries/{delivery_id}/retryNeeds webhook-pro. Sends one delivery again now and answers {"success", "status_code", "message", "delivery_id"}. Refused with 400 on a disabled webhook.

healthy is false once a webhook has five or more recorded attempts and fewer than 90% of them succeeded. Only failed deliveries start attempts, so a webhook whose deliveries all succeed stays healthy.

When a delivery moves to the dead-letter queue, the instance publishes a webhook.delivery_failed event. A flow starts on it with an event trigger:

{ "type": "trigger.event", "config": { "kind": "system", "name": "webhook.delivery_failed" } }

The event carries webhook_id, webhook_name, dead_letter_id, event_type, schema, total_attempts and, when there was one, last_status_code. Read the error text from the dead letter itself.

Terminal window
curl -X POST http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/rotate-secret \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'

{} generates a new secret. {"new_secret": "..."} sets one of at least 16 characters. The answer carries new_secret once, so save it then. The body is required: an empty request answers 400 invalid JSON. Rotating needs the super_admin role.

An incoming webhook turns a JSON payload from another service into a new entry. Creating or changing one needs the super_admin role and webhook-pro:

Terminal window
curl -X POST http://localhost:3001/api/admin/incoming-webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Shop orders",
"secret": "replace-with-a-long-random-secret",
"schema_name": "order",
"field_map": {"order_number": "number", "total_cents": "total"},
"allowed_ips": ["198.51.100.0/24"]
}'

field_map maps a key of the incoming JSON to a field of the content type, and it cannot be empty. Keys you do not map are ignored. allowed_ips takes addresses and CIDR ranges. The other service posts to POST /api/v1/webhooks/in/{id} on the Content API, with no credential. With a secret, it signs the request the way LyEve signs outbound deliveries:

Terminal window
BODY='{"order_number": "1042", "total_cents": 4900}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIG=$(printf '%s.%s.%s' "$TS" "$NONCE" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST "http://localhost:3002/api/v1/webhooks/in/$INCOMING_ID" \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Nonce: $NONCE" \
-H "X-Webhook-Signature: sha256=$SIG" \
-d "$BODY"

A good request answers 201 with {"status": "accepted"}.

  • With a secret, X-Webhook-Timestamp and X-Webhook-Nonce are required. The signature may also come in X-Hub-Signature-256, and a sha384= or sha512= prefix picks that hash.
  • The timestamp may be at most 5 minutes old and 30 seconds ahead. A nonce is accepted once.
  • With allowed_ips, any other address is refused.
  • Only the first 1 MiB of the body is read, and each address may send 50 requests a second, with bursts of 100.
  • The request's host decides the tenant, as on every public route, so post to the owning tenant's own domain. See Tenants.
  • An unknown or disabled id answers 404.

Without a secret the endpoint accepts anyone who knows its id, so always set one. An entry created this way is published at once and does not fire outbound webhooks.

The webhook.deliver node posts a payload to one of the tenant's webhooks, by id or name, with an event name you choose. The webhook's template, headers, field lists, signing, history and retries apply. A disabled webhook is refused, and a test run sends nothing. It needs flow-pro, because it sends a payload of your own outside the instance. See Flows.

VariableWhat it doesDefault
WEBHOOK_ALLOWED_PRIVATE_NETWORKSComma-separated CIDR ranges or addresses that webhooks may reach although they are private, such as an internal event sink. Checked on save and on every send.none
ENCRYPTION_KEYEncrypts signing secrets at rest, with the other stored credentials. Set it in production.none

If you set LYEVE_PLUGINS to choose which features start, include webhook. See Licensing and tiers.

Reads take the admin role, and changing a webhook or its secret takes super_admin. An admin token with webhooks:read can call the read routes, and with webhooks:write the test, retry, retry policy and dead-letter routes. See Admin tokens.

Every webhook route
MethodPathRole
GET/api/admin/webhooks, /api/admin/webhooks/{id}admin
POST/api/admin/webhookssuper_admin
PUT, DELETE/api/admin/webhooks/{id}super_admin
POST/api/admin/webhooks/{id}/rotate-secretsuper_admin
POST/api/admin/webhooks/{id}/test, /api/admin/webhooks/{id}/previewadmin
GET/api/admin/webhooks/{id}/deliveries, .../deliveries/search, .../deliveries/statsadmin
POST/api/admin/webhooks/{id}/deliveries/{delivery_id}/retryadmin
GET, PUT/api/admin/webhooks/{id}/retry-configadmin
GET/api/admin/webhooks/{id}/attemptsadmin
GET/api/admin/webhooks/health, /api/admin/webhooks/{id}/healthadmin
GET/api/admin/webhook-dead-letters, /api/admin/webhook-dead-letters/{id}admin
POST/api/admin/webhook-dead-letters/{id}/replay, .../dismissadmin
DELETE/api/admin/webhook-dead-letters/{id}admin
GET/api/admin/incoming-webhooks, /api/admin/incoming-webhooks/{id}admin
POST/api/admin/incoming-webhookssuper_admin
PUT, DELETE/api/admin/incoming-webhooks/{id}super_admin
POST/api/v1/webhooks/in/{id}public
StatusMessageCause
400invalid inputThe URL is refused, or a retry policy value is out of range.
400validation failed on field "secret": failed on 'min=16'The secret is shorter than 16 characters.
401invalid signatureAn incoming request's signature did not verify.
402cap_exceededThe tenant already holds 25 webhooks and the license does not carry webhook-pro.
402payment_requiredThe write sets an option that needs webhook-pro.
Every other error
StatusMessageCause
400invalid JSONThe body is empty, malformed or has a field the API does not know.
400cannot retry: webhook is disabledA manual retry on a disabled webhook.
400webhook destination is not an allowed addressA dead-letter replay to a URL that is no longer allowed.
400X-Webhook-Timestamp header is required, X-Webhook-Nonce header is requiredA signed incoming webhook got a request without them.
400timestamp outside tolerance windowAn incoming timestamp is too old or too far ahead.
403ip not allowedAn incoming request came from an address not in allowed_ips.
404not foundAn incoming webhook that does not exist, is disabled or belongs to another tenant.
409dead letter entry is not pendingThe entry was already replayed or dismissed.
409replayed requestAn incoming request reused a nonce.
422failed to create content recordAn incoming payload left out a required field, or a value did not fit its field.