CreativeCape

GA4 E-commerce Tracking in GTM: The Complete Funnel

view_item to purchase, wired properly in Google Tag Manager: the nested ecommerce object, one tag for the whole funnel, and deduplication that actually works.

August 28, 2026·4 min read

GA4's ecommerce reports are genuinely good — when they are fed correctly. Feeding them correctly through Google Tag Manager is a narrower path than the documentation suggests, and most of the difficulty is in three or four specific details.

The eight events that matter

You do not need every event GA4 supports. This set covers the funnel:

| Event | Fires when |

|---|---|

| view_item_list | A list of products is displayed |

| select_item | A product is clicked in a list |

| view_item | A product detail page is viewed |

| add_to_cart | Item added |

| remove_from_cart | Item removed |

| view_cart | Cart viewed |

| begin_checkout | Checkout started |

| purchase | Payment confirmed by the server |

add_payment_info and refund are worth adding once the core set is solid.

Nested ecommerce vs the flat gtag shape

The single most common implementation error. If you copy gtag.js examples into a GTM setup, you get a flat payload GTM will not read as ecommerce.

Wrong for GTM (this is the gtag.js shape):


dataLayer.push({ event: "add_to_cart", currency: "USD", value: 79, items: [...] });

Right for GTM — nested under ecommerce:


dataLayer.push({ ecommerce: null });

dataLayer.push({

  event: "add_to_cart",

  ecommerce: { currency: "USD", value: 79, items: [...] },

});

With the flat shape the tag fires, GA4 records the event, and the item and revenue data is simply absent. Everything looks like it works.

Building the item object once

Item fields must be consistent across every event, or GA4 cannot join them into product reports. Write one mapper and use it everywhere:


function toGaItem(product, ctx = {}) {

  return {

    item_id:       product.slug,

    item_name:     product.title,

    item_brand:    "CreativeCape",

    item_category: product.category ?? undefined,

    item_variant:  ctx.tier ?? undefined,

    price:         Number(product.price) || 0,

    quantity:      ctx.quantity ?? 1,

    index:         ctx.index,

    item_list_id:  ctx.list_id,

    item_list_name: ctx.list_name,

  };

}

Rules worth enforcing: item_id is a stable identifier (slug or SKU, never a database row number that changes between environments); price is a number; item_variant carries the meaningful variation — for us that is the licence tier.

One regex trigger and one GA4 tag for the whole funnel

Build a single Custom Event trigger with Use regex matching on:


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

Then one GA4 Event tag:

  • Event Name: {{Event}} — passes the dataLayer event name straight through

  • More Settings → Ecommerce → Send Ecommerce data = ON, Data source = Data Layer

That mapping handles items, value, currency, transaction_id, coupon and tax automatically. One tag, entire funnel.

Purchase dedup: transaction_id plus sessionStorage

purchase is the event you cannot afford to get wrong, and the default behaviour of a confirmation page is to fire it again on every refresh.

Two layers of protection:

1. Always send a stable transaction_id. GA4 deduplicates on it. Use your order number — never Date.now() or a random value, which defeats the whole mechanism.

2. Guard client-side as well:


function purchase(order) {

  const key = `purchase_${order.transaction_id}`;

  try {

    if (sessionStorage.getItem(key)) return;   // already sent

    sessionStorage.setItem(key, "1");

  } catch {}

  pushEcom("purchase", { ...order, currency: "USD" });

}

Fire purchase only after server verification

If purchase fires when the payment modal reports success, you are recording revenue the business has not confirmed. Payment gateways can report client-side success for transactions that later fail verification, and a determined user can trigger the handler directly.

The correct sequence:

  1. Gateway returns a client-side success

  2. Send the payload to your server

  3. Server verifies the signature and writes the order

  4. Only then fire purchase, using the order number your database issued

This is also why the id must come from the server: it is what makes GA4 and your orders table reconcilable.

Refunds

Refunds are usually skipped, and revenue drifts upward permanently as a result. A full refund needs only the transaction id:


pushEcom("refund", { transaction_id: "ORD-2026-00042", currency: "USD", value: 79 });

Because refunds happen in an admin system rather than the user's browser, the robust approach is the Measurement Protocol, server-side, when the refund is processed.

QA checklist

Before you trust a single report:

  • Each event fires exactly once per action

  • { ecommerce: null } precedes every ecommerce push

  • Items in each event are the right items, not leftovers

  • value is numeric and matches the charge

  • transaction_id matches your order number

  • Refreshing the confirmation page does not re-fire purchase

  • A day of GA4 revenue reconciles against the orders table

That last line is the only one that really matters. Everything above exists to make it true.


→ Analytics Implementation

Tagged with
#ga4 ecommerce#gtm#purchase event#add_to_cart#conversion tracking#datalayer

Found this useful? Share it.

Keep Reading

Related articles

Booking Q2 2026 Projects

Ready to Build Something Great?

From idea to launch — let our senior engineers build, ship and scale your next product. No commitment, just a conversation.

Senior Engineers
On-Time Delivery
Enterprise-Grade
Free Consultation

Free 30-min discovery call

Talk to a senior engineer — not a salesperson.

We'll review your goals, suggest the leanest path forward, and send a clear proposal within 24 hours.

24h

Response Time

100+

Projects Delivered

No commitment · No automated bots · Fully transparent