Implementing Accurate GA4 Ecommerce Tracking in WooCommerce with Google Tag Manager and Google Ads

Basic analytics can tell an online retailer how many people visited a website. A proper ecommerce tracking implementation must answer much more valuable questions:

  • Which products were viewed?
  • Which products were added to the cart?
  • How many users started checkout?
  • Which payment and delivery methods were selected?
  • Which orders were completed?
  • What was the real value of each purchase?
  • Which advertising campaigns generated profitable sales?

This article explains how we implemented a custom WooCommerce tracking architecture using:

  • Google Tag Manager;
  • Google Analytics 4;
  • Google Ads;
  • Google Consent Mode v2;
  • a consent management platform;
  • a custom WordPress must-use plugin;
  • a dedicated JavaScript data-layer module.

All company names, domains, account IDs, order numbers and product identifiers in this article have been anonymized.


Why a simple Google Analytics script is not enough

Installing the standard GA4 script gives access to page views, sessions and some automatically measured interactions. However, it does not automatically understand the complete commercial structure of a WooCommerce store.

For effective advertising optimization, Google must receive structured ecommerce events such as:

view_item_list
select_item
view_item
add_to_cart
remove_from_cart
view_cart
begin_checkout
add_shipping_info
add_payment_info
purchase

Each event must contain structured parameters such as:

item_id
item_name
item_brand
item_category
price
quantity
currency
value
transaction_id
tax
shipping

Without these fields, Google Ads may know that a visitor reached the order confirmation page, but it may not know:

  • which products were sold;
  • how much revenue was generated;
  • whether the same order was reported twice;
  • whether the sale was worth more or less than another conversion.

That makes meaningful ROAS optimization difficult.


The final tracking architecture

The completed architecture followed this flow:

WooCommerce
    ↓
Custom WordPress MU-plugin
    ↓
Structured ecommerce dataLayer
    ↓
Google Tag Manager
    ├── Consent Management Platform
    ├── Google Analytics 4
    └── Google Ads destinations
            ↓
GA4 purchase conversion imported into Google Ads

The custom integration was intentionally placed in a WordPress must-use plugin rather than the active theme.

This provided several benefits:

  • it remained active when the theme changed;
  • it could not be accidentally disabled from the WordPress plugins screen;
  • it was versioned in Git;
  • it could read configuration from environment variables;
  • it worked correctly in containerized and reverse-proxy environments;
  • tracking logic remained separate from presentation logic.

The PHP implementation was structured as a dedicated singleton class that initialized GTM, generated ecommerce data and exposed configuration to JavaScript.


Part 1: Building the WordPress MU-plugin

The plugin was stored using a structure similar to:

wp-content/
└── mu-plugins/
    ├── ecommerce-google-integrations.php
    └── ecommerce-google-integrations/
        └── assets/
            └── ecommerce-data-layer.js

Reading configuration securely

The GTM container ID should not be hardcoded throughout the source code.

Instead, the plugin reads configuration from WordPress constants or environment variables:

define('ECOMMERCE_GTM_ID', 'GTM-XXXXXXX');
define('ECOMMERCE_TRACKING_ENABLED', true);
define(
    'ECOMMERCE_ALLOWED_HOSTS',
    'shop.example.com,www.shop.example.com'
);
define('ECOMMERCE_TRACKING_DEBUG', false);

A Kubernetes or Docker deployment can provide equivalent environment variables.

The GTM ID must also be validated:

private function get_gtm_id(): string {
    $value = getenv('ECOMMERCE_GTM_ID') ?: '';

    $value = strtoupper(trim($value));

    return preg_match('/^GTM-[A-Z0-9]+$/', $value)
        ? $value
        : '';
}

This prevents an unexpected or invalid value from being inserted into the page.


Restricting tracking by hostname

Development, staging and production traffic should not be mixed unintentionally.

A robust implementation verifies the request hostname before loading GTM:

private function is_allowed_host(): bool {
    $host = $this->get_request_host();

    return $host !== ''
        && in_array($host, $this->get_allowed_hosts(), true);
}

Because the website may run behind an ingress controller or reverse proxy, the implementation should check HTTP_X_FORWARDED_HOST before HTTP_HOST:

private function get_request_host(): string {
    $host = '';

    if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
        $forwarded = explode(
            ',',
            (string) wp_unslash($_SERVER['HTTP_X_FORWARDED_HOST'])
        );

        $host = trim($forwarded[0]);
    } elseif (!empty($_SERVER['HTTP_HOST'])) {
        $host = (string) wp_unslash($_SERVER['HTTP_HOST']);
    }

    $host = strtolower(
        preg_replace('/:\d+$/', '', $host)
    );

    return sanitize_text_field($host);
}

This is particularly important in Kubernetes deployments where the application may see an internal hostname while the visitor accesses a public reverse-proxy domain.


Loading Google Tag Manager correctly

A web GTM container requires two parts:

  1. the JavaScript bootstrap in the document head;
  2. the noscript iframe immediately after the opening body tag.

The head integration can be added with:

add_action(
    'wp_head',
    array($this, 'render_head_bootstrap'),
    0
);

The body fallback can be added with:

add_action(
    'wp_body_open',
    array($this, 'render_gtm_noscript'),
    0
);

The bootstrap also places a page-context event in the data layer before GTM starts:

$bootstrap = array(
    'event'       => 'site_page_context',
    'page_type'   => $page_context['page_type'],
    'page_title'  => $page_context['page_title'],
    'content_id'  => $page_context['content_id'],
    'logged_in'   => $page_context['logged_in'],
    'user_role'   => $page_context['user_role'],
    'environment' => $page_context['environment'],
);

That produces a data-layer object similar to:

{
    event: "site_page_context",
    page_type: "product",
    page_title: "Example product",
    content_id: 1234,
    logged_in: false,
    user_role: "guest",
    environment: "production"
}

This contextual information can later be used for reporting, debugging or GTM trigger conditions.


Part 2: Converting WooCommerce products into GA4 items

GA4 requires a predictable product structure.

A reusable PHP method can convert a WC_Product object into the GA4 item format:

private function format_product_item(
    WC_Product $product,
    int $quantity = 1,
    string $list_name = ''
): array {
    $sku = $product->get_sku();

    $item = array(
        'item_id'   => $sku !== ''
            ? $sku
            : (string) $product->get_id(),

        'item_name' => wp_strip_all_tags(
            $product->get_name()
        ),

        'price'     => $this->to_number(
            $product->get_price()
        ),

        'quantity'  => max(1, $quantity),
    );

    return $item;
}

Product ID strategy

The preferred item_id is normally the SKU:

'item_id' => $product->get_sku()

If no SKU exists, the WooCommerce product ID can be used as a fallback.

The same identifier should be used consistently in:

  • GA4;
  • Google Ads;
  • Merchant Center;
  • product feeds;
  • internal reporting.

Inconsistent identifiers make product-level attribution difficult.


Adding brand information

WooCommerce stores represent brands in different ways. A brand may be stored in:

pa_brand
pa_marca
product_brand
pwb-brand

A flexible implementation can check several known taxonomies:

$taxonomies = array(
    'pa_brand',
    'pa_marca',
    'product_brand',
    'pwb-brand'
);

foreach ($taxonomies as $taxonomy) {
    if (!taxonomy_exists($taxonomy)) {
        continue;
    }

    $terms = wc_get_product_terms(
        $product_id,
        $taxonomy,
        array('fields' => 'names')
    );

    if (!is_wp_error($terms) && !empty($terms)) {
        $item['item_brand'] = sanitize_text_field(
            (string) reset($terms)
        );

        break;
    }
}

Adding product categories

GA4 supports several category levels:

item_category
item_category2
item_category3
item_category4
item_category5

A simplified implementation can collect the product categories and map the first five values:

foreach (
    array_slice($categories, 0, 5)
    as $index => $category
) {
    $key = $index === 0
        ? 'item_category'
        : 'item_category' . ($index + 1);

    $item[$key] = $category;
}

For more advanced stores, it may be better to build the actual parent-to-child taxonomy path instead of sorting terms only by parent ID.


Handling product variations

For variable products, the selected variation should be represented through:

item_id
item_variant
price

The JavaScript layer listens for WooCommerce’s found_variation event:

$('form.variations_form').on(
    'found_variation',
    function (event, variation) {
        selectedVariation = clone(config.currentItem || {});

        selectedVariation.item_id =
            variation.sku ||
            String(variation.variation_id || '');

        selectedVariation.price =
            numberValue(
                variation.display_price,
                selectedVariation.price || 0
            );

        selectedVariation.item_variant =
            Object.keys(variation.attributes || {})
                .map(function (key) {
                    return variation.attributes[key];
                })
                .filter(Boolean)
                .join(' | ');
    }
);

This ensures the event reports the product variation actually chosen by the customer rather than only the parent product.


Part 3: Generating server-assisted ecommerce events

Some ecommerce events can be prepared on the server because WordPress already knows which page is being rendered.

Examples include:

view_item
view_item_list
view_cart
begin_checkout
purchase

The plugin builds these events in PHP and passes them to JavaScript:

$config = array(
    'enabled'      => true,
    'currency'     => $this->get_currency(),
    'currentItem'  => $this->get_current_product_item(),
    'cartContext'  => $this->get_cart_context(),
    'serverEvents' => $this->get_server_events(),
);

The configuration is made available before the JavaScript file:

wp_add_inline_script(
    'ecommerce-data-layer',
    'window.ecommerceTrackingData = '
        . wp_json_encode($config)
        . ';',
    'before'
);

Sending a product-view event

A view_item event can be prepared as:

$events[] = array(
    'event' => 'view_item',
    'ecommerce' => array(
        'currency' => $this->get_currency(),
        'value'    => $item['price'],
        'items'    => array($item),
    ),
);

The corresponding data layer becomes:

{
    event: "view_item",
    ecommerce: {
        currency: "EUR",
        value: 249.90,
        items: [
            {
                item_id: "SKU-EXAMPLE",
                item_name: "Example product",
                price: 249.90,
                quantity: 1
            }
        ]
    }
}

Part 4: JavaScript interaction tracking

Not every event can be generated reliably on the server.

Actions such as adding or removing products happen in the browser, frequently through AJAX.

The JavaScript module handles:

  • product list clicks;
  • add-to-cart clicks;
  • WooCommerce AJAX add-to-cart completion;
  • removal from cart;
  • variation selection;
  • shipping method selection;
  • payment method selection.

The original data-layer module implemented product extraction, event generation and deduplication logic.


A single event-push function

A central function should clear the previous ecommerce object before sending the next one:

function pushEcommerceEvent(
    eventName,
    ecommerce,
    dedupeKey
) {
    if (!eventName || !ecommerce) {
        return;
    }

    dataLayer.push({
        ecommerce: null
    });

    dataLayer.push({
        event: eventName,
        ecommerce: ecommerce
    });
}

Clearing ecommerce prevents values from an earlier event from leaking into a later one.

For example, without the reset, a view_item event could accidentally retain a coupon or transaction ID from a previous event in a single-page or AJAX-heavy session.


Tracking add-to-cart

The event payload is calculated from product price and quantity:

function trackAddToCart(item, quantity) {
    if (!item) {
        return;
    }

    var payloadItem = clone(item);

    payloadItem.quantity = Math.max(
        1,
        quantity || payloadItem.quantity || 1
    );

    pushEcommerceEvent(
        'add_to_cart',
        {
            currency: config.currency || 'EUR',
            value:
                numberValue(payloadItem.price, 0) *
                payloadItem.quantity,

            items: [payloadItem]
        }
    );
}

The implementation should support both:

  • standard product-page buttons;
  • WooCommerce AJAX buttons in product listings.

WooCommerce exposes an added_to_cart jQuery event:

$(document.body).on(
    'added_to_cart',
    function (event, fragments, cartHash, $button) {
        var button = $button && $button.length
            ? $button.get(0)
            : null;

        var item = getItemFromElement(button);

        trackAddToCart(
            item,
            parseInt(
                button.getAttribute('data-quantity') || '1',
                10
            )
        );
    }
);

Preventing duplicate add-to-cart events

A common implementation error is sending add_to_cart twice:

  1. once when the button is clicked;
  2. once when WooCommerce emits added_to_cart.

A short-lived in-memory deduplication map can prevent this:

var recentEvents = {};

function markRecent(key, ttlMs) {
    var now = Date.now();

    if (
        recentEvents[key] &&
        recentEvents[key] >= now
    ) {
        return false;
    }

    recentEvents[key] = now + (ttlMs || 2000);

    return true;
}

An event key may include:

var key =
    'add:' +
    String(item.item_id || '') +
    ':' +
    quantity;

This is not a replacement for correct event design, but it protects against rapid duplicate listeners.


Part 5: Building the purchase event correctly

The purchase event is the most important ecommerce event.

It must include:

transaction_id
value
currency
tax
shipping
coupon
items

Validating the order

The order confirmation page should not be trusted based only on a URL parameter.

The plugin should validate:

  • the WooCommerce order ID;
  • the order object;
  • the order key from the URL;
  • the real order key stored in WooCommerce.

Example:

$order_id = absint(
    get_query_var('order-received')
);

$order = $order_id
    ? wc_get_order($order_id)
    : false;

$key = isset($_GET['key'])
    ? wc_clean(wp_unslash($_GET['key']))
    : '';

if (
    !$order instanceof WC_Order ||
    $key === '' ||
    !hash_equals($order->get_order_key(), $key)
) {
    return null;
}

This prevents arbitrary order IDs from being used to generate purchase data.


Calculating the purchase value

An important issue discovered during testing was the difference between:

order total including tax

and:

GA4 ecommerce value

For consistent product-level reporting, the event value should equal:

sum of item price × item quantity

while tax and shipping are sent separately.

Correct calculation:

$items       = array();
$items_value = 0.0;

foreach (
    $order->get_items('line_item')
    as $order_item
) {
    if (
        !$order_item instanceof
        WC_Order_Item_Product
    ) {
        continue;
    }

    $quantity = max(
        1,
        (int) $order_item->get_quantity()
    );

    $line_total = (float) $order_item->get_total();

    $items_value += $line_total;

    $price = $this->to_number(
        $line_total / $quantity
    );

    // Build item data here.
}

The event should then use:

'ecommerce' => array(
    'transaction_id' => (string) $order->get_order_number(),
    'affiliation'     => get_bloginfo('name'),
    'value'           => $this->to_number($items_value),
    'tax'             => $this->to_number(
        $order->get_total_tax()
    ),
    'shipping'        => $this->to_number(
        $order->get_shipping_total()
    ),
    'currency'        => $order->get_currency(),
    'coupon'          => implode(
        ',',
        $order->get_coupon_codes()
    ),
    'items'           => $items,
),

Example:

{
    event: "purchase",
    ecommerce: {
        transaction_id: "ORDER-10001",
        value: 443.01,
        tax: 93.04,
        shipping: 0,
        currency: "RON",
        coupon: "",
        items: [
            {
                item_id: "SKU-A",
                item_name: "Example network device",
                price: 129.78,
                quantity: 2
            },
            {
                item_id: "SKU-B",
                item_name: "Example managed switch",
                price: 183.45,
                quantity: 1
            }
        ]
    }
}

The item calculation is:

129.78 × 2 = 259.56
183.45 × 1 = 183.45
Total value = 443.01

Tax is not added again to the ecommerce value because it is already sent through the separate tax parameter.


Purchase deduplication

Customers may reload the confirmation page, reopen it from browser history or revisit the URL from an email.

A browser-side safeguard can store the transaction ID:

if (
    eventName === 'purchase' &&
    ecommerce.transaction_id
) {
    var purchaseKey =
        'purchase_' +
        String(ecommerce.transaction_id);

    if (localStorage.getItem(purchaseKey)) {
        return;
    }

    localStorage.setItem(
        purchaseKey,
        String(Date.now())
    );
}

GA4 can also use the transaction ID to deduplicate purchase events, but local protection reduces unnecessary duplicate network requests.

For higher-value stores, stronger server-side deduplication should also be considered.

For example, WordPress can store an order meta flag after a server-side conversion has been successfully delivered:

$order->update_meta_data(
    '_analytics_purchase_sent',
    current_time('mysql')
);

$order->save();

Browser-only deduplication can be bypassed when:

  • the customer uses another device;
  • local storage is cleared;
  • privacy mode creates a separate storage context;
  • multiple browsers access the same confirmation URL.

Part 6: Consent Mode v2 and the CMP

A consent management platform should be the single source of truth for consent.

An early implementation included custom consent functions inside the ecommerce JavaScript, including:

readConsent
applyConsent
resetConsent
GSCGoogleConsent

That architecture became unnecessary once a dedicated CMP was introduced.

Running two consent systems simultaneously can create conflicting updates:

CMP consent update
+
custom gtag consent update

Possible consequences include:

  • consent states being overwritten;
  • Analytics firing unexpectedly;
  • consent diagnostics reporting missing updates;
  • duplicate consent cookies;
  • difficult debugging.

The final design should therefore be:

Consent Management Platform
    └── controls Google Consent Mode

Custom JavaScript
    └── controls ecommerce events only

The CMP tag should use:

Consent Initialization – All Pages

Initial states should normally be:

analytics_storage: denied
ad_storage: denied
ad_user_data: denied
ad_personalization: denied
security_storage: granted

After the visitor accepts all categories:

analytics_storage: granted
ad_storage: granted
ad_user_data: granted
ad_personalization: granted

This separation of responsibilities makes the implementation easier to audit and maintain.


Part 7: Configuring Google Tag Manager

The GTM container needs at least three logical components.

1. Google tag

Example configuration:

Tag type:
Google tag

Tag ID:
G-XXXXXXXXXX

Trigger:
All permitted pages

On a staging environment, the trigger can be restricted with:

Page Hostname equals staging-shop.example.com

In production:

Page Hostname matches RegEx
^(www\.)?shop\.example\.com$

2. GA4 ecommerce event tag

Example:

Tag type:
Google Analytics: GA4 Event

Measurement ID:
G-XXXXXXXXXX

Event Name:
{{Event}}

The tag should send ecommerce data from the data layer.

A custom-event trigger can match:

^(view_item_list|select_item|view_item|add_to_cart|remove_from_cart|view_cart|begin_checkout|add_shipping_info|add_payment_info|purchase)$

3. Consent-management tag

The CMP should run on:

Consent Initialization – All Pages

It must execute before the Google tag and ecommerce event tags.


Part 8: Google Ads conversion strategy

There are two common ways to send purchases to Google Ads:

  1. direct Google Ads conversion tag;
  2. GA4 purchase event imported into Google Ads.

In this implementation, the existing Google Ads conversion used:

Source: Google Analytics 4
Event: purchase
Optimization: Primary
Count: Every
Attribution: Data-driven

Because the purchase was already imported from GA4, creating another direct Google Ads purchase tag would risk duplicate primary conversions.

The recommended structure was:

purchase
Primary conversion
Used for automated bidding

begin_checkout
Secondary conversion
Used for funnel analysis

add_to_cart
Secondary conversion
Used for funnel analysis

Google Ads should optimize primarily for actual sales, not merely for users who add a product to the cart.


Part 9: Testing the implementation

A tracking implementation is not complete until it has been tested at three levels.

Level 1: dataLayer

In GTM Preview, verify that the event contains:

{
    event: "purchase",
    ecommerce: {
        transaction_id: "...",
        value: 443.01,
        currency: "RON",
        tax: 93.04,
        shipping: 0,
        items: [...]
    }
}

Level 2: GTM hit

Open:

Hits Sent
→ Purchase

Verify that the GA4 request contains:

Event Name: purchase
Transaction ID: expected order number
Value: expected product value
Tax: expected tax
Shipping: expected shipping
Currency: expected currency
Ecommerce Item: expected products

Seeing the data in the data layer is not enough. It must also be present in the outgoing Analytics hit.

Level 3: GA4 and Google Ads

Check:

GA4 DebugView
GA4 Realtime
GA4 Ecommerce purchases
Google Ads conversion diagnostics

A direct test order may appear in GA4 but not as an advertising conversion if it was not preceded by an eligible ad interaction. That is expected.


Recommended final test matrix

Before production launch, test:

ScenarioExpected result
Simple productCorrect view_item and purchase item
Variable productSelected variation ID and price
Quantity greater than oneCorrect quantity and total value
Multiple productsAll items included
CouponDiscounted product value
Paid deliveryCorrect shipping value
Cash on deliveryPurchase sent according to business policy
Online paymentPurchase sent only after valid order confirmation
Page reloadNo duplicate purchase
Consent rejectedNo optional storage
Consent acceptedAnalytics and Ads storage granted
Staging hostnameExcluded or marked as staging
Production hostnameNormal tracking enabled

Common mistakes to avoid

Using the order total as ecommerce value

This may include:

  • tax;
  • shipping;
  • fees.

If these are also sent separately, reporting can become inconsistent.

Sending the same purchase to Ads twice

This often happens when:

  • GA4 purchase is imported into Ads;
  • a direct Google Ads purchase tag is also active;
  • both conversions are marked as primary.

Loading GTM twice

Possible duplicate sources include:

  • a theme integration;
  • an analytics plugin;
  • a custom MU-plugin;
  • a consent plugin;
  • a manually inserted header script.

Running two consent managers

Only one system should control:

analytics_storage
ad_storage
ad_user_data
ad_personalization

Testing only the data layer

The data layer may be correct while the GA4 tag fails to send some parameters. Always verify the outgoing hit.

Ignoring staging traffic

Test orders can distort:

  • revenue;
  • conversion rates;
  • product reports;
  • remarketing audiences;
  • Google Ads optimization.

Use hostname restrictions, development traffic filters or a separate test property.


Final result

The finished integration produced a complete purchase event containing:

unique transaction ID
product-level revenue
tax
shipping
currency
SKU
product name
quantity
brand
category

The purchase reached GA4 correctly and could be imported into Google Ads as the primary sales conversion.

The main technical lessons were:

  1. Build a structured WooCommerce data layer instead of tracking page URLs.
  2. Keep tracking logic in a dedicated MU-plugin.
  3. Separate server-generated and browser-generated events.
  4. Calculate ecommerce value consistently.
  5. Use a unique transaction ID.
  6. Deduplicate purchase events.
  7. Let one CMP manage all consent states.
  8. Verify both the data layer and the outgoing Analytics request.
  9. Avoid duplicate Google Ads conversion sources.
  10. Test the complete funnel before optimizing advertising campaigns.

With this architecture, advertising campaigns can be optimized not simply for clicks or checkout visits, but for actual purchases and their real commercial value.

This article is inspired by real-world challenges we tackle in our projects. If you're looking for expert solutions or need a team to bring your idea to life,

Let's talk!

    Please fill your details, and we will contact you back

      Please fill your details, and we will contact you back