Skip to content

Webhook Events

Plugins can emit events that subscribers (external services like Zapier, n8n, Make, or your own endpoints) react to. newmeta ships a full webhook system with HMAC signatures, queue-based delivery, and retry/circuit breaker. This page shows how a plugin registers and dispatches events.

Two event types

TypeSourceRegistration
AutoGenerated from content_construct=table pluginsAutomatic during installWebhookEvents()
ManualCustom business events emitted by the plugin$this->webhookEvents in install()

A plugin can have both: auto-events for CRUD operations on its tables, plus manual events for things like "order completed" or "certificate issued".

Auto-events (from content_table)

Every plugin with content_construct=table receives three auto-events per table:

Event nameTrigger
item.{table}.createdAfter POST /api/backend/item
item.{table}.updatedAfter PATCH /api/backend/item
item.{table}.deletedBefore DELETE /api/backend/item (record still available)

Example: a plugin with content_table = ["my_articles"] automatically receives item.my_articles.created, item.my_articles.updated, and item.my_articles.deleted — zero code.

The auto-installer in install_controller.php::installWebhookEvents() reads every table from plugin_backend and inserts the events into webhook_events:

php
// Excerpt from install_controller.php
$pbQ = query("SELECT content_table FROM plugin_backend
              WHERE plugin_id = $pluginID
                AND content_construct = 'table'
                AND content_table != ''");
while ($row = fetch_assoc($pbQ)) {
    $table = real_escape_string($row['content_table']);
    foreach (['created', 'updated', 'deleted'] as $op) {
        $eventName = "item.$table.$op";
        query("INSERT IGNORE INTO webhook_events
               (plugin_id, event_name, event_type, content_table, description)
               VALUES ($pluginID, '$eventName', 'auto', '$table', '...')");
    }
}

Registering manual events

For business events that aren't triggered by CRUD, populate $this->webhookEvents in install():

php
public function install()
{
    // ... other properties

    $this->webhookEvents = [
        '{
            "name":        "blog.published",
            "type":        "manual",
            "description": "Blog article published"
        }',
        '{
            "name":        "blog.unpublished",
            "type":        "manual",
            "description": "Blog article retracted"
        }'
    ];
}

Each entry is a JSON string with name, type (conventionally "manual"), and an optional description. install_controller::installWebhookEvents() uses INSERT IGNORE — re-installing is safe.

Dispatching an event

From plugin code (API model, script, controller):

php
require_once $_SERVER['DOCUMENT_ROOT'] . '/_core/system/webhook/WebhookDispatcher.php';

WebhookDispatcher::dispatch('blog.published', [
    'post_id'      => $postId,
    'title'        => $title,
    'author_id'    => $authorId,
    'published_at' => date('c'),
]);

dispatch($eventName, $data) is non-blocking — it writes an entry into webhook_queue and returns immediately. The WebhookDeliveryWorker picks up the job via console/bin process-webhooks and performs the HTTP POST.

Sensitive-data filter

Before the queue insert, the dispatcher automatically filters sensitive keys out of the payload — recursively, case-insensitive:

password, secret, hash, key, token, salt

Example: ['email' => '[email protected]', 'password' => 'p4ss', 'settings' => ['api_key' => 'xyz']] becomes ['email' => '[email protected]', 'settings' => []]. This protects against accidentally leaking credentials in webhooks.

Additional keys can be added to WebhookDispatcher::SENSITIVE_KEYWORDS.

HMAC signature

The worker signs every payload with an HMAC-SHA256 hash using the subscription secret:

php
$signature = hash_hmac('sha256', $payload, $subscription->secret);

The receiver validates:

php
$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', $payload, 'your_webhook_secret');
if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    die('Invalid signature');
}
$data = json_decode($payload, true);

The secret is generated and shown once when you create the subscription in the backend (/admin/api-keys → Webhook section) — it cannot be retrieved later. Rotation: a new secret via the "Rotate" button immediately invalidates the old one.

Delivery headers

Every outbound HTTP POST carries these headers:

Content-Type: application/json
User-Agent: newmeta-CMS/3.0 Webhook
X-Webhook-Event: {event_name}
X-Webhook-Signature: {hmac_sha256}
X-Delivery-ID: {queue_id}

In addition, custom headers from webhook_subscriptions.custom_headers (JSON key:value) are merged on at the end.

Retry + circuit breaker

On a 4xx/5xx response or a connection error:

  • Retry with exponential backoff: after the initial failed attempt, up to 5 retries follow with waits of 30s → 2min → 8min → 32min → 2h (up to 6 delivery attempts in total). Default from webhook_subscriptions.max_retries = 5, adjustable per subscription (in code: (int)($job['max_retries'] ?: 5)).
  • Circuit breaker: after 10 consecutive failures, the subscription is automatically disabled (active = 0) and disabled_reason is set. An admin has to reactivate it manually.
  • Cleanup: successfully delivered queue items are deleted automatically after 24h.

Starting the worker

The WebhookDeliveryWorker runs via the CLI:

bash
php console/bin process-webhooks --time-limit=25

Recommended cron setup for 30-second cadence:

cron
* * * * * php /path/console/bin process-webhooks --time-limit=25
* * * * * sleep 30 && php /path/console/bin process-webhooks --time-limit=25

Details on scheduling: Scheduled Tasks.

Event matching (wildcards)

Subscriptions support three matching modes:

Subscription eventsMatches
"blog.published"only blog.published
"item.*"every auto-event (item.blog.created, item.users.deleted, …)
"*"every event system-wide

This makes audit subscriptions possible — one sub that logs everything without needing manual updates as new plugins are added.

System events (not plugin-bound)

The core dispatches 11 system events that no plugin has to register:

EventWhen
page.publishedAfter a Pagebuilder publish
page.draft_discardedDraft discarded
media.uploadedMedia upload succeeded
media.deletedMedia file deleted
auth.loginBackend login
auth.logoutBackend logout
order.createdShop order (Stripe + PayPal dispatch separately)
form.submittedForm submit via /api/form/submit
user.registeredNew frontend user registered
emailmarketing.subscribedNewsletter subscribe
deployment.completedAfter a deploy-target run

Database tables

TablePurpose
webhook_eventsEvent registry (per plugin, auto/manual)
webhook_subscriptionsUser-created subscriptions (name, URL, secret, event list, custom headers, max_retries, failure_count, disabled_reason)
webhook_queuePending/retry/delivered queue
webhook_deliveriesLog (request payload, response code, duration)

Schema migration: _migrations/core/010_create_webhooks.sql. Details in Migrations.

Management UI

  • Create subscriptions: /admin/api-keys → "Webhooks" tab → event picker (grouped multi-select with "Select All" per plugin), HTTPS URL, auto-generated secret, test-ping button
  • Delivery history: /admin/task-manager → "Webhooks" tab → queue + stats (success rate)

Testing tips

  • Test ping: inside the webhook management panel, "Test Ping" sends a dummy event to the subscription URL. Perfect for initial setup.
  • ngrok for local receivers: ngrok http 3001 and use the ngrok URL as the subscription URL — HTTPS is enforced, which fits.
  • Queue inspection: SELECT * FROM webhook_queue WHERE status IN ('pending', 'retry') ORDER BY next_retry_at. retry_count and error_message show why a delivery is stuck.

Common issues

dispatch() without require_once

WebhookDispatcher isn't part of the autoloader — every call has to include the file first: require_once $_SERVER['DOCUMENT_ROOT'] . '/_core/system/webhook/WebhookDispatcher.php'. Otherwise: Fatal error: Class 'WebhookDispatcher' not found.

HTTP instead of HTTPS as the subscription URL

The subscription UI enforces https://. For local testing, fall back to http:// if needed (ngrok provides HTTPS) — never run production webhooks over HTTP, since the signature validation offers no value against MITM there.

Receiver responds slowly → timeouts

The worker has a 10s cURL timeout. Receivers that take longer (synchronous DB queries, external API calls) cause retries. Recommendation: respond immediately with 200 and process the work asynchronously (queue or background job).

INSERT IGNORE — detecting duplicate events

installWebhookEvents() uses INSERT IGNORE, so events with the same (plugin_id, event_name) are silently skipped. If a description needs to be updated, the plugin must run an UPDATE webhook_events call in update() — re-running install() changes nothing.

Sensitive-keyword filter matches too aggressively

The filter matches every key that contains one of the keywords (case-insensitive substring). api_key, key, private_key are all filtered — so are harmless fields like category_key or key_user_id. That's deliberately conservative. If needed: keep sensitive data in flat, non-keyword-matched sub-keys (e.g. public_identifier).

See also

  • Plugin Anatomy — the $this->webhookEvents property
  • API Endpoints — dispatching events from endpoints
  • Scheduled Tasks — the process-webhooks worker on cron
  • Migrations_migrations/core/010_create_webhooks.sql
  • Webhook management panel: /admin/api-keys → "Webhooks" tab
  • Delivery history: /admin/task-manager → "Webhooks" tab