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 throughMore 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:
Gateway returns a client-side success
Send the payload to your server
Server verifies the signature and writes the order
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 pushItems in each event are the right items, not leftovers
valueis numeric and matches the chargetransaction_idmatches your order numberRefreshing the confirmation page does not re-fire
purchaseA 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.