Add a Webhook
Included free on every install, up to 25 webhooks per tenant. Step 6 sets a retry policy and resends a delivery, which need a license with the
webhook-profeature, and step 7 needsevents. See pricing.
This walkthrough connects LyEve to an endpoint of your own. You run a small receiver that refuses any request LyEve did not sign, register it as a webhook, watch a test delivery and a real content event arrive, then read the history and recover what failed. Every field, header and route is described on Webhooks. This page is the path through them.
Before you start
Section titled “Before you start”- An admin token in
TOKENfor a user with thesuper_adminrole. The quickstart shows how to get one. - A content type to watch. The steps use
postfrom Create your first content type. - Node.js 18 or later, or Python 3.8 or later. Neither receiver needs a package.
- A place the instance can reach your receiver. The URL's host must resolve to
a public address, or to a private range the operator lists in
WEBHOOK_ALLOWED_PRIVATE_NETWORKS.
1. Run a receiver
Section titled “1. Run a receiver”Pick a long random secret and keep it in WEBHOOK_SECRET. LyEve signs every
delivery with it, and the receiver recomputes the signature over the raw body,
the timestamp and the nonce. It also refuses a timestamp older than five
minutes and a nonce it has already seen, so a captured request cannot be
replayed.
Node.js, saved as receiver.mjs:
import http from "node:http";import crypto from "node:crypto";
const SECRET = process.env.WEBHOOK_SECRET;const PORT = Number(process.env.PORT ?? 8787);const MAX_AGE_SECONDS = 300;const seenNonces = new Map(); // nonce -> time it may be forgotten
function verify(headers, rawBody) { const timestamp = headers["x-webhook-timestamp"]; const nonce = headers["x-webhook-nonce"]; const signature = headers["x-webhook-signature"]; if (!timestamp || !nonce || !signature) return "unsigned request";
const now = Date.now() / 1000; if (Math.abs(now - Number(timestamp)) > MAX_AGE_SECONDS) return "stale timestamp";
for (const [seen, forgetAt] of seenNonces) if (forgetAt < now) seenNonces.delete(seen); if (seenNonces.has(nonce)) return "nonce already used";
const expected = "sha256=" + crypto .createHmac("sha256", SECRET) .update(`${timestamp}.${nonce}.`) .update(rawBody) .digest("hex"); const a = Buffer.from(signature); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return "bad signature";
seenNonces.set(nonce, now + 2 * MAX_AGE_SECONDS); return null;}
http.createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); const problem = verify(req.headers, rawBody); if (problem) { console.log("refused:", problem); res.writeHead(401).end(problem); return; } const delivery = JSON.parse(rawBody); console.log("accepted:", delivery.event, delivery.schema, JSON.stringify(delivery.data)); res.writeHead(204).end(); });}).listen(PORT, () => console.log(`listening on http://localhost:${PORT}`));Python, saved as receiver.py:
import hashlibimport hmacimport jsonimport osimport timefrom http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"].encode()PORT = int(os.environ.get("PORT", "8787"))MAX_AGE_SECONDS = 300seen_nonces = {} # nonce -> time it may be forgotten
def verify(headers, raw_body): timestamp = headers.get("X-Webhook-Timestamp") nonce = headers.get("X-Webhook-Nonce") signature = headers.get("X-Webhook-Signature") if not (timestamp and nonce and signature): return "unsigned request"
now = time.time() if abs(now - int(timestamp)) > MAX_AGE_SECONDS: return "stale timestamp"
for seen in [n for n, forget_at in seen_nonces.items() if forget_at < now]: del seen_nonces[seen] if nonce in seen_nonces: return "nonce already used"
signed = f"{timestamp}.{nonce}.".encode() + raw_body expected = "sha256=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return "bad signature"
seen_nonces[nonce] = now + 2 * MAX_AGE_SECONDS return None
class Receiver(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0))) problem = verify(self.headers, raw_body) if problem: print("refused:", problem, flush=True) self.send_response(401) self.end_headers() self.wfile.write(problem.encode()) return delivery = json.loads(raw_body) data = json.dumps(delivery["data"], separators=(",", ":"), ensure_ascii=False) print("accepted:", delivery["event"], delivery["schema"], data, flush=True) self.send_response(204) self.end_headers()
def log_message(self, *args): pass
print(f"listening on http://localhost:{PORT}", flush=True)HTTPServer(("", PORT), Receiver).serve_forever()Start one of them:
export WEBHOOK_SECRET='replace-with-a-long-random-secret'node receiver.mjs # or: python3 receiver.pylistening on http://localhost:8787Both print the fields of the standard body. If you give the webhook a
payload_template, the body is your own JSON, so change the line that prints
it. Both check the raw bytes. If you move the check into a framework, read the body
before any JSON parser touches it (in Express, express.raw({ type: "application/json" })),
because a re-serialized body no longer matches the signature. Keep the nonce
list in a shared store such as Redis when you run more than one receiver.
2. Register the receiver
Section titled “2. Register the receiver”Replace the URL with the address the instance reaches your receiver at, and send the same secret:
curl -X POST http://localhost:3001/api/admin/webhooks \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "My receiver", "url": "https://hooks.example.com/lyeve", "events": ["after_create", "after_update", "after_delete"], "schemas": ["post"], "secret": "'"$WEBHOOK_SECRET"'" }'The answer is 201 with the webhook. Copy its id into WEBHOOK_ID. A URL
whose host does not resolve, or resolves to a private address, answers
400 invalid input.
3. Send a test
Section titled “3. Send a test”curl -X POST http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/test \ -H "Authorization: Bearer $TOKEN"The API answers {"success": true, "status_code": 204, "message": "No Content"},
and the receiver prints:
accepted: test * {"message":"webhook test from LyEve"}If the receiver prints refused: bad signature, the two secrets differ.
4. Fire a real event
Section titled “4. Fire a real event”Create a post on the Content API:
curl -X POST http://localhost:3002/api/v1/content/post \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"data": {"title": "Webhook check", "slug": "webhook-check", "body": "Hello"}}'Within a moment the receiver prints an after_create line with the entry's
fields in alphabetical order, its id and status among them:
accepted: after_create post {"_status":"published","body":"Hello","id":"4af50561-8093-4925-983f-ed8813ab615b","slug":"webhook-check","title":"Webhook check"}5. Read the history
Section titled “5. Read the history”curl "http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/deliveries?limit=5" \ -H "Authorization: Bearer $TOKEN"The answer lists the latest deliveries, newest first:
[ { "id": "c1b593c6-8e40-4a97-9851-d545eae5c27e", "webhook_id": "df373000-a2df-4fcb-b7c1-949494a57337", "tenant_id": "default", "event_type": "after_create", "schema_name": "post", "status_code": 204, "success": true, "duration_ms": 32, "request_body": "{\"event\":\"after_create\",\"schema\":\"post\",\"data\":{\"_status\":\"published\",\"body\":\"Hello\",\"id\":\"4af50561-8093-4925-983f-ed8813ab615b\",\"slug\":\"webhook-check\",\"title\":\"Webhook check\"},\"timestamp\":\"2026-10-03T14:29:09Z\"}", "original_payload": "{\"data\": {\"id\": \"4af50561-8093-4925-983f-ed8813ab615b\", \"body\": \"Hello\", \"slug\": \"webhook-check\", \"title\": \"Webhook check\", \"_status\": \"published\"}, \"event\": \"after_create\", \"schema\": \"post\", \"timestamp\": \"2026-10-03T14:29:09Z\"}", "attempted_at": "2026-10-03T14:29:09.529085Z", "retry_count": 0 }]A failed delivery carries success: false, the status_code or an error,
and next_retry_at. In the admin console the same list is under
Delivery > Webhooks, Delivery history.
6. Recover a failed delivery
Section titled “6. Recover a failed delivery”This step needs webhook-pro. The default retry policy sends no retry today,
so first set a policy whose waits stay under a minute, as
Retry policy explains:
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"}'Stop the receiver and create another post. The delivery fails. With this
policy its four retries wait 10, 20, 20 and 20 seconds, each counted from the
30-second check that picks it up, so they arrive about half a minute apart. Start the receiver again and either wait for the next retry,
which carries X-Webhook-Retry: true, or send the delivery yourself with its
id from the history:
curl -X POST http://localhost:3001/api/admin/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/retry \ -H "Authorization: Bearer $TOKEN"A delivery that ran out of attempts waits in the dead-letter queue. List it
with GET /api/admin/webhook-dead-letters?status=pending and send it with
POST /api/admin/webhook-dead-letters/{id}/replay, as
Replay or dismiss a dead letter
shows.
7. Recover a missed window
Section titled “7. Recover a missed window”When the receiver was down for longer than its retries lasted, send every
content event of the window again with event replay.
This step needs a license with the events feature. Start with a dry run that
counts what would be sent:
curl -i -X POST http://localhost:3001/api/admin/events/replay \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"since": "2026-10-01T09:00:00Z", "handler": "my-receiver-catchup", "dry_run": true}'The answer is 202 with a Location header naming the run. Read the run until
status is completed, and check total_events:
curl http://localhost:3001/api/admin/events/replay/$RUN_ID \ -H "Authorization: Bearer $TOKEN"Send the same request without dry_run to replay for real. Each event is
published again, the webhook matches it as it matched the original, and the
receiver gets it with a fresh timestamp and nonce. Running the same handler
name again skips events it already replayed.
- Webhooks: filters, payload templates, health and incoming webhooks.
- Event replay: the event log and how replays run.
- Flows: send your own events with
webhook.deliver.