Webhooks: Notify External Systems of CMS Changes

The optional aimeos/pagible-webhooks package sends signed HTTP notifications when pages, shared elements or files are published, moved, deleted, restored or purged. Use them to rebuild a static site, refresh a search index, purge a CDN or sync a shop without polling the CMS.

Deliveries run through the Laravel queue, each destination as a separate job. Every tenant manages its own subscriptions in the admin panel or through GraphQL, while operators can add internal endpoints that receive the events of all tenants. Source: package README and default configuration.

Install the package

The package requires PHP 8.2+ with the curl extension and aimeos/pagible-core 0.13. Install aimeos/pagible-admin for the management panel and aimeos/pagible-graphql for the GraphQL API. Then run:

composer require aimeos/pagible-webhooks
php artisan cms:install:webhooks
php artisan migrate

cms:install:webhooks publishes config/cms/webhooks.php and the admin panel bundle to public/vendor/cms/webhooks, and clears the Lighthouse schema cache if Lighthouse is installed. The migration creates the cms_webhooks table for the subscriptions.

Enable webhooks and run a worker

Webhooks are disabled by default; no events are dispatched until you enable them. Use an asynchronous queue connection and a dedicated queue name:

CMS_WEBHOOKS_ENABLED=true
CMS_WEBHOOKS_QUEUE_CONNECTION=redis
CMS_WEBHOOKS_QUEUE=cms-webhooks

Start a worker for that queue, e.g. under Supervisor or systemd:

php artisan queue:work redis --queue=cms-webhooks

The sync driver delivers immediately without a worker, but failed deliveries aren't retried. The null driver queues nothing and logs cms.webhook.delivery_blocked with the reason invalid_queue.

Each destination is its own job and a slow endpoint occupies a worker for up to the HTTP timeout. Monitor queue depth, oldest job age and failed jobs, and add workers if scheduled publications create bursts.

Configuration reference

Settings live in config/cms/webhooks.php (config key cms.webhooks):

Key
Default
Description
enabled
false
Turn webhooks on (CMS_WEBHOOKS_ENABLED)
queue.connection
app default
Queue connection (CMS_WEBHOOKS_QUEUE_CONNECTION)
queue.name
cms-webhooks
Queue name (CMS_WEBHOOKS_QUEUE)
timeout
10
Seconds to wait for the response
limit
25
Subscriptions per tenant
endpoints
[]
Operator endpoints for internal services
deny_cidrs
[]
IP addresses and CIDR ranges denied for all destinations, e.g. ['10.1.0.0/16', '10.2.0.5']

Lowering limit doesn't delete existing subscriptions, but none can be added until the tenant is below the limit again. An invalid deny_cidrs entry blocks all deliveries until it's fixed. Run php artisan config:clear and restart the queue workers after changing these values.

Operator endpoints

Internal services such as search indexers or cache purgers are configured in the config file instead of the admin panel. They receive the events of all tenants, which the tenant_id in the payload tells apart:

'endpoints' => [
    'indexer' => [
        'url' => env( 'CMS_WEBHOOK_INDEXER_URL' ),        // e.g. http://indexer:8080/cms
        'secret' => env( 'CMS_WEBHOOK_INDEXER_SECRET' ),  // "whsec_..." or a list
        'events' => ['page.published', 'page.deleted'],
    ],
],
  • Names may contain up to 64 letters, digits, _ and - and must not be numbers. The env variables above are only examples, choose your own names.
  • Create a secret with echo "whsec_$(openssl rand -base64 32)". To rotate it, use a list of the new and the previous secret, e.g. 'secret' => [env( 'CMS_WEBHOOK_INDEXER_SECRET' ), env( 'CMS_WEBHOOK_INDEXER_PREVIOUS_SECRET' )]; empty entries are ignored.
  • Endpoints may use HTTP or HTTPS on any port and reach private addresses. Prefer HTTPS if the traffic leaves the host; certificates of a private CA must be trusted by the worker hosts.
  • Invalid endpoints are skipped and logged (cms.webhook.endpoint_invalid) while all others still receive the event.
  • A changed URL or secret applies to queued deliveries too; removing an event cancels its queued deliveries.
  • Endpoints aren't shown in the admin panel and report through the log only.

Grant the config:webhook permission

The package registers the config:webhook permission. It is required for the admin panel entry and for every webhook GraphQL query and mutation.

Of the default roles in config/cms.php, only admin (*) includes it. publisher, editor and viewer don't, so add config:webhook explicitly to a custom role or user if non-admins should manage webhooks. Names of subscriptions are visible to everyone with this permission, so don't put secrets in them. See Authorization and permissions for assigning roles.

Manage webhooks in the admin panel

Users with config:webhook see a Webhooks entry in the admin navigation. The list shows each subscription with its endpoint, events, status and health; use the search field, the status filter in the sidebar and Refresh to find entries. A warning above the list tells you when webhooks are disabled or blocked by the server configuration.

To add a subscription:

  1. Click Add webhook.
  2. Enter the HTTPS endpoint URL. It can't be changed later; delete the subscription and add a new one instead.
  3. Select one or more Events from the list.
  4. Optionally enter a Name to tell subscriptions to the same endpoint apart.
  5. Switch on Active. New subscriptions are inactive unless activated.
  6. Save and copy the Webhook secret. It's shown only once, after creating or rotating.

In the edit dialog you can change the events, name and status, rotate the secret and send a Test event. Select subscriptions in the list to delete them.

  • The URL isn't shown again, only the endpoint without the query string and the last path segment, which may contain credentials (e.g. https://hooks.slack.com/services/T0/B0/).
  • Removing events cancels queued deliveries of those events. Inactive subscriptions drop their queued deliveries.
  • Rotating keeps the status and queued deliveries. For 24 hours, requests are signed with the new and the previous secret; rotating again drops the older one.
  • Test immediately sends a signed webhook.ping event, even to inactive subscriptions, and shows the HTTP status or the error. Test events aren't retried, but a successful one resumes a paused destination.

Events and payload

These events can be subscribed:

  • page.published, page.moved, page.deleted, page.restored, page.purged
  • element.published, element.deleted, element.restored, element.purged
  • file.published, file.deleted, file.restored, file.purged

The body is JSON with the event name, the tenant ID, the UTC event time (the same for all attempts) and the content and version IDs. Page events also contain path and domain if available:

{
  "event": "page.published",
  "tenant_id": "tenant",
  "timestamp": "2026-09-14T12:00:00.000+00:00",
  "data": {
    "id": "01995d6a-cb84-7218-9bb9-79063c4bf681",
    "version_id": "01995d6a-cb84-7218-9bb9-79063c4bf682",
    "path": "products/example",
    "domain": "example.com"
  }
}

Bulk operations in the admin panel send one event whose data is an ordered array of {id, version_id} references:

{
  "event": "page.deleted",
  "tenant_id": "tenant",
  "timestamp": "2026-09-14T12:05:00.000+00:00",
  "data": [
    {"id": "01995d6a-cb84-7218-9bb9-79063c4bf681", "version_id": "01995d6a-cb84-7218-9bb9-79063c4bf682"},
    {"id": "01995d6a-cb84-7218-9bb9-79063c4bf683", "version_id": "01995d6a-cb84-7218-9bb9-79063c4bf684"}
  ]
}

The payload is a reference, not the content. Fetch details through the CMS API if needed. Deliveries can arrive out of order, so treat each one as a hint and load the current state, or ignore events older than the last one processed for the same item. Return 2xx for events you don't handle, e.g. webhook.ping, so they aren't recorded as failed.

Verify request signatures

Requests are POST with Content-Type: application/json and User-Agent: Pagible-Webhook/1.0. They are signed like Standard Webhooks, so its libraries can verify them:

  • webhook-id: delivery ID, the same for all attempts
  • webhook-timestamp: Unix time of the attempt
  • webhook-signature: v1,<base64 HMAC>, space-separated if signed with two secrets during a rotation

To verify a request yourself:

  1. Reject missing or malformed headers.
  2. Reject a webhook-timestamp outside your allowed clock skew, e.g. five minutes.
  3. Compute v1, + base64 of the HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of the secret after whsec_.
  4. Accept the request if any received signature matches in a constant-time comparison.
  5. Claim the webhook-id atomically and return 2xx for duplicates. Keep claims for at least 24 hours plus the clock skew.

Laravel receiver

Put the route in routes/api.php or exclude it from CSRF verification:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

Route::post( '/hooks/cms', function( Request $request ) {

    $id = (string) $request->header( 'webhook-id' );
    $time = (string) $request->header( 'webhook-timestamp' );
    $signatures = explode( ' ', (string) $request->header( 'webhook-signature' ) );

    if( $id === '' || !ctype_digit( $time ) || abs( time() - (int) $time ) > 300 ) {
        abort( 401 );
    }

    // Key is the base64 part of "whsec_...", the body must be the raw one
    $key = base64_decode( substr( (string) config( 'services.cms.webhook_secret' ), 6 ) );
    $hmac = hash_hmac( 'sha256', $id . '.' . $time . '.' . $request->getContent(), $key, true );
    $expected = 'v1,' . base64_encode( $hmac );

    if( !array_filter( $signatures, fn( $signature ) => hash_equals( $expected, $signature ) ) ) {
        abort( 401 );
    }

    // Delivered before: 24 hours delivery age plus 5 minutes clock skew
    if( !Cache::add( 'cms-delivery:' . $id, true, 86700 ) ) {
        return response()->noContent();
    }

    try {
        $event = json_decode( $request->getContent(), true, 512, JSON_THROW_ON_ERROR );
        // Process $event['event'] and $event['data'] ...
    } catch( \Throwable $e ) {
        Cache::forget( 'cms-delivery:' . $id ); // so the retry isn't skipped
        throw $e;
    }

    return response()->noContent();
} );

Node.js receiver

The same check with Express. Use the raw body; re-serialized JSON won't match the signature. Replace the in-memory Set with a shared store such as Redis in production:

import crypto from 'node:crypto';
import express from 'express';

const key = Buffer.from(process.env.CMS_WEBHOOK_SECRET.slice(6), 'base64'); // "whsec_..."
const seen = new Set(); // use a shared store with 24h+ TTL in production
const app = express();

function verify(headers, body) {
  const id = headers['webhook-id'] ?? '';
  const time = headers['webhook-timestamp'] ?? '';
  const signatures = (headers['webhook-signature'] ?? '').split(' ');

  if (!id || !/^\d+$/.test(time) || Math.abs(Date.now() / 1000 - Number(time)) > 300) {
    return false;
  }

  const expected = Buffer.from('v1,' + crypto.createHmac('sha256', key)
    .update(`${id}.${time}.`).update(body).digest('base64'));

  return signatures.some((signature) => {
    const received = Buffer.from(signature);
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

app.post('/hooks/cms', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.headers, req.body)) return res.sendStatus(401);

  const id = req.headers['webhook-id'];
  if (seen.has(id)) return res.sendStatus(204);
  seen.add(id);

  const event = JSON.parse(req.body.toString('utf8'));
  // Process event.event and event.data ...
  res.sendStatus(204);
});

app.listen(3000);

Retries, pauses and timeouts

Temporary failures pause all deliveries to that destination for 30 seconds, consecutive ones for 2, 10 and then 30 minutes each. Temporary failures are:

  • timeouts, connection, TLS and DNS errors
  • HTTP 408, 425, 429 and 5xx responses

A Retry-After header in seconds extends the pause, up to 30 minutes. After the pause, one delivery probes the destination while the others wait. Any other response, including 4xx, resets the backoff and resumes all deliveries. Responses like 400, 404 or 410 are terminal and never retried. Redirects aren't followed and response bodies aren't read, only the HTTP status counts.

Deliveries still queued after 24 hours are dropped and logged as cms.webhook.delivery_expired. The pause state lives in the application cache, which must be shared by all servers and workers; with an array or null cache store, deliveries are retried one by one.

Workers abort a delivery after timeout + 13 seconds (23 seconds by default) for connecting, resolving the host name and recording the result. Set the queue connection's retry_after (or the broker's visibility timeout) higher, otherwise running deliveries are released twice. Also configure short DNS resolver timeouts on worker hosts, e.g. options timeout:1 attempts:2 in /etc/resolv.conf.

Deliveries that can't be pushed to the queue, e.g. because the queue server is down, are lost. The error is reported to the exception handler and the subscriptions show "Queue unavailable".

Delivery health and log entries

After each attempt, the subscription stores last_success_at (updated at most once a minute) or last_error with the reason, HTTP status and time. The admin panel shows the last error and, for paused subscriptions, the end of the pause (paused_until). Common reasons are http_error, timeout, connection_failed, resolution_failed, transport_error, destination_not_allowed, invalid_policy, queue_failed, invalid_encryption and delivery_failed.

Log entries go to the channel in CMS_LOG_CHANNEL, or the default log channel if it's not set. Retry, expiry, blocked and invalid-endpoint warnings are logged at most once every 10 minutes for the same problem and destination:

  • cms.webhook: a subscription was created, updated, its secret rotated or purged
  • cms.webhook.delivered: an operator endpoint received a delivery (only if CMS_LOG_CHANNEL is set)
  • cms.webhook.delivery_retried: a delivery failed temporarily
  • cms.webhook.delivery_failed: a delivery failed for good
  • cms.webhook.delivery_expired: a delivery was queued for more than 24 hours
  • cms.webhook.delivery_blocked: nothing was queued because of invalid_queue, queue_failed or invalid_policy
  • cms.webhook.endpoint_invalid: an operator endpoint is misconfigured and was skipped

Destination security

Tenant subscriptions are restricted to prevent server-side request forgery:

  • Subscriptions must use HTTPS on port 443. Private, loopback, link-local (e.g. 169.254.169.254), multicast, transition and reserved addresses are blocked, and host names from /etc/hosts aren't used.
  • Operator endpoints may use HTTP or HTTPS on any port, private and loopback addresses and /etc/hosts, but link-local, multicast, transition and reserved addresses are still blocked.
  • deny_cidrs applies to both.

Redirects and environment proxies are disabled. DNS is resolved right before each call and the allowed addresses are pinned in cURL, which falls back to the next one if a server is unreachable. The delivery is rejected with destination_not_allowed if no allowed address remains.

Multi-tenancy

Subscriptions are stored in the cms_webhooks table by tenant_id and only receive events of their own tenant. The limit setting applies per tenant. Operator endpoints receive the events of all tenants, with the tenant_id in the payload.

With a Tenancy callback registered, events without a tenant are dropped, so don't use the default (empty) tenant ID for a site in multi-tenant installations. When you remove a tenant, delete its rows in cms_webhooks together with its other data; queued deliveries of deleted subscriptions are dropped. See Multi-tenancy and SaaS setup.

GraphQL API

With aimeos/pagible-graphql installed, the schema adds these operations, all requiring an authenticated user with config:webhook:

  • Queries: cmsWebhooks, cmsWebhookEvents (available event names), cmsWebhookServer (enabled and blocked reason)
  • Mutations: addWebhook, saveWebhook, rotateWebhook, pingWebhook, purgeWebhook (up to 100 IDs)

addWebhook and rotateWebhook return the secret once. Dates are returned in UTC, e.g. 2026-09-15T12:00:00.000000Z. In saveWebhook, an omitted name keeps the current one and an empty one removes it.

mutation {
  addWebhook(input: {
    url: "https://example.com/hooks/cms"
    name: "Shop sync"
    events: ["page.published", "page.deleted"]
    status: true
  }) {
    secret
    webhook { id endpoint events status }
  }
}

Check the configuration

A broken webhook configuration never stops the application, it only stops the affected deliveries. Run the check on every deploy; it lists each problem with the setting to fix and exits with an error if there are any:

php artisan cms:webhooks:check

It validates the deny list, operator endpoints and queue connection, and also reports a queue retry_after too low for the delivery timeout, an array or null cache store, and (as a warning) subscriptions whose secrets were encrypted with an application key that isn't available any more.

Troubleshoot webhooks

  • No deliveries at all: check CMS_WEBHOOKS_ENABLED=true, run php artisan config:clear, restart workers and run php artisan cms:webhooks:check. Make sure a worker listens on the configured queue name and connection.
  • The admin panel warns that webhooks are blocked: fix the reported setting, e.g. an invalid deny_cidrs entry or a null queue connection.
  • The Webhooks entry is missing in the admin: the user needs config:webhook; run php artisan cms:install:webhooks again if the panel bundle wasn't published.
  • Signatures don't match: sign the raw request body, not re-encoded JSON, and decode only the part of the secret after whsec_. During a rotation, accept any of the space-separated signatures.
  • destination_not_allowed: the URL resolves to a blocked address. Tenant subscriptions need a public HTTPS host on port 443; use an operator endpoint for internal services.
  • Deliveries are sent twice: raise the queue's retry_after above timeout + 13 seconds and deduplicate by webhook-id in the receiver.
  • invalid_encryption after rotating APP_KEY: secrets and queued deliveries are encrypted with the application key. Keep the old key in APP_PREVIOUS_KEYS, or let the tenants rotate their secrets. If the key leaked, consider the secrets compromised.
  • A subscription stays paused: fix the receiver, then use Test in the edit dialog; a successful test event resumes deliveries immediately.