- The WooCommerce thank-you page is a browser event. Anything that stops the visitor reaching it (a redirect-based payment method, a closed bank app, a dropped connection) also stops the browser purchase tag. The order still exists.
- Server-side tracking should hang off order hooks, not the thank-you page:
woocommerce_payment_completefor the paid moment, thewoocommerce_order_status_processingand_completedhooks as a backstop,woocommerce_order_refundedfor refunds and the WooCommerce Subscriptions renewal hook for recurring payments. - Use the order ID as the shared ID: GA4
transaction_id, Metaevent_id, and the same value in the browser event so platforms can deduplicate. - Save the identifiers (fbp, fbc, GA client ID, click IDs) on the order at checkout. A refund, a webhook-confirmed payment or a subscription renewal has no browser to read them from.
- Guard every send with order meta so a status flip from processing to completed cannot send a second purchase, and test with a redirect payment method, a partial refund and a renewal before you trust the numbers.
A customer pays for an order in their bank app, closes the tab, and goes back to scrolling. WooCommerce gets the payment confirmation from the gateway, marks the order as paid and sends the receipt. Your Meta Pixel and GA4 tag, which were waiting on the thank-you page, never see any of it. The order is real; the conversion is missing.
This is the core problem of e-commerce tracking in the browser, and it is why a shop is the clearest case for moving events to the server. This guide walks through what happens in a WooCommerce order, where browser tracking breaks, which hooks to use instead, what to send for purchases, refunds and subscription renewals, and how to test it. It is the hub for our WooCommerce and forms series; the spokes are linked at the end.
What actually happens in a WooCommerce order
Strip away the theme and a WooCommerce order has two tracks running side by side. The visitor track happens in a browser: cart, checkout, payment, return to the shop, thank-you page. The order track happens in PHP on your server: an order is created, the gateway confirms payment, the status changes, emails go out, stock is reduced.
The tracking tags you install in the browser sit on the first track. They fire when a page loads, which makes the thank-you page (“order received”) the natural place for the purchase event. The problem is that the second track does not wait for the first. WooCommerce’s payment_complete() method fires the woocommerce_payment_complete action and sets the order to processing or completed, as seen in the WooCommerce order class. Nothing in that code checks whether the visitor is still looking at your site.
Where browser tracking breaks in a shop
Most of these failures are boring and individually small. Together they explain why a shop’s order count and its ad-platform purchase count disagree. For the broader picture, see why your WordPress analytics data is missing and client-side vs server-side tracking in WordPress.
- Redirect-based payment methods. Some methods send the visitor away to the provider’s page or to a banking or wallet app and bring them back afterwards. Depending on how a given gateway is integrated, this can include wallets and bank-transfer methods such as Klarna, Vipps or MobilePay. The payment can complete while the visitor never lands on the return URL, for example because they closed the tab or the app did not hand control back to the browser.
- Asynchronous confirmation. Some gateways confirm payment later through a webhook. The order flips to processing in the background, long after the browser has left.
-
The thank-you page not loading. A slow connection, a closed tab or a failed redirect is enough. WooCommerce’s own template fires the
woocommerce_thankyouhooks even for a failed order, after showing a failure notice (thankyou.php), so a purchase tag keyed only to that page can also fire for an order that did not get paid unless it checks the status. - Ad blockers and browser restrictions. Tag scripts can be blocked outright, and cookie lifetimes are shortened in some browsers. See Safari ITP and WordPress cookies.
- Caching. A cached or edge-served order-received page can carry a stale or missing order ID into the tag. Never cache checkout or order-received pages, and test the real page rather than the template.
- Orders that do not come from checkout. Admin-created orders, REST API orders and renewal orders have no browser at all. WooCommerce notes that orders can be created manually in the admin or through the REST API (Managing orders).
There is also a quieter failure that has nothing to do with browsers: the order data itself moved. WooCommerce stores orders in custom tables with High-Performance Order Storage (HPOS), enabled by default for new installations from WooCommerce 8.2 (WooCommerce HPOS docs). Code that reads order data with get_post_meta() instead of the order object is exactly the sort of thing that breaks there.
The fix: hang tracking off order hooks
A WordPress action hook is a named point in the code where plugins can run their own functions. WooCommerce fires hooks as an order changes state, whether or not a browser is involved. Server-side tracking listens to those instead of the thank-you page.
The hooks, confirmed against WooCommerce code and docs
| Hook | What it tells you | Source |
|---|---|---|
woocommerce_payment_complete |
Fires after payment_complete() sets the order to processing or completed. Arguments: order ID and transaction ID. |
WC_Order |
woocommerce_order_status_{status}, for example _processing and _completed
|
Fires when the order moves to that status. Receives the order ID and the order object. | WC_Order |
woocommerce_order_refunded |
Fires after a refund is saved. Arguments: order ID and refund ID. woocommerce_order_partially_refunded or _fully_refunded also fire. |
wc_create_refund() |
woocommerce_subscription_renewal_payment_complete |
Fires when a renewal payment is made. Receives the subscription and the renewal order. Not for initial or switch orders. | Subscriptions action reference |
woocommerce_subscription_renewal_payment_failed |
Fires for failed renewals only. | Subscriptions action reference |
Why use more than one purchase hook
The tidy story is that every paid order passes through woocommerce_payment_complete. The honest story is that gateways differ. Some call payment_complete(), others set a status directly, and orders created in the admin may only change status. Listening to the processing and completed status hooks as well catches those. The price is that one order can now trigger two or three hooks (payment complete, then processing, then completed), so you need a guard that makes the send happen once. The next section shows one.
Decide what “purchase” means for your shop and write it down. Orders waiting for a bank transfer sit on hold until payment arrives; most shops will not want a purchase event until the money is confirmed. If you sell on invoice, “processing” may be your paid moment, and that is a business decision, not a tracking one.
Wiring it up: a minimal pattern
First, declare HPOS compatibility so WooCommerce does not flag your code, and use order objects rather than post meta. This is the pattern from the WooCommerce HPOS recipe book:
add_action( 'before_woocommerce_init', function () {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables', __FILE__, true
);
}
} );
Second, capture identifiers while the visitor is still in the browser. This part has to run at checkout, because later hooks may fire without any cookies. We cover which values matter in Event Match Quality; here is the WooCommerce side:
function tr_save_ids( $order ) {
foreach ( array( '_fbp', '_fbc', '_ga' ) as $name ) {
if ( ! empty( $_COOKIE[ $name ] ) ) {
$order->update_meta_data(
'_tr' . $name,
sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) )
);
}
}
}
// Classic checkout (runs before the order is saved).
add_action( 'woocommerce_checkout_create_order', 'tr_save_ids' );
// Block checkout (Store API); save explicitly.
add_action( 'woocommerce_store_api_checkout_order_processed', function ( $order ) {
tr_save_ids( $order );
$order->save();
} );
The classic checkout and the block checkout use different hooks, so the snippet registers both; the Store API one is documented in the WooCommerce Blocks code, and you should confirm which checkout your shop uses. Third, send once. The guard writes the flag before queuing the send, so a second hook in the same request sees it:
function tr_send_purchase( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order || $order->get_meta( '_tr_purchase_sent' ) ) {
return;
}
$order->update_meta_data( '_tr_purchase_sent', time() );
$order->save();
// Build the payload from $order and queue it. Do not make
// slow HTTP calls inside the checkout request.
}
add_action( 'woocommerce_payment_complete', 'tr_send_purchase' );
add_action( 'woocommerce_order_status_processing', 'tr_send_purchase' );
add_action( 'woocommerce_order_status_completed', 'tr_send_purchase' );
Two practical notes. Queue the HTTP request (Action Scheduler ships with WooCommerce, or use a plugin that does it) so a slow ad-platform API cannot slow checkout. And keep a log of each request and response; when a number is off, the response is the evidence.
What to send for each event
Purchase
For GA4, the ecommerce documentation lists transaction_id, value and currency as required for purchase; tax, shipping, coupon and items (up to 200) are recommended. Via the Measurement Protocol on a web stream you also need measurement_id, api_secret and a client_id, and Google recommends sending session_id so activity can be joined to the session (Measurement Protocol reference). See our Measurement Protocol limits cheat sheet for the caps.
For Meta, Purchase requires currency and value (Meta Pixel reference). On the server side, add event_name, event_time, action_source set to website, event_source_url and customer data, as described in our Meta Conversions API guide. Google Ads, LinkedIn, TikTok and the rest have their own required fields; the dedup rules and limits table and TikTok, Snapchat and Pinterest on WooCommerce cover them.
Value, tax, shipping and discounts: pick one rule
Google defines value as the sum of price times quantity for the items, with tax and shipping as separate fields. WooCommerce gives you several totals: line subtotals, line totals after discounts, order total, order total excluding tax. None is wrong; mixing them is. Pick one definition (many shops use the item total after discounts, excluding tax and shipping, so that value matches what the product earned), apply it to every platform, and write it in your tracking documentation. Whatever you choose, make the Meta value match the GA4 value for the same order, or comparing platforms later will be a guessing game. Currency is the order’s currency, not the store’s default.
The shared ID and deduplication
Use the order ID, as a string, for GA4 transaction_id and for the Meta event_id, and put the same value on the browser event. Meta deduplicates when the browser eventID and the server event_id match and the event names match, within 48 hours (Meta documentation; the mechanics are in our deduplication guide). Google states that GA4 deduplicates purchase events with the same transaction ID, but only for web streams, and warns that sending an empty transaction_id makes all such events collapse into one (Google Analytics help). The same guard keeps your server side from sending the order twice.
Refunds
GA4 has a refund event. transaction_id must match the original purchase and currency is required; value, tax, shipping and an items array with item_id and quantity are recommended, and Google says item information is what gives you item-level refund metrics and lets you express partial refunds (GA4 ecommerce docs). In WooCommerce the refund is its own object; read the refund ID from woocommerce_order_refunded:
add_action( 'woocommerce_order_refunded', function ( $order_id, $refund_id ) {
$refund = wc_get_order( $refund_id );
$items = array();
foreach ( $refund->get_items() as $item ) {
$items[] = array(
'item_id' => (string) $item->get_product_id(),
'quantity' => abs( (int) $item->get_quantity() ),
);
}
$value = abs( (float) $refund->get_amount() );
// Queue a GA4 "refund" with transaction_id = $order_id.
}, 10, 2 );
Refund line quantities are stored as negatives in WooCommerce, hence abs(); check this on your own version. A refund can also be money only, with no items, such as a goodwill refund: send value and no items.
Meta is different. Its list of standard events has no refund or cancel event, and its documentation gives no guidance on sending negative purchase values (Meta Pixel reference). Do not invent one. Refunds stay in GA4 and your shop; ad platforms keep the original purchase, which is another reason their numbers will not match your net revenue. Our post on why ad platforms never match WooCommerce orders covers the rest of that gap.
Subscriptions and renewals
A WooCommerce Subscriptions signup is a normal checkout and follows the flow above. A renewal is different: it is created and charged by the scheduler, with no visitor and no browser. The woocommerce_subscription_renewal_payment_complete hook gives you the subscription and the renewal order. Three decisions follow:
-
Is a renewal a purchase? For revenue reporting in GA4, yes: send
purchasewith the renewal order’s ID astransaction_id, optionally with a custom parameter such asrenewalset to true so you can filter. For ad platforms, you may prefer not to send renewals as new purchases, since the click that “caused” them was months ago. It is a business choice; make it deliberately. - Which identifiers? The renewal has none of its own. Reuse the values you saved on the parent order at checkout, and accept that they will be older and may no longer match a live browser.
- What about a failed renewal? The failed hook exists, but most stores treat a failed payment as a CRM event, not an ad conversion.
Consent
Moving a tag to the server does not change what the visitor agreed to. Decide at checkout what the visitor consented to, store it on the order next to the identifiers, and have the later hooks respect it. A refund fired by an admin three weeks later has no banner to ask. Our posts on Consent Mode v2 on WordPress and whether server-side tracking bypasses consent cover the mechanics. Take legal advice for your own situation.
How the common approaches compare
You do not have to write the code above. These are the usual options for a WooCommerce shop, described from their own WordPress.org listings and the docs fetched in October 2026. Features and plans change, so check the current page before you buy.
| Approach | What its own page states | Worth checking |
|---|---|---|
| Meta for WooCommerce and the official Meta Pixel plugin | The Meta Pixel plugin lists Conversions API support and WooCommerce compatibility. The WooCommerce listing focuses on the Pixel and catalog sync. | Which events are sent server-side on your version, and whether refunds or renewals are covered. Meta has no refund event anyway. |
| Google for WooCommerce | Automatic Google tagging and conversion tracking, plus enhanced conversions. | It manages Google Ads conversions, not Meta. If you also run GTM, avoid two sources for the same purchase; one user review on the listing describes duplicate events in that situation. |
| PixelYourSite | Free: Meta Conversions API support and GA4 ecommerce events for WooCommerce. Pro adds more events, parameters and multiple tags. States HPOS compatibility. | Which destinations get server-side events on your plan. |
| Conversios | GA4 ecommerce events including purchases and refunds. Server-side tracking and Meta CAPI are in the Pro plan. Describes itself as fully HPOS compatible. | Pro pricing and exactly which events are server-side. |
| GTM4WP with a server container | GA4 ecommerce for WooCommerce, HPOS compatible, supports server-side containers, de-duplicates purchase events. States it does not support refunds. | You host and maintain the container; see what sGTM is and what it costs. |
| A forwarding plugin such as Traculo | Events go from Traculo’s servers to GA4 (Measurement Protocol), Meta CAPI, Google Ads offline conversions, LinkedIn, TikTok, Snapchat, Pinterest and Plausible, with a delivery log per event. | It is not a GTM server container host and does not replace GTM for every use. Its plugin emits product_view, add_to_cart (product and quantity, not the variation), checkout_start (classic checkout and Block Checkout, once per checkout) and order_complete, which is sent once per order (an order meta flag prevents repeats) from the thank-you page hook rather than from payment-status or refund hooks. WooCommerce tracking is off by default. Confirm which WooCommerce events your plugin version emits; the behaviour described here applies from plugin version 1.0.44. |
None of these is universally right. A shop that only runs GA4 and Google Ads may be well served by Google’s own plugin; an agency with a full GTM practice will reasonably keep sGTM. Our server-side tracking without GTM guide covers when a plugin route is enough.
Testing plan
- Use a staging site with HPOS in the same mode as production, and the same payment gateways in test mode.
- Turn on the platform test tools. GA4 DebugView and Meta’s Test Events (use the test event code, and remove it before launch).
- Run the paths, not just one order. Card payment; a redirect method where you close the tab before returning; a bank-transfer order moved to processing by hand; a partial and a full refund; a renewal.
- Count. After a day of live traffic, compare orders in WooCommerce with purchases in GA4 and Meta for the same period and currency. Expect differences from attribution; a count near double points to duplicates.
- Read the log. Every request should have a response you can read. A silent failure is the common one.
Frequently asked questions
Do I still need the browser Pixel and GA4 tag?
Usually yes. The browser event carries real-time context and lets the platforms deduplicate against the server event, which is why both use the order ID. The server event is what covers the orders the browser missed.
Which hook should I use for “purchase”?
woocommerce_payment_complete is the cleanest signal, but not every gateway calls it, so also listen to the processing and completed status hooks and guard with order meta so each order sends once.
Does this work with HPOS?
Yes, if you declare compatibility with the custom_order_tables feature and read and write order data through order objects rather than post meta. WooCommerce documents both steps in its HPOS recipe book.
How do I handle partial refunds?
Send a GA4 refund with the original transaction_id, the refunded value, and an items array containing only the refunded item_id and quantity. Meta has no equivalent event.
Will server-side tracking make my numbers match WooCommerce exactly?
No. It removes the browser-loss gap; it does not remove attribution windows, refunds that ad platforms never see, or consent choices. Use WooCommerce as the source of truth for orders and the platforms for attribution.
Related reading
- Server-side tracking without GTM: the WordPress plugin approach
- Meta CAPI deduplication, event_id explained
- Meta Conversions API for WordPress: the complete guide
- Event Match Quality: what the score means and how to raise it
- Why Meta, Google and GA4 never match your WooCommerce orders
- TikTok, Snapchat and Pinterest Conversions API on WooCommerce
- Track form submissions in WordPress
- Client reporting for WordPress agencies
- Server-Side Tracking for WordPress: a beginner’s guide
- GA4 Server-Side Tracking for WordPress
If you would rather not maintain hooks and payloads yourself, Traculo’s WordPress plugin forwards events to GA4, Meta and other platforms and keeps a delivery log with each integration’s response. See server-side event tracking to check whether it fits your shop.