---
title: "From do_action to HTTP: WordPress Webhook Delivery"
description: "How WordPress webhook plugins discover hooks, register listeners, and build delivery payloads. Engineering internals with real code."
url: "https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/"
date: "2026-04-22"
---

# From do_action to HTTP: WordPress Webhook Delivery

TL;DR

-   Hook discovery combines `$wp_filter` runtime scanning with static PHP source analysis — neither approach alone covers all available hooks
-   Payload serialization converts WP\_Post, WC\_Order, and custom PHP objects into flat JSON, normalizing nested objects and discarding non-serializable properties
-   The full delivery path: `do_action` → hook listener → payload normalization → queue dispatch → async HTTP worker → delivery log entry

/ Hook Discovery

## Why isn't **$wp\_filter alone** enough for hook discovery?

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.

WooCommerce is a particularly important case to cover. According to [BuiltWith e-commerce technology data](https://trends.builtwith.com/shop/WooCommerce), WooCommerce powers roughly a quarter of all online stores globally — making its hooks among the most business-critical events any WordPress site fires, and missing them from the discovery list would be a significant gap for most users.

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.

The scale of the problem becomes clear from the numbers: [WordPress.org](https://wordpress.org/plugins/) lists over 59,000 plugins in its repository, each potentially registering its own set of hooks. No handcrafted list could cover that breadth — let alone stay current as plugins are updated and new ones appear.

### 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.

`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

## How do you combine **two sources of truth** for complete hook coverage?

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.

The breadth of that coverage matters at scale. [W3Techs web technology surveys](https://w3techs.com/technologies/details/cm-wordpress) report that WordPress powers over 43% of all websites on the internet — a webhook plugin that misses conditionally-registered plugin hooks fails across a significant share of production installs.

/ 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.  
**JsonSerializable** — `jsonSerialize()` is called.  
**Objects with `get_data()`** — WooCommerce `WC_Order`, `WC_Product`, etc. follow this convention.  
**Objects with `get_properties()`** — some WP core objects.  
**Everything else** — `get_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**

| 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 | 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](https://wpwebhooks.org/blog/async-webhooks-wordpress/) and [REST API control layer](https://wpwebhooks.org/blog/wordpress-webhooks-rest-api/) are covered in separate articles. The plugin is free, GPL-licensed, and available on [WordPress.org](https://wordpress.org/plugins/flowsystems-webhook-actions/) and [GitHub](https://github.com/flowsystems-pl/wordpress-webhook-actions). Full details on the [WordPress webhook plugin page](https://wpwebhooks.org/wordpress-webhook-plugin/).

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"From do_action to HTTP: WordPress Webhook Delivery","description":"How WordPress webhook plugins discover hooks, register listeners, and build delivery payloads. Engineering internals with real code.","datePublished":"2026-04-22","dateModified":"2026-04-22","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/","image":"https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/og_image.jpg","keywords":["wordpress webhook","do action webhook","wordpress hook discovery","wordpress payload serialization","webhook plugin internals"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"From do_action to HTTP: WordPress Webhook Delivery","item":"https://wpwebhooks.org/blog/wordpress-webhook-delivery-engineering/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why doesn't $wp_filter contain all WordPress hooks?","acceptedAnswer":{"@type":"Answer","text":"$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."}},{"@type":"Question","name":"How does WordPress webhook payload serialization handle complex PHP objects like WooCommerce orders?","acceptedAnswer":{"@type":"Answer","text":"WordPress hook arguments are untyped PHP values. A normalizeArgs function walks the argument list and applies type-specific handling: scalars pass through directly, arrays are recursed, Closures become null, DateTimeInterface objects are converted to ISO 8601 strings, JsonSerializable objects call jsonSerialize(), and WooCommerce-style objects with get_data() or get_properties() methods call those. 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 is looking at. A fswa_normalize_object filter lets third-party code override the default handling for specific classes."}},{"@type":"Question","name":"Why does WordPress hook discovery use a regex instead of PHP reflection or token_get_all()?","acceptedAnswer":{"@type":"Answer","text":"Reflection operates on loaded classes — it cannot enumerate hooks that are registered conditionally or in files that were never included during the current request. token_get_all() is correct 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."}},{"@type":"Question","name":"What does the __type field in webhook payloads mean?","acceptedAnswer":{"@type":"Answer","text":"When a PHP object is serialized into the webhook payload, its class name is stored under the __type key alongside the object's data. For example, a WooCommerce order argument becomes { \\"}}]}
```
