Status: Draft, rev 2 (2026-05-26)
Owner: SDK team
Depends on: mbuzz/mbuzz-php ≥ 1.2.0 — adds Client::flush() for the WP-CLI flush command (see §9). 1.1.0 ships async POST dispatch and the Laravel + PSR-15 adapters this plugin builds on.
Slug: mbuzz-attribution
- Zero-config tracking for a vanilla WordPress site — install, paste API key, conversions and sessions start flowing.
- First-class WooCommerce attribution: purchases (with revenue), subscription renewals (recurring revenue), refunds.
- Identity stitching: link logged-in WP users to anonymous visitors via
Mbuzz::identify(). - Form-plugin conversions out of the box for the top four: Contact Form 7, Gravity Forms, WPForms, Fluent Forms.
- WP.org-submittable (GPL-2.0+, no external network calls beyond the configured API endpoint, no obfuscation, sandbox-safe).
- Page-render-neutral: all API I/O happens after
fastcgi_finish_requeston FPM hosts — the SDK already does this in 1.1.0; the plugin must not reintroduce sync calls.
- Replacing the JS pixel. The pixel still loads for client-side hits (scroll depth, time-on-page) where server-side hooks can't see. Plugin's job is server-side parity, not eliminating JS.
- A reporting dashboard inside wp-admin. Reporting lives at app.mbuzz.co. The plugin's admin screen is configuration + diagnostics only.
- A visual builder for custom events. v1 ships hard-coded hooks for the supported plugins; arbitrary event mapping is a v2 concern.
- Multisite network-wide config UI in v1. Per-site config only; network admins set defaults via
wp-config.phpconstants.
wp-content/plugins/mbuzz-attribution/
├── mbuzz-attribution.php # Plugin bootstrap (metadata, requires PHP/WP)
├── composer.json # Pulls mbuzz/mbuzz-php as dependency
├── vendor/ # Bundled at build time, shipped in zip
├── readme.txt # WP.org-format readme
├── uninstall.php # Cleans up wp_options
├── src/
│ ├── Plugin.php # Singleton entrypoint, registers hooks
│ ├── Bootstrap.php # Reads settings, calls Mbuzz::init()
│ ├── Settings/
│ │ ├── Page.php # Renders Settings → Mbuzz
│ │ ├── Fields.php # Sanitization + validation
│ │ └── Diagnostics.php # "Send test event" button + status
│ ├── Identity/
│ │ ├── LoginHook.php # wp_login → identify
│ │ └── RegisterHook.php # user_register → identify + signup conversion
│ ├── Integrations/
│ │ ├── WooCommerce.php
│ │ ├── EasyDigitalDownloads.php
│ │ ├── ContactForm7.php # Hooks `wpcf7_submit` (not `wpcf7_mail_sent` — see §7)
│ │ ├── GravityForms.php
│ │ ├── WPForms.php
│ │ ├── FluentForms.php
│ │ ├── MemberPress.php
│ │ └── LearnDash.php
│ ├── Cli/
│ │ └── Commands.php # WP-CLI: send-test, status, flush
│ ├── Privacy/
│ │ ├── Consent.php # WP Consent API integration
│ │ └── Exporter.php # wp_privacy_personal_data_exporters
│ └── Block/
│ ├── TrackedButton.php # Gutenberg block PHP-side registration
│ └── tracked-button/
│ ├── block.json # One dir per block — WP convention
│ ├── index.js # Editor script
│ └── view.js # Front-end pixel hook
└── tests/ # PHPUnit + WP_UnitTestCase via wp-env
- Plugin bundles the
mbuzz/mbuzz-phpSDK undervendor/via Composer in the build step. - Namespace-scoped via
php-scoperto avoid clashes when another plugin ships a different SDK version. Scoped prefix:Mbuzz\Vendor\(soMbuzz\Vendor\Mbuzz\Mbuzzis the actual class at runtime, with our own thin facadeMbuzz\WP\Sdkkeeping the call sites pretty). - Theme/snippet escape hatch. Theme code copy-pasted from the SDK docs calls
\Mbuzz\Mbuzz::event(...), which won't exist after scoping. Bootstrap ships two unscoped procedural helpers —mbuzz_event(string $type, array $props = [])andmbuzz_conversion(string $type, array $opts = [])— that delegate to the scoped class. Documented as the supported surface for theme integrators. (class_aliaswas considered and rejected: aliasing back to the unscoped name re-introduces the very collision risk scoping was meant to prevent.) - Cookie is the cross-plugin integration point.
_mbuzz_vidis read/written by whichever scoped SDK runs first; subsequent SDK copies (from other plugins) see the same value. Do not rename the cookie in a future scoper config — it's intentionally outside the scoped surface. - WP requires PHP 7.4+; SDK requires 8.1+. Plugin requires PHP 8.1+ — declared via the
Requires PHP: 8.1header inmbuzz-attribution.php(WP ≥ 5.1 honors this and refuses activation on older PHP). Belt-and-braces: the activation hook runsversion_compare(PHP_VERSION, '8.1', '<')and callsdeactivate_plugins()+ sets a transient that renders an admin notice on the next request, since the header check still allows old WP versions to activate.
| WP hook | Plugin action |
|---|---|
plugins_loaded (prio 5) |
Load autoloader, instantiate Plugin, call Mbuzz::init() with stored settings. |
init (prio 5) |
Register Gutenberg block, REST endpoints, CLI commands. |
template_redirect (prio 1) |
Call Mbuzz::initFromRequest(). This is the WP-equivalent of "early in the request lifecycle, before output." Skipped on is_admin(), wp_doing_ajax(), wp_doing_cron(), REST_REQUEST, XMLRPC_REQUEST, and is_robots(). |
admin_init |
Register settings page, sanitization callbacks. |
admin_enqueue_scripts |
Settings page CSS only — no public JS unless a tracked block is on the page. |
Why template_redirect and not wp: wp fires before WP has decided whether the request will 404, get cached by WP Rocket, or be served by wp_send_json_*. template_redirect is the last hook before output, fires only for front-end page loads, and matches the SDK's "real navigation" expectations.
- Activation: Set default option values; schedule no cron jobs (the SDK is event-driven). Display an admin notice if API key is missing: "Mbuzz is installed but inactive — add your API key in Settings → Mbuzz."
- Deactivation: Nothing. Settings persist.
- Uninstall:
uninstall.phpdeletes the singlembuzz_attribution_settingsoption (one serialized array — all fields from §4 live under this key) plus thembuzz_attribution_last_callandmbuzz_attribution_php_noticetransients.
Single screen at Settings → Mbuzz (options-general.php?page=mbuzz).
| Setting | Type | Default | Notes |
|---|---|---|---|
| API key | password | — | sk_live_* or sk_test_*. Validated by hitting GET /validate on save. |
| Enable tracking | checkbox | true | Master kill switch; sets enabled => false on the SDK. |
| Debug logging | checkbox | false | Pipes SDK error_log to WP_DEBUG_LOG. |
| Track logged-in admins | checkbox | false | When off, current_user_can('manage_options') bypasses tracking. Most sites want this off so internal QA doesn't pollute. |
| Skip paths (textarea, one per line) | text | empty | Merged with SDK defaults (/wp-admin/, /wp-login.php, /wp-cron.php always skipped regardless). |
| Identify users at | radio | "login" | "login" (only when they log in) or "every page" (on every request for logged-in users). |
| WooCommerce: track purchases | checkbox | true | Enables woocommerce_thankyou. |
| WooCommerce: include order details in properties | checkbox | true | Sends line items, coupons, payment method. |
| WooCommerce: mark first paid order as acquisition | checkbox | true | First completed order → is_acquisition: true. Subsequent → inherit_acquisition: true. |
Any setting can be locked via constants — useful for multisite and managed-host deploys:
define( 'MBUZZ_API_KEY', 'sk_live_...' );
define( 'MBUZZ_ENABLED', true );
define( 'MBUZZ_DEBUG', false );Constants take precedence over DB options and the corresponding setting field is rendered disabled with a "Locked by wp-config.php" hint.
Below the form: a card showing
- SDK version, plugin version, PHP version
- "Last successful API call: 2026-05-25 13:42:11 UTC" (stored as a transient, updated by a hook on successful POST)
- "Send test event" button → fires
Mbuzz::event('mbuzz_test', [...])and shows the response. - Consent API status: "Detected: WP Consent API v1.0.6" or "Not detected — tracking always on."
| WP hook | Action |
|---|---|
wp_login ($user_login, $user) |
Mbuzz::identify($user->ID, ['email' => $user->user_email, 'name' => $user->display_name, 'role' => $user->roles[0] ?? null]) |
user_register ($user_id) |
Load user, identify(...) + Mbuzz::conversion('signup', ['user_id' => $user_id, 'is_acquisition' => true]) |
profile_update |
Re-identify if email changed. |
wp_logout |
No-op. SDK has no reset() semantics for "log out" — we leave the visitor cookie so attribution survives across login sessions. Trade-off: on a shared machine, the next person to use the browser inherits the previous user's anonymous visitor id until they log in (at which point identify rebinds it). Right call for the typical solo-laptop case; explicit so a privacy reviewer doesn't have to ask. |
Setting "Identify users at: every page": adds a template_redirect callback that re-identifies the current user on every page load. Useful for sites where the WP login session can outlive the visitor cookie or where the visitor cookie can be rotated by privacy tools.
The single most-requested integration. Hooks:
| Hook | Mapped to |
|---|---|
woocommerce_thankyou ($order_id) |
conversion('purchase', [...]) — see below |
woocommerce_order_status_processing |
Fires for orders that reach paid state on a gateway that stops at processing (Stripe + digital goods is the common case). Deduped via order meta _mbuzz_conversion_id. |
woocommerce_order_status_completed |
Fallback for orders that skip the thank-you page (renewals, admin-marked) and never went through processing. Same dedupe. |
woocommerce_order_refunded |
conversion('refund', ['revenue' => -$refund_total, 'properties' => ['original_order_id' => $order_id]]) |
woocommerce_subscription_renewal_payment_complete (WooCommerce Subscriptions) |
conversion('payment', ['user_id' => $user_id, 'revenue' => $renewal_total, 'inherit_acquisition' => true]) |
woocommerce_checkout_order_processed |
No conversion fired here — that hook runs before payment confirms; would double-count. |
Mbuzz::conversion('purchase', [
// user_id resolves logged-in customers (numeric WP ID) or guest
// checkouts (billing email). Either way it's a stable per-customer
// identifier the backend uses to find-or-create an Identity row.
'user_id' => $order->get_user_id() > 0
? (string) $order->get_user_id()
: ($order->get_billing_email() ?: null),
'revenue' => (float) $order->get_total(),
'currency' => $order->get_currency(),
'is_acquisition' => $is_first_paid_order_for_user,
'inherit_acquisition' => ! $is_first_paid_order_for_user,
'properties' => [
'order_id' => $order->get_id(),
'order_number' => $order->get_order_number(),
'payment_method' => $order->get_payment_method(),
'coupons' => $order->get_coupon_codes(),
'item_count' => $order->get_item_count(),
'line_items' => array_map(fn($item) => [
'product_id' => $item->get_product_id(),
'sku' => $item->get_product()?->get_sku(),
'quantity' => $item->get_quantity(),
'subtotal' => (float) $item->get_subtotal(),
], $order->get_items()),
],
]);The order is marked with the returned conversion id to enable dedupe + audit. Storage call must be HPOS-safe: $order->update_meta_data('_mbuzz_conversion_id', $result['conversion_id']); $order->save(); rather than update_post_meta(), which is a no-op against the HPOS orders table.
Counting rule. The first of woocommerce_thankyou, woocommerce_order_status_processing, or woocommerce_order_status_completed to fire for a given order wins; the order meta dedupe keys the rest out. This handles three real cases: (a) standard checkout — thankyou fires first; (b) digital goods on Stripe that never leave processing — processing fires; (c) admin-marked or renewal orders that skip the customer-facing pages — completed fires.
When $order->get_user_id() === 0, we send the billing email as user_id. The backend (multibuzz ≥ 1.x) treats user_id as a stable identifier — find-or-create an Identity(external_id: $email) and resolve a visitor from it. When the same customer later browses with a cookie, Identities::IdentificationService merges the cookied visitor onto that identity and re-runs attribution for prior conversions. No identifier field needed (deprecated in PHP/Python/Node SDKs as of 2026-05-25 — backend never honored it on /conversions).
$is_first = wc_get_orders([
'customer_id' => $user_id,
'status' => ['completed', 'processing'],
'exclude' => [$order_id],
'return' => 'ids',
'limit' => 1,
]) === [];Cached per request to avoid repeat queries.
All optional — the integration class checks if the host plugin is active (class_exists, function_exists) before wiring hooks.
| Plugin | Hook | Conversion |
|---|---|---|
| Easy Digital Downloads | edd_complete_purchase |
purchase with revenue, payment_id, downloads |
| Contact Form 7 | wpcf7_submit (gated on $result['status'] ∈ {'mail_sent', 'demo_mode'}) |
lead with form ID + form title in properties. Why not wpcf7_mail_sent: forms configured for webhook/CRM-only delivery skip email and never fire that hook. |
| Gravity Forms | gform_after_submission |
lead with form ID; user_id set to the email field's value if one exists |
| WPForms | wpforms_process_complete |
lead |
| Fluent Forms | fluentform/submission_inserted |
lead |
| MemberPress | mepr-event-transaction-completed |
purchase with revenue + membership type |
| LearnDash | learndash_course_completed |
event('course_completed', ...) (not a conversion — counts as engagement) |
Each integration ships its own filter for users to override the conversion type or skip:
add_filter('mbuzz_woocommerce_conversion_type', fn() => 'sale'); // rename from "purchase"
add_filter('mbuzz_woocommerce_skip_order', fn($skip, $order) => $order->get_total() < 1, 10, 2);A single block (mbuzz/tracked-button) that renders a button and fires Mbuzz::event() (via the JS pixel — server can't see clicks). Block attributes:
label(string)url(string)event_type(string, defaultcta_click)properties(key-value editor)is_conversion(bool — switches toconversion()call)
The block exists primarily so non-developers can wire CTAs to the funnel without copy-pasting JS. Saved markup is a <a data-mbuzz-event="…" data-mbuzz-properties="…"> and the bundled pixel JS reads the attributes on click.
wp mbuzz status # API key set? Last successful call? SDK version?
wp mbuzz test # Fire a test event, print full response.
wp mbuzz flush # Force-flush the SDK deferred queue (useful in long-running wp-cli scripts).
wp mbuzz identify <user_id> [--traits='{"plan":"pro"}']
wp mbuzz conversion <type> [--user_id=…] [--revenue=…] [--properties='{}']
All commands respect --dry-run and --debug.
flush depends on a new SDK method. The SDK 1.1.0 deferred queue is private (Api::flushDeferred() is internal, registered on register_shutdown_function). wp mbuzz flush requires a public Client::flush() in SDK 1.2.0 that proxies to it; ship that first or drop the command from v1 scope. WP-CLI is the one place this matters — under FPM the shutdown handler runs anyway, but CLI processes don't go through shutdown until the whole script exits, so long-running migration/import scripts accumulate the queue unbounded.
- Plugin can be network-activated or per-site.
- Settings are per-site (in
wp_options, notwp_sitemeta). Network admin defaults live inwp-config.phpconstants. - On
switch_blog: callMbuzz::reset()and then immediatelyMbuzz::init()again with the freshly-resolved site's settings.reset()alone nulls the static client and the next SDK call throwsRuntimeExceptionfromMbuzz::ensureInitialized(). TheBootstrapclass exposes aboot(array $settings): voidmethod soswitch_bloghandlers can callBootstrap::boot(Settings::current())without re-implementing the init pipeline. Same flow runs onrestore_current_blog.
If WP Consent Level API is active:
- Plugin registers as a consent-aware plugin with category
statistics. - Gating happens at call-site, not at init. Consent plugins resolve their state asynchronously (often after
plugins_loaded, sometimes only afterwponce the page knows whether it's a cached page, a REST request, etc.). Init-timeenabled => falsewould either be wrong half the time or force us to defer init pastplugins_loaded. Instead:Mbuzz::init()runs normally; every tracking hook this plugin registers wraps itsMbuzz::event()/conversion()/identify()call inif (! Consent::allows()) return;, whereConsent::allows()returnstruewhen the Consent API is absent andwp_has_consent('statistics')when it's present. - No cookie is set without consent —
_mbuzz_vidwrites are gated through the sameConsent::allows()check beforeMbuzz::initFromRequest()runs.
When the Consent API is not installed: tracking runs unconditionally (matches the SDK's default and current behavior).
What the plugin actually stores per data subject:
- Order meta (HPOS-safe):
_mbuzz_conversion_idon each WooCommerce/EDD order, recording the conversion id returned by the backend. Indirect link to the user via$order->get_user_id()(or billing email for guest checkouts). - User meta:
_mbuzz_last_identified_at(timestamp),_mbuzz_last_conversion_id(most recent). Written by the identify hooks (§5) and the WC/EDD conversion hooks (§6, §7). Kept deliberately small — anything more belongs in the backend, not inwp_usermeta.
Registers a wp_privacy_personal_data_exporters callback that exports the _mbuzz_* user meta listed above plus the _mbuzz_conversion_id order meta for every order owned by (or with billing email matching) the data subject.
Registers a wp_privacy_personal_data_erasers callback that deletes the same _mbuzz_* user meta and order meta, then pings the backend with a forget request (backend endpoint TBD — out of scope for v1 if backend isn't ready; until then the eraser deletes local meta and reports the backend step as "retained" per WP eraser contract).
- Zero added latency on the rendered page. All API I/O happens in the shutdown phase. The plugin must NOT call
Mbuzz::event()/conversion()during render-blocking hooks likewp_headorthe_content. - No polling, no cron. The plugin schedules zero recurring jobs.
- Query budget: ≤ 1 additional query per page load, only on logged-in pages (identify trait lookup). WooCommerce conversions add 1–2 queries on the thank-you page only.
- Object cache friendly: all option reads go through
get_option(which is cached) and we cache the first-paid-order check per request.
A bundled tests/performance.php runs WP-Cli with --profile=hook against a fresh install + the plugin + WooCommerce, and fails the build if template_redirect-onward adds > 5ms median over 100 runs.
- All admin form submissions are nonce-protected (
mbuzz_settings_nonce). - API key stored in
wp_optionswithautoload=no, never echoed back to the page (password field with placeholder = "••••••" when set). - API key validation request uses the bearer header and never logs the key.
- All output escaped with
esc_html,esc_attr,esc_url,wp_kses_postas appropriate. - No
eval, nounserializeof user input, no remote URL fetches except the configured API endpoint. uninstall.phpchecksWP_UNINSTALL_PLUGINconstant before running.
- WP.org repository as the primary channel — free, GPL-2.0+.
- GitHub releases as a mirror for developers who want to pin commits.
- No premium tier in v1. If a premium tier ships later, it lives on a separate domain and uses the EDD Software Licensing pattern for updates (does not pollute the WP.org plugin).
Semver. The plugin version is independent of the SDK version it bundles; both are shown on the diagnostics card.
- WP.org plugin updater handles updates automatically.
- Each release bumps the
Stable taginreadme.txtand theVersioninmbuzz-attribution.php. Both are enforced by a build script.
- WordPress: current latest minus 2 minor (e.g. if WP 6.7 is current, we support 6.5+).
- WooCommerce: current latest minus 2 minor.
- PHP: 8.1+ (matches SDK).
| Layer | Tool | What |
|---|---|---|
| Unit | PHPUnit + Brain Monkey | Pure-PHP classes (Settings, Identity, Integrations) with WP functions mocked. |
| Integration | wp-env + wp-phpunit |
Full WP install, activates WC, fires the real hooks, asserts on the captured SDK transport. |
| E2E | Playwright | Single happy-path: install plugin, set key, place an order, see the conversion on a captured API receiver. |
| Manual QA matrix | — | WP 6.5 / 6.6 / 6.7 × PHP 8.1 / 8.2 / 8.3 × WC 8.x / 9.x. |
The SDK's setTransport() is the seam — integration tests configure it once in their bootstrap and assert against captured calls.
| Phase | Scope | Gate |
|---|---|---|
| 0.1.0 alpha | Core + WC + CF7. Internal dogfood on 1 store. | "It tracks a purchase end-to-end without breaking checkout." |
| 0.2.0 beta | + EDD, Gravity, WPForms, Fluent, MemberPress, LearnDash. WP-CLI. Privacy. | 5 external beta sites running for 2 weeks with no support tickets. |
| 1.0.0 | WP.org submission. Block. Documentation site. | WP.org approval; SDK 1.1.0 published; docs published. |
| 1.1+ | Backlog: WP REST endpoints for SPA themes, BuddyPress events, custom event mapper UI. | Customer-driven. |
- Should the plugin ship its own JS pixel (bundled, no CDN dependency) or load
pixel.mbuzz.co/p.js? Bundling is more WP.org-friendly; loading from CDN gives instant fixes. Recommend: bundle, with the CDN URL as a hidden override constant for fast hotfix. - WP Multisite shared API key: some networks will want one key for the whole network. Resolved by
wp-config.phpconstants for v1; revisit a network-admin UI in v1.1 if there's demand. - Pixel + server-side dedupe: if both the JS pixel and the server-side
purchasehook fire, backend needs to dedupe byproperties.order_id. Backend ticket required before GA.
Resolved upstream and folded into the spec:
- WooCommerce HPOS — §6 now specifies
$order->update_meta_data()+save(). - Order status counting (
processingvs.completed) — §6 "Counting rule" spells out the first-wins behavior.
- Per-product conversion tracking (separate from order-level purchase) — backlog.
- Cart abandonment tracking — needs a cron + email infra we don't want to own.
- Multi-currency normalization — backend handles it.
- A/B testing or feature-flag integration.
- Custom dashboards inside wp-admin (reporting stays at app.mbuzz.co).