Outgoing Webhooks
An outgoing webhook is how your site tells somebody else that something happened. A post goes live and a Slack channel hears about it; a comment arrives and a Zapier flow files it; a member registers and your CRM gets a row. jekcms sends a signed JSON POST to a URL you own, the moment the event fires.

This page is the outbound direction. For the reverse - external tools pushing content into your site through /api/v1/webhook/{action} - see Webhooks under Automation.
Setting one up
Webhook targets live on the same screen as API keys: Admin → API Keys, in the Outgoing Webhooks box. A target needs a name, a URL that starts with https://, and a set of events to subscribe to. Plain http is refused.
Saving generates a signing secret and shows it once, on that screen. It is stored encrypted; if the install cannot encrypt it, creation is refused outright rather than falling back to plaintext, because a readable signing secret is worth as much to an attacker as an API key.
A Test button next to each target sends a delivery with the event name test and the body {"hello": "jekcms"}, which is the fastest way to confirm your receiver is reachable and your signature check works.
The five events
| Event | Fires when | |---|---| | post_published | A post moves into the published state - on creation, when a draft is published, or when the scheduler releases a scheduled post | | post_updated | An already-published post is saved again | | post_deleted | A post is moved to trash or permanently deleted | | comment_created | A visitor submits a comment, whether it lands approved or pending | | member_registered | A member confirms their email address |
Subscribing to nothing is the same as subscribing to everything: a target with an empty event list receives every event. post_published deliberately does not re-fire when you edit a live post - that is what post_updated is for - so newsletter and social integrations built on it do not send twice.
The envelope
Every delivery has the same four top-level keys:
{
"event": "post_published",
"site": "https://yoursite.com",
"timestamp": "2026-04-21T14:32:17+03:00",
"data": { }
}
timestamp is ISO 8601 in the site's timezone, not UTC. site is the site's public base URL, which is what lets one receiver serve several jekcms installs.
Three headers ride with it:
Content-Type: application/json
User-Agent: jekcms-webhook/1.0
X-Jek-Event: post_published
X-Jek-Signature: sha256=<64 hex characters>
Payloads
The three post events share one shape:
{
"id": 123,
"title": "10 high-protein breakfasts",
"slug": "high-protein-breakfasts",
"status": "published",
"url": "https://yoursite.com/high-protein-breakfasts"
}
comment_created carries the comment and the post it belongs to, with status telling you whether it is already live or waiting in the moderation queue:
{
"id": 456,
"post_id": 123,
"author_name": "Alex",
"status": "pending"
}
member_registered is the smallest of the three:
{
"id": 42,
"name": "Buyer Name",
"email": "buyer@example.com"
}
These payloads are deliberately thin. They give you an identifier and enough context to decide whether you care; if you need the full record, call the REST API with that id.
Verifying the signature
The signature is an HMAC-SHA256 of the raw request body, hex-encoded and prefixed with sha256=, keyed with the secret shown when you created the webhook.
const crypto = require('crypto');
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.JEK_WEBHOOK_SECRET)
.update(rawBody) // the raw bytes - before JSON.parse
.digest('hex');
const given = req.headers['x-jek-signature'] || '';
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.status(401).end();
}
Two details decide whether this works. Sign the bytes you received, not a re-serialised object - reformatting the JSON changes the hash. And compare in constant time (timingSafeEqual in Node, hmac.compare_digest in Python); a plain === on a long-lived endpoint leaks timing.
Delivery behaviour, and what it does not do
Deliveries are queued while the request runs and sent after the response has been handed to the visitor, so a slow receiver never slows down your site. Connection timeout is 2 seconds and the whole request times out at 4.
A single page load sends at most 20 deliveries and spends at most about 12 seconds doing so. Anything beyond that is written to the delivery log marked deferred rather than dropped silently, so you can see it happened and re-send by hand.
There is no automatic retry. A non-2xx response, a timeout or a connection failure is recorded with its status code and error, and that is the end of that attempt. If delivery matters to you, either make your receiver accept fast and do the work asynchronously, or watch the delivery log and re-send. A 410 Gone is treated like any other failure; it does not disable the webhook.
The delivery log
Every attempt is stored with its event, URL, HTTP status, the request body that was sent and a truncated copy of the response. The admin screen shows the most recent eight per webhook, each with a Re-send button that replays the stored payload to the target's current URL - useful after you fix a receiver that was down.
The log keeps the newest hundred rows per webhook and prunes the rest, so it will not grow without bound on a busy site. Signing secrets never appear in a stored payload.