/ Article — Engineering Internals

From do_action to HTTP:
The Engineering Behind WordPress Webhook Delivery

Every outbound webhook starts life as a do_action() call somewhere in WordPress or a plugin. Getting from that call to a reliable HTTP delivery involves hook discovery, dynamic listener registration, PHP object serialization, and payload field mapping. This article walks through each layer with the real engineering problems and design decisions behind them.

/ Hook Discovery

Why $wp_filter alone isn't enough

The first challenge any webhook plugin faces: how do you show users a useful, accurate list of every hookable event on their specific WordPress install? The obvious answer is to read the global $wp_filter array — the data structure that add_action() and add_filter() write to, which contains every hook that has at least one registered callback at that moment.

This works for a small handcrafted list of common hooks, but it breaks down as soon as users want to use hooks from plugins like WooCommerce. The core issue is WordPress request context: when the admin UI calls GET /fswa/v1/triggers (a WP REST API request), WordPress bootstraps in REST mode. Many plugins register their hooks conditionally — only during front-end requests, only during admin-ajax.php, or only when a specific page template loads.

A hook like woocommerce_order_status_changed is registered by WooCommerce during the order processing lifecycle, which never runs during a REST API call. So $wp_filter during that request simply doesn't contain it. The same problem applies to hooks from form plugins, membership plugins, and anything that gates its add_action() calls behind is_admin(), !is_admin(), or specific action priorities.

The original workaround — a hardcoded getSuggestedTriggers() list of ~250 manually curated hooks — was immediately stale and said nothing about what was actually installed on the user's site.

Static scan: HookDiscoveryService

The solution is to combine what WordPress can tell us at request time with what the source code guarantees will fire at runtime. HookDiscoveryService.php does a one-time recursive scan of all active plugins (resolved to filesystem paths via WP_PLUGIN_DIR), WordPress core (wp-includes/ and wp-admin/), and the active theme and child theme.

For each PHP file it runs a single regex over the raw source:

HookDiscoveryService.php — static hook extraction regex
preg_match_all( '/do_action(?:_ref_array)?\s*\(\s*[\'"]([a-zA-Z0-9_\-\.\/]+)[\'"]/', $content, $matches );

This intentionally only matches string literal hook names. Dynamic hook names like do_action( "save_post_{$post->post_type}" ) are not captured — they cannot be statically known. The trade-off is conservative and noise-free; users can always type a custom hook name in the UI.

The regex was extended once in development (\/ was added to the character class) when IvyForms integration was added — its hooks follow a namespace/event/name convention like ivyforms/form/after_submission, and the original pattern didn't allow forward slashes. More on that in the integrations section.

RecursiveCallbackFilterIterator skips vendor/, node_modules/, and .git directories inside each plugin folder. The plugin's own files are excluded from the scan to avoid false positives.

Caching and cache invalidation

Scanning an entire WordPress install is not free. Results are stored in a WordPress transient (fswa_discovered_hooks_v3) with a 24-hour TTL. The cache is busted immediately on plugin activation, deactivation, and theme switches:

cache invalidation hooks
add_action('activated_plugin', [HookDiscoveryService::class, 'clearCache']); add_action('deactivated_plugin', [HookDiscoveryService::class, 'clearCache']); add_action('switch_theme', [HookDiscoveryService::class, 'clearCache']);

The cache key went through three versions (_v1, _v2, _v3) as the return format changed between releases. Bumping the key forces a clean rebuild on existing installs without a migration step — old transient values are simply ignored.

The scanner returns array<hookName, sourceSlug> — the hook name as key, the plugin directory name (e.g. contact-form-7) or wordpress for core as value. First plugin to define a hook wins on conflicts.

/ Runtime Merge

Combining two sources of truth

Neither $wp_filter nor the static scan alone is complete. The solution is to use both and merge them. TriggersController.php serves GET /fswa/v1/triggers and combines both sources into a single grouped response:

1. Iterate $wp_filter keys, apply an exclusion filter, and bucket each hook into a category.
2. Run HookDiscoveryService::discover() and merge any hook not already seen.
3. Return { grouped: { category: [hookName, …] }, categories: { key: label }, allowCustom: true }.

Exclusion filter

Hooks that are technically do_action calls but are useless as webhook triggers get dropped before reaching the UI. These are UI-only exclusions — they don't affect which hooks the plugin will intercept at runtime. A user can still type any hook name manually.

TriggersController.php — exclusion patterns
$excludePatterns = [ '/^admin_/', // admin page hooks '/^wp_ajax/', // ajax handler hooks '/^rest_api/', // REST framework hooks '/^wp_enqueue/', // asset enqueueing '/^sanitize_/', // sanitization filters '/^pre_get/', // pre-query filters '/^get_/', // getter filters ];

Category assignment

Runtime hooks from $wp_filter are bucketed by name-pattern matching — woocommerce catches anything starting with /^woocommerce/, users catches /user|login|logout|password|role|profile/, and so on.

Statically-scanned hooks from plugins are bucketed under their plugin slug. All Contact Form 7 hooks go under contact_form_7, displayed as "Contact Form 7". Third-party plugin hooks self-group with no hardcoded mapping required.

An earlier version of this API returned ~643 KB of per-hook metadata per request. The current version returns only names grouped by category; labels are computed client-side from the category key. On a site with WooCommerce and several form plugins active, the response now sits at 83.7 kB covering ~2,500 discovered do_action calls — a roughly 8× reduction with no loss of information for the UI.

/ Dynamic Registration

Listening on hooks at boot time

Once a user saves a webhook with a trigger set to (say) wpcf7_before_send_mail, the plugin needs to listen on that exact action. HooksHandler.php does this at boot time by reading all configured trigger names and registering a listener for each:

HooksHandler.php — boot-time registration loop
$allTriggers = $this->dispatcher->getWebhooksRepository()->getAllTriggers(); foreach ($allTriggers as $trigger) { add_action($trigger, [$this, 'registerTriggerHandler'], 10, PHP_INT_MAX); }

PHP_INT_MAX as the accepted argument count means the handler receives every argument WordPress passes to the hook, regardless of arity. Without it, a hook that passes three arguments would only deliver the first one to the handler — the default for add_action is 1.

Deduplication

WordPress can fire the same hook multiple times in a single request — save_post fires on autosave, on the main save, and sometimes on meta updates. Delivering duplicate webhooks for the same event is a data quality problem for the receiver. Two deduplication layers guard against this:

In-process static lock — catches duplicate calls within the same PHP execution:

in-process deduplication
static $triggerLocks = []; $uniqueKey = md5($trigger . serialize($args)); if (isset($triggerLocks[$uniqueKey])) { return; } $triggerLocks[$uniqueKey] = true;

Cross-request transient lock (30 seconds) — catches the case where Action Scheduler or WP-Cron reschedules a job while the original request is still processing:

cross-request transient lock
$transientKey = 'fswa_trigger_' . md5($trigger . serialize($args)); if (get_transient($transientKey)) { return; } set_transient($transientKey, true, 30);
/ Payload Construction

Building the delivery envelope

Every webhook delivery gets a consistent envelope, constructed in Dispatcher::dispatch():

Dispatcher.php — base payload shape
$payload = apply_filters('fswa_payload', [ 'event' => [ 'id' => $eventUuid, // wp_generate_uuid4() 'timestamp' => $eventTimestamp, // gmdate ISO 8601 'version' => '1.0', ], 'hook' => $trigger, 'args' => $this->normalizeArgs($args), 'timestamp' => time(), 'site' => ['url' => home_url()], ], $trigger, $args);

$eventUuid is generated once per dispatch() call and shared across all webhooks subscribed to the same trigger. If five webhooks listen on woocommerce_order_status_changed, they all carry the same event.id. This lets the receiving end deduplicate or correlate deliveries without needing to match payloads. The UUID and timestamp are also sent as HTTP headers on every delivery: X-Event-Id and X-Event-Timestamp.

The serialization problem: normalizeArgs

WordPress hook arguments are untyped PHP values. They can be scalars, arrays, closures, objects, or any combination. normalizeArgs walks the argument list and applies type-specific handling:

Scalars — pass through directly as JSON-serializable values.
Arrays — recursed with the same normalization applied to each value.
Closure — not serializable; replaced with null.
DateTimeInterface — serialized to ISO 8601 atom string.
Traversable — iterated to a plain array.
JsonSerializablejsonSerialize() is called.
Objects with get_data() — WooCommerce WC_Order, WC_Product, etc. follow this convention.
Objects with get_properties() — some WP core objects.
Everything elseget_object_vars() as a last resort.

Every serialized object gets a __type key set to get_class($value):

__type annotation on serialized objects
return array_merge(['__type' => get_class($value)], $data);

Without __type, two different object classes with identical field names would be indistinguishable on the receiving end. It also serves as documentation: the receiver knows exactly which WooCommerce or WordPress class produced each argument in the payload.

A filter escape hatch lets third-party code override normalization for specific classes before the default handling runs:

fswa_normalize_object filter — third-party override point
$custom = apply_filters('fswa_normalize_object', null, $value); if (is_array($custom)) { return array_merge( ['__type' => get_class($value)], array_map([$this, 'normalizeValue'], $custom) ); }
/ Third-Party Integrations

Normalizing plugin-specific objects

Generic reflection is fine for simple objects but produces unusable noise for complex CMS entities. The plugin ships built-in integrations for plugins whose hook arguments would otherwise be unreadable.

Contact Form 7

CF7 fires three hooks around mail sending: wpcf7_before_send_mail, wpcf7_mail_sent, and wpcf7_mail_failed. The problem is their $args signatures are not consistent — wpcf7_mail_sent and wpcf7_mail_failed only pass a WPCF7_ContactForm object. There is no submission data anywhere in $args. Without an integration, the webhook payload would contain form metadata but none of the fields the user actually submitted.

The fix relies on the fact that WPCF7_Submission is a singleton — it stays alive in memory for the duration of the request. Even when the hook only passes the form object, WPCF7_Submission::get_instance() returns the active submission. The integration verifies the singleton's form ID matches before pulling it in, so it never attaches a stale submission from a different form:

CF7Integration.php — pulling submission data from the singleton
private function normalizeContactForm(WPCF7_ContactForm $form, bool $includeSubmission = true): array { $data = [ 'id' => $form->id(), 'title' => $form->title(), 'name' => $form->name(), 'locale' => $form->locale(), ]; if ($includeSubmission) { $submission = WPCF7_Submission::get_instance(); if ($submission && $submission->get_contact_form()->id() === $form->id()) { $data['submission'] = $this->normalizeSubmission($submission); unset($data['submission']['form']); // avoid redundant nesting } } return $data; }

The $includeSubmission = false flag prevents recursion: when normalizeSubmission calls back into normalizeContactForm to embed the form reference, that inner call skips the singleton lookup. Internal CF7 fields (keys starting with _) are stripped from posted data before they reach the payload — so fields like _wpcf7, _wpcf7_version, and _wpcf7_unit_tag never appear in the webhook.

The final payload shape for a wpcf7_mail_sent event contains { id, title, name, locale, submission: { fields: {...}, meta: { url, timestamp, remote_ip, … }, uploaded_files: […] } } — the same shape regardless of whether the hook passed the submission directly or whether it was retrieved from the singleton.

IvyForms

IvyForms submission hooks (ivyforms/form/before_submission, ivyforms/form/after_submission) pass four arguments: (int $formId, array $submissionData, Field[] $formFields, int|null $entryId). The problem is that $submissionData is keyed by numeric field ID, while $formFields is an array of typed Field entity objects. A receiver getting the raw payload would need to cross-reference two separate arrays — matching numeric IDs to labels — to understand what was submitted.

The integration registers two filters. First, fswa_normalize_object converts each Field entity in args[2] to { id, label, type, required }. Then fswa_payload runs after normalization and injects the submitted value from args[1] into each already-normalised field entry:

IvyFormsIntegration.php — injecting submitted values into field definitions
public function transformSubmissionPayload(array $payload, string $trigger): array { if (!in_array($trigger, self::SUBMISSION_HOOKS, true)) { return $payload; } $submissionData = $payload['args'][1] ?? []; foreach ($payload['args'][2] as &$fieldData) { $fieldId = $fieldData['id'] ?? null; if ($fieldId === null) { continue; } // submissionData may be keyed by int or string ID — try both $fieldData['value'] = $submissionData[(string) $fieldId] ?? $submissionData[$fieldId] ?? null; } unset($fieldData); return $payload; }

The double lookup — (string) $fieldId first, then $fieldId as-is — handles the fact that PHP array keys may be stored as integers or strings depending on how IvyForms built the submission array. The result: args[2] in the final payload is a single self-contained array of { id, label, type, required, value } entries, with no separate lookup needed by the receiver.

The slash problem

IvyForms hook names contain forward slashes: ivyforms/form/after_submission. This caused two independent bugs that both had to be fixed separately:

Bug 1 — Discovery regex dropped them. The original character class [a-zA-Z0-9_\-\.] did not include /, so these hooks were never found by the static scan. Fix: add \/ to the character class.

Bug 2 — REST API 404s. Hook names with slashes were passed as literal URL path segments (/fswa/v1/schemas/ivyforms/form/after_submission), causing WordPress to fail route matching. Fix: encodeURIComponent() on the client side; on the server, expand the route regex to accept % and ., then rawurldecode() before sanitize_text_field() to recover the original hook name.

/ Field Mapping

Reshaping payloads with dot-notation paths

Users can reshape the payload before it is sent: rename fields, exclude noise, move nested values to the root. The mapping config is stored in the schema table:

field mapping configuration — stored JSON
{ "mappings": [ { "source": "args.0.billing_email", "target": "email" } ], "excluded": ["args.0.customer_note"], "includeUnmapped": true }

PayloadTransformer::applyFieldMapping() first flattens the entire payload to dot-notation paths, applies explicit mappings, then optionally carries over everything else when includeUnmapped is true.

The Gravity Forms sub-field problem

Gravity Forms uses numeric sub-field IDs with dots in the key name: field 6.1, 6.2, etc. A key 6.1 in a PHP array, when represented in dot notation, is ambiguous — is this key 6 → key 1, or key 6.1?

The fix: dot characters inside array keys are escaped as \. when building paths. splitPath() uses a negative lookbehind to split only on unescaped dots:

PayloadTransformer.php — splitPath with escaped dot support
$parts = preg_split('/(?<!\\\\)\./', $path); return array_map(fn($p) => str_replace('\.', '.', $p), $parts);

Path args.0.6\.1 splits into ['args', '0', '6.1'] — the segment 6.1 is treated as a single key. Without this, Gravity Forms sub-fields would silently produce incorrect path resolution.

/ Architecture

The full delivery pipeline

do_action fires
    └─► HooksHandler::registerTriggerHandler()
            (deduplication: static lock + 30s transient)
            └─► Dispatcher::dispatch()
                    ├─ build base payload (event UUID, hook, normalizeArgs)
                    ├─ apply_filters('fswa_payload')        ← integration point
                    ├─ PayloadTransformer::transform()
                    │       ├─ captureExamplePayload()      ← first-time only
                    │       ├─ user enrichment (WP_User)
                    │       ├─ field mapping (dot notation, escaped keys)
                    │       └─ load conditions config
                    ├─ ConditionEvaluator::evaluate()       ← skip if rules fail
                    ├─ LogService::logPending()
                    └─ QueueService::enqueue()
                            └─► (WP-Cron / Action Scheduler)
                                    └─► Dispatcher::sendToWebhook()
                                            ├─ WPHttpTransport::send()
                                            ├─ LogService::appendAttemptHistory()
                                            └─ reschedule or permanently_fail

Hook Discovery (admin UI):
    GET /fswa/v1/triggers
        ├─ $wp_filter (runtime, live hooks)
        └─ HookDiscoveryService::discover()
                ├─ active plugins (PHP file scan, regex extraction)
                ├─ WP core (wp-includes, wp-admin)
                └─ active theme + child theme
                (cached 24h transient, busted on activate/deactivate/switch_theme)

A few design decisions in this diagram are worth calling out explicitly:

Decision Reason
Static scan + runtime merge Neither source alone is complete; union gives best coverage
Regex matches string literals only Dynamic hook names can't be statically known; conservative is better than noisy
PHP_INT_MAX accepted args Hooks pass varying argument counts; we need all of them
Event UUID shared per dispatch Allows correlation across multiple webhooks triggered by the same event
__type on serialized objects Receiver needs type context; opaque blobs are useless
fswa_normalize_object filter Keeps integrations decoupled; third-party plugins can ship their own normalizer
Escaped dots in path notation Gravity Forms-style fractional field IDs (6.1) break naïve dot-split
Transient cache key versioning (_v3) Format changes invalidate existing caches without a migration step
/ Production Plugin

If you'd rather not build this yourself

Flow Systems Webhook Actions is an open-source WordPress plugin that implements this entire pipeline — hook discovery, dynamic registration, async queue delivery, payload normalization, field mapping, and structured logs. The async delivery architecture and REST API control layer are covered in separate articles. The plugin is free, GPL-licensed, and available on WordPress.org and GitHub. Full details on the WordPress webhook plugin page.

/ FAQ

Common questions

$wp_filter is a runtime data structure — it only contains hooks that have at least one registered callback at the moment it is read. Many plugins register their hooks conditionally: only during front-end requests, only during admin-ajax.php, or only when a specific page template loads.

A REST API request bootstraps WordPress in REST mode, so plugins that gate their add_action() calls behind is_admin() or specific lifecycle events never register their hooks during that request. $wp_filter at REST API time is an accurate snapshot of that context — not an exhaustive list of every hookable event on the site.
A normalizeArgs function walks the argument list and applies type-specific handling: scalars pass through directly, arrays are recursed, Closure instances become null, DateTimeInterface objects convert to ISO 8601 strings, and JsonSerializable objects call jsonSerialize().

WooCommerce-style objects with get_data() or get_properties() call those methods. Everything else falls back to get_object_vars(). Every serialized object gets a __type key set to get_class() so the receiving end knows what it's looking at. A fswa_normalize_object filter lets third-party code override the default handling for specific classes.
Reflection operates on loaded classes — it cannot enumerate hooks registered conditionally or in files that were never included during the current request.

token_get_all() is accurate but much slower across thousands of PHP files in core and all active plugins. A single regex run over raw file contents is fast enough for a one-time scan of an entire WordPress install (cached for 24 hours) and conservative by design: it only matches string literal hook names, deliberately ignoring dynamic names like do_action( 'save_post_' . $post_type ) that cannot be statically known. The trade-off is an occasional false negative that the user can cover by typing the hook name manually.
When a PHP object is serialized into the webhook payload, its class name is stored under __type alongside the object's data. For example, a WooCommerce order argument becomes { "__type": "WC_Order", "id": 1042, "status": "completed", ... }.

Without __type, two different object classes with identical field names would be indistinguishable on the receiving end. It also serves as built-in documentation: the receiver knows exactly which WooCommerce or WordPress class produced each argument in the payload.