Opens in a new tab
Home/Blog/WooCommerce server-side tracking: purchases, refunds and subscriptions

WooCommerce server-side tracking: purchases, refunds and subscriptions

Product Updates & Use CasesPhillip StemannOctober 28, 2026

Last updated: October 30, 2026

Title card: WooCommerce server-side tracking: purchases, refunds, subscriptions
In this article
Key takeaways
  • 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_complete for the paid moment, the woocommerce_order_status_processing and _completed hooks as a backstop, woocommerce_order_refunded for refunds and the WooCommerce Subscriptions renewal hook for recurring payments.
  • Use the order ID as the shared ID: GA4 transaction_id, Meta event_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.

Two lanes: a browser lane with checkout, off-site payment, return to shop and thank-you page marked as failure points, and a server lane with order created, payment_complete, status changes and order_refunded
The browser lane can lose the purchase at three points; the server lane runs regardless.

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_thankyou hooks 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.

Table of six WooCommerce hooks, when each fires and which tracking event to send from it
Hook-to-event mapping. The thank-you hook stays browser-only.

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

Three code panels for order 1042: a GA4 Measurement Protocol purchase, a Meta Conversions API Purchase and a GA4 refund, with the shared transaction and event IDs highlighted
One order ID threads through all three payloads. Values are illustrative.

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 purchase with the renewal order’s ID as transaction_id, optionally with a custom parameter such as renewal set 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

  1. Use a staging site with HPOS in the same mode as production, and the same payment gateways in test mode.
  2. Turn on the platform test tools. GA4 DebugView and Meta’s Test Events (use the test event code, and remove it before launch).
  3. 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.
  4. 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.
  5. Read the log. Every request should have a response you can read. A silent failure is the common one.
Eight-step QA checklist: card payment, redirect method, status flip, partial refund, full refund, renewal, consent withheld and comparison with real orders
Eight checks to run before you trust the numbers.

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

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.

ShareLinkedInXEmail

About the author

Track what matters, without the guesswork

Traculo gives WordPress sites analytics, funnels and server-side event forwarding in one dashboard. Start on the free plan with 10,000 events a month.

Try Traculo free

Related articles

Title card: Why Meta, Google and GA4 never match your WooCommerce orders
Product Updates & Use CasesOctober 30, 2026

Why Meta, Google and GA4 never match your WooCommerce orders

Why Meta, Google Ads and GA4 don't match WooCommerce orders: a one-day reconciliation, a worksheet and a decision tree for more-or-less gaps.

Read more
Title card reading LinkedIn Conversions API for WordPress on a dark slate background with an orange glow

LinkedIn Conversions API for WordPress

How to send WordPress lead conversions to LinkedIn with the Conversions API: li_fat_id, hashed email, payloads, limits, timing and API version upkeep.

Read more
Title card reading GCLID, GBRAID and WBRAID: what they are and when each appears, on a dark slate background with an orange glow

GCLID, GBRAID and WBRAID: what they are and when each appears

What Google's click IDs are, when gclid, gbraid and wbraid appear, the upload rules, how WordPress loses them and how to capture them reliably.

Read more