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
| Type | Source | Registration |
|---|---|---|
| Auto | Generated from content_construct=table plugins | Automatic during installWebhookEvents() |
| Manual | Custom 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 name | Trigger |
|---|---|
item.{table}.created | After POST /api/backend/item |
item.{table}.updated | After PATCH /api/backend/item |
item.{table}.deleted | Before 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:
// 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():
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):
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, saltExample: ['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:
$signature = hash_hmac('sha256', $payload, $subscription->secret);The receiver validates:
$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) anddisabled_reasonis 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:
php console/bin process-webhooks --time-limit=25Recommended cron setup for 30-second cadence:
* * * * * php /path/console/bin process-webhooks --time-limit=25
* * * * * sleep 30 && php /path/console/bin process-webhooks --time-limit=25Details on scheduling: Scheduled Tasks.
Event matching (wildcards)
Subscriptions support three matching modes:
| Subscription events | Matches |
|---|---|
"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:
| Event | When |
|---|---|
page.published | After a Pagebuilder publish |
page.draft_discarded | Draft discarded |
media.uploaded | Media upload succeeded |
media.deleted | Media file deleted |
auth.login | Backend login |
auth.logout | Backend logout |
order.created | Shop order (Stripe + PayPal dispatch separately) |
form.submitted | Form submit via /api/form/submit |
user.registered | New frontend user registered |
emailmarketing.subscribed | Newsletter subscribe |
deployment.completed | After a deploy-target run |
Database tables
| Table | Purpose |
|---|---|
webhook_events | Event registry (per plugin, auto/manual) |
webhook_subscriptions | User-created subscriptions (name, URL, secret, event list, custom headers, max_retries, failure_count, disabled_reason) |
webhook_queue | Pending/retry/delivered queue |
webhook_deliveries | Log (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 3001and use thengrokURL 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_countanderror_messageshow 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->webhookEventsproperty - API Endpoints — dispatching events from endpoints
- Scheduled Tasks — the
process-webhooksworker on cron - Migrations —
_migrations/core/010_create_webhooks.sql - Webhook management panel:
/admin/api-keys→ "Webhooks" tab - Delivery history:
/admin/task-manager→ "Webhooks" tab