Skip to main content

Status-Driven Purchase Conversions

Record Facebook (and other platforms') purchase conversions only when WooCommerce confirms the order is in a paid state, using the platform's Conversion API as the single source of truth.

When you need this recipe

WooCommerce orders fall into three broad outcomes after checkout:

  1. Order fails immediately. Customer never reaches the thank-you page. Nothing fires. No action needed.
  2. Order is paid immediately (most Stripe/PayPal-style integrations). Browser pixel fires on the thank-you page, CAPI fires on the paid-status transition, both share the same event_id, Meta deduplicates them. This is the normal case. No action needed.
  3. Order goes to processing but may still fail or never get paid (bank transfer, invoice, BNPL, manual review). The browser pixel fires on the thank-you page and records the conversion, even if the order is later cancelled or never paid.

This recipe addresses case 3.

The Pixel Manager's default behavior tracks everything that does not immediately fail because most checkouts fall into case 2 and waiting on a paid status hurts attribution and Meta's optimizer for the common case. Use this recipe only when case 3 is frequent enough in your store to distort the numbers.

What CAPI is and is not

The Conversions API was built for tracking reliability: it sends events from the server directly to Meta to survive ad blockers, browser privacy controls, and network filters that suppress the browser pixel. In the standard setup, CAPI runs alongside the browser pixel as a backup, and Meta deduplicates the two via event_id. This recipe repurposes CAPI as the only sender for purchase events to take advantage of its built-in status-driven trigger.

7-day limitation

Meta's Conversions API only accepts events with an event_time within the last 7 days. If an order sits in processing longer than 7 days before transitioning to a paid status, its conversion is permanently lost. This is a hard limit on Meta's side.

How the Pixel Manager handles purchase events by default

For Facebook, the Pixel Manager fires the purchase event from two places:

  1. Browser pixel - fires on the order received (thank-you) page, regardless of the order's payment status.
  2. Conversion API (CAPI) - registered on the order status transitions listed below. A meta-key guard on the order prevents double-fires across these hooks.

Both events share the same event_id (pmw_{order_id}), which is how Facebook deduplicates them.

Which transitions trigger the server-side purchase

The server-side purchase event is registered on:

  • woocommerce_payment_complete
  • every woocommerce_order_status_{paid_status} transition, where the paid statuses come from wc_get_is_paid_statuses(), by default processing and completed
  • woocommerce_order_status_on-hold
The server-side purchase is status-driven, but "status-driven" is not the same as "paid"

Two cases reach your ad platforms although no money has arrived:

  • On-hold orders. The transition into on-hold is a deliberate trigger. Gateways that park an order as awaiting payment (crypto invoices, bank transfers, some BNPL flows) would otherwise report their conversions days late, and Meta penalises delayed reporting. On a shop where most on-hold orders are never paid, this inflates the conversion count instead.
  • Zero-value orders. A free order (a $0 subscription trial, a free plan, a fully discounted cart) runs through payment_complete() and lands in a paid status, so it triggers a purchase like any other order. Nothing on this path inspects the order total.

Failed, cancelled and refunded orders never trigger a server-side purchase. Orders in pending do not either, since no hook fires on that status, but an order that passed briefly through on-hold before the gateway reset it will have triggered one on the way.

If either case applies to your shop, gate the server-side purchase with pmw_skip_s2s_purchase_event below. The browser pixel alone is not the only thing recording non-paid orders.

Recipe

To record conversions strictly when the order reaches a paid state, do two things:

1. Enable Facebook CAPI

In the Pixel Manager, open Tracking Pixels → Meta and enable Conversions API. This is what makes CAPI the source that delivers the purchase event to Meta.

Enabling Facebook in the Pixel Manager also disables the tracking pixel from the Meta for WooCommerce plugin if it is installed. Catalog sync from Meta for WooCommerce is not affected.

2. Suppress the Facebook browser pixel for purchase events

Add a JavaScript filter that returns null for purchase events on the Facebook pixel. Returning null from a pmw_pixel_data_{pixel} filter blocks that pixel from firing for the matched event.

/wp-content/themes/child-theme/functions.php
add_action('wp_head', function() {
?>
<script>
window._pmwq = window._pmwq || [];
window._pmwq.push(function() {
pmw.hooks.addFilter(
'pmw_pixel_data_facebook',
'my-store/status-driven-purchase',
function(pixelData, eventName) {
if (eventName === 'purchase') {
return null;
}
return pixelData;
}
);
});
</script>
<?php
}, 1);

With the browser purchase event suppressed, CAPI is the only sender, dedup is no longer relevant, and conversions appear in Meta when WooCommerce transitions the order into a paid state. For most payment gateways this is essentially immediate; for offline or manually approved payments, the conversion is recorded the moment the order moves into a paid status.

Stricter "paid" definition (server-side filter)

Use this whenever the trigger list above is wider than your definition of a conversion: to keep out on-hold orders, zero-value orders, or processing orders on a shop that only counts completed.

The filter to use is pmw_skip_s2s_purchase_event. It receives the order itself, and returning true suppresses the server-side purchase for every platform, including the SweetCode Cloud proxy path, and keeps the order out of the Google Ads Conversion Adjustments feed.

/wp-content/themes/child-theme/functions.php
add_filter('pmw_skip_s2s_purchase_event', function ($skip, $order) {

if (!$order instanceof WC_Order) {
return $skip;
}

// A free order is not a conversion.
if ((float) $order->get_total() <= 0) {
return true;
}

// Not paid yet, so not a conversion yet. This covers on-hold orders.
if (!$order->is_paid()) {
return true;
}

return $skip;
}, 10, 2);

A skipped order is not marked as already sent, so the purchase goes out normally the moment the order does reach a paid status. That is what makes the filter a gate rather than a permanent exclusion.

To narrow the definition further, for example to completed only, replace the is_paid() check:

if ($order->get_status() !== 'completed') {
return true;
}

To apply the rule to one platform only, read the filter's third argument, which carries the pixel class name:

/wp-content/themes/child-theme/functions.php
use SweetCode\Pixel_Manager\Pixels\Facebook\Facebook_CAPI;

add_filter('pmw_skip_s2s_purchase_event', function ($skip, $order, $context) {

if ($context !== Facebook_CAPI::class) {
return $skip;
}

return ($order instanceof WC_Order && !$order->is_paid()) ? true : $skip;
}, 10, 3);
Do not use pmw_server_event_payload_{pixel}_purchase for this

The pmw_server_event_payload_* filters documented under Event Filters run on browser events that are forwarded to the server. The endpoint that feeds that pipeline rejects purchase events, because a purchase is always built from the order on the server and never accepted from the browser. A callback on pmw_server_event_payload_facebook_purchase therefore never runs, and a status gate built on it fails silently.

The one exception is Google Analytics 4: the Measurement Protocol applies pmw_server_event_payload_google_analytics, pmw_server_event_payload_google_analytics_purchase and pmw_server_event_payload_post to its own purchase payload, so those three do run for GA4. For every other platform, use pmw_skip_s2s_purchase_event.

Earlier versions of this page recommended the payload filter for all platforms. That was wrong and we have corrected it.

Pixel Manager Pro 1.65.0 to 1.67.x

On those versions the server-side purchase path passed the Pixel Manager's internal order object to this filter instead of the WC_Order, so the instanceof WC_Order guard above silently skips the whole callback. Update to 1.68.0 or later, where the filter receives the WooCommerce order again. If you have to support an older version, resolve the argument first with $order = wc_get_order(is_object($order) ? $order->get_id() : $order);.

Custom order statuses (e.g. partially-paid)

Deposit and partial-payment plugins add custom order statuses that WooCommerce does not treat as paid. Because the purchase conversion fires on the transition into a paid status, an order that only ever reaches a custom order status such as partially-paid never triggers purchase conversion tracking.

To register a custom order status as paid, use WooCommerce's woocommerce_order_is_paid_statuses filter. It is the filter behind wc_get_is_paid_statuses(), so the Pixel Manager picks it up automatically and fires the purchase conversion when an order transitions into that status:

/wp-content/themes/child-theme/functions.php
add_filter('woocommerce_order_is_paid_statuses', function($statuses) {
$statuses[] = 'partially-paid'; // status slug without the `wc-` prefix
return $statuses;
});

There is no separate Pixel Manager filter for this; the WooCommerce filter is the single source of truth. Be aware that it widens what WooCommerce itself considers paid (for example $order->is_paid()), not just the Pixel Manager's conversion trigger.

Optional: manually re-fire CAPI for an order

If a custom workflow needs to push a CAPI purchase event for a specific order (for example from a custom hook), call the platform's static send_purchase_hit() method with the order object:

\SweetCode\Pixel_Manager\Pixels\Facebook\Facebook_CAPI::send_purchase_hit($order);

This is an internal method and is not part of the public Pixel Manager API. It can change between releases.

Same pattern for other platforms

The same approach works for every platform that has a server-side counterpart in the Pixel Manager. Only the JavaScript filter name changes per platform, because the server-side gate is one filter for all of them.

PlatformJavaScript filterServer-side gate
Facebook / Metapmw_pixel_data_facebookpmw_skip_s2s_purchase_event, $context is Facebook_CAPI::class
TikTokpmw_pixel_data_tiktokpmw_skip_s2s_purchase_event, $context is TikTok_EAPI::class
Pinterestpmw_pixel_data_pinterestpmw_skip_s2s_purchase_event, $context is Pinterest_APIC::class
Snapchatpmw_pixel_data_snapchatpmw_skip_s2s_purchase_event, $context is Snapchat_CAPI::class
Redditpmw_pixel_data_redditpmw_skip_s2s_purchase_event, $context is Reddit_CAPI::class
Google Analytics 4pmw_pixel_data_google_analyticspmw_skip_s2s_purchase_event, $context is Google_MP_GA4::class

Microsoft Advertising, Nextdoor, Mixpanel, OpenAI and the other server-side platforms are gated by the same filter; their pixel classes live under SweetCode\Pixel_Manager\Pixels\{Platform}. Omit the $context check to gate every platform at once, which is what the example above does.

Google Analytics 4 additionally exposes pmw_server_event_payload_google_analytics_purchase for modifying the Measurement Protocol payload. Use it to change what is sent, not to decide whether it is sent at all: the skip filter runs earlier and is the cheaper gate, since a skipped order never has a payload built for it.

Platforms without a server-side counterpart (Google Ads and others)

This recipe relies on the platform having a server-side purchase API in the Pixel Manager, because that is what supplies the status-driven trigger. Google Ads does not, and neither do LinkedIn, X, Taboola, Outbrain, AdRoll, Criteo, and the other browser-only pixels. For those platforms the purchase conversion is sent from the browser, so suppressing the browser event with no server-side sender behind it discards the conversion entirely.

Microsoft Advertising used to belong on this list. Since Pixel Manager Pro 1.66.0 it has a Conversions API, so it follows the recipe above instead.

Use the pmw_conversion_prevention PHP filter instead. It withholds the browser purchase conversion for an individual order while the order is not yet in a paid status, and because the filter is re-evaluated by Automatic Conversion Recovery (pro), the conversion is recovered in full on the customer's next visit to the shop once the order has been paid.

Two differences from the CAPI-based recipe above are worth knowing before you choose it:

  • It suppresses every browser purchase pixel for the affected orders, not one platform, because the order data is withheld from the data layer as a whole.
  • Recovery requires the customer to return to the shop with the same browser. CAPI needs no return visit.

If a platform in your setup does have a Conversion API, prefer this recipe for that platform and use the filter only for the browser-only pixels.

Troubleshooting

  • Conversions still appear for failed orders. The browser pixel filter is not active. Confirm the snippet is rendered in the page source and that pmw.hooks is defined when it runs (this is what the _pmwq queue guarantees).
  • No conversions appear at all. Confirm Facebook CAPI is enabled in the Pixel Manager under Tracking Pixels → Meta and that the access token is valid. Check the logs in the Pixel Manager under Support → Logger for the CAPI request.
  • Conversions appear later than expected. That is the expected behavior. CAPI fires when WooCommerce transitions the order into a paid status, not when the customer lands on the thank-you page.
  • Conversions still appear for on-hold, unpaid or free orders after the browser pixel was suppressed. These come from the server-side event, which also triggers on the transition into on-hold and does not check the order total. Add the pmw_skip_s2s_purchase_event gate. If you already added it and nothing changed, check two things: that your callback is registered on pmw_skip_s2s_purchase_event and not on pmw_server_event_payload_{pixel}_purchase, which never runs for a purchase, and that you are on 1.68.0 or later, since 1.65.0 to 1.67.x passed the wrong object to the callback.
  • More purchase events than paid orders in the platform's event manager. Compare the order IDs behind them. The event ID is pmw_{order_id}, so a spot check against the order's status history usually shows an on-hold transition, a free order, or an order that briefly passed through on-hold before the gateway reset it to pending.

Make more money from your ads with high-precision tracking