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 to published status (scheduled publish also fires this)
  • post.updated — any field on an already-published post changed
  • post.deleted — soft or hard delete
  • comment.approved — a comment moved from pending to approved
  • order.completed — an order reached paid status (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:

  1. Attempt 1 — immediate
  2. Attempt 2 — 30 seconds later
  3. 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.

Be the first to know

New features, release notes & CMS guides — a couple of emails a month, no spam.