Webhooks
Webhooks let jekcms tell the rest of the internet when something happens. A post publishes — your Slack channel gets a ping, n8n kicks off a social workflow, your analytics service records the event. The mechanism is simple: you register a URL against an event, and jekcms sends a signed POST request every time that event fires.
This page covers the available events, signature verification, retry behaviour, and receiver setup.
Available events
Currently supported outbound events:
post.published— a post transitioned topublishedstatus (scheduled publish also fires this)post.updated— any field on an already-published post changedpost.deleted— soft or hard deletecomment.approved— a comment moved frompendingtoapprovedorder.completed— an order reachedpaidstatus (only fires if the commerce module is active)
More events are added per release — check the dropdown in the webhook admin UI for the current list.
Webhook admin UI
Open admin/webhooks.php. The listing shows every registered webhook with event, URL, status, and last-delivery timestamp.
Add webhook
- Event — pick from the dropdown
- URL — the receiver endpoint (must be HTTPS in production; HTTP allowed for local dev)
- Description — free-text note for yourself ("Slack #content channel")
- Secret — auto-generated HMAC key; used to sign the payload
Click Test to fire a synthetic payload at the URL right away — handy for checking the receiver is wired up before relying on a real event to prove it.
Edit / delete
Edit changes event or URL. Delete is immediate — no soft-delete for webhook rows.
Signed payloads
Every outbound request carries an X-Webhook-Signature header with an HMAC-SHA256 signature of the raw request body using the webhook's secret as key:
X-Webhook-Signature: sha256=b2f3a9e4c1d8...
Content-Type: application/json
User-Agent: jekcms-Webhook/1.0
Your receiver must verify the signature before trusting the payload. Without this check, anyone who guesses your webhook URL can POST fake events.
Signature verification examples
PHP
$raw = file_get_contents('php://input');
$secret = getenv('JEKCMS_WEBHOOK_SECRET');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($raw, true);
// process $payload safely
Node.js
const crypto = require('crypto');
app.post('/jekcms-hook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.JEKCMS_WEBHOOK_SECRET;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
const received = req.headers['x-webhook-signature'];
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(401).send('invalid signature');
}
const payload = JSON.parse(req.body.toString());
// process payload safely
res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib, os
from flask import request, abort
@app.route('/jekcms-hook', methods=['POST'])
def hook():
raw = request.get_data()
secret = os.environ['JEKCMS_WEBHOOK_SECRET'].encode()
expected = 'sha256=' + hmac.new(secret, raw, hashlib.sha256).hexdigest()
received = request.headers.get('X-Webhook-Signature', '')
if not hmac.compare_digest(expected, received):
abort(401)
payload = request.get_json()
# process payload safely
return '', 200
Always use constant-time comparison (hash_equals, timingSafeEqual, compare_digest) — naive == opens a timing side-channel.
Retry mechanism
jekcms expects an HTTP 2xx response within 10 seconds. Anything else (non-2xx, timeout, connection refused, DNS failure) triggers retries:
- Attempt 1 — immediate
- Attempt 2 — 30 seconds later
- Attempt 3 — 5 minutes later
After three failures the delivery is marked failed_permanent and the webhook row increments its failure_count. If failure_count exceeds 20, the webhook auto-disables and you get an admin dashboard alert — prevents a misconfigured URL from clogging the queue forever.
Attempt log
Every delivery attempt (success or failure) gets a row in webhook_attempts with timestamp, HTTP status, response body (first 2 KB), and round-trip duration. View from Automation → Webhooks → click webhook → Logs. Filter by status code, by date range, or search for specific payloads.
Log rows older than 30 days are pruned by the backup.rotate scheduled task.
Receiver configuration — n8n example
In n8n, add a Webhook node (HTTP method POST, mode "lastNode"). Right after it, add a Function node that verifies the signature exactly as in the Node.js example above — use $env.JEKCMS_WEBHOOK_SECRET so the key isn't hardcoded into workflow JSON. Only downstream nodes after the signature check should trust the payload. See the [n8n setup guide](/docs/integrations/n8n-setup) for a full walk-through.
Receiver configuration — Slack
Slack incoming webhooks don't do custom HMAC verification, but the URL itself is secret. Create a Slack incoming webhook, paste the URL into jekcms, and build a small formatter proxy (Cloudflare Worker or n8n) that converts jekcms's JSON into Slack's block-kit shape.