GA4

GA4 Ecommerce Tracking with the dataLayer

Published 

A code editor open on a laptop in a darkened room

Ecommerce is the part of a GA4 implementation where mistakes are most expensive, because the output is revenue and finance will notice when it is wrong. It is also the part most often built from a half-remembered Universal Analytics spec, which produces something that appears to work and reports numbers that never quite reconcile.

This is the spec we hand developers. It covers the event sequence, the items array, and the specific mistakes that cause revenue discrepancies.

The rule that prevents most problems

Clear the ecommerce object before every push.

GA4's ecommerce events reuse the same ecommerce key in the dataLayer, and the dataLayer is cumulative — values persist until overwritten. If you push a view_item with one item and then push an add_to_cart without clearing, the second event can inherit fields from the first.

1dataLayer.push({ ecommerce: null });
2dataLayer.push({
3 event: 'add_to_cart',
4 ecommerce: { /* ... */ }
5});

Two separate pushes. The first sets ecommerce to null, the second sets the new value. Doing it in one push does not work, because the key is simply overwritten and any field absent from the new object is not necessarily cleared in the merged view GTM reads.

This one line prevents a large share of ecommerce data problems, and it is the line most implementations are missing.

The items array

Every ecommerce event carries an items array. The fields are shared across all of them.

Required in practice: item_id and item_name. GA4 accepts one or the other, but sending both is the only way reports stay legible.

Strongly recommended: price (number, not string), quantity (number), item_brand, item_category, item_variant.

Contextual: item_list_name and item_list_id on list views, index for position in a list, affiliation, coupon, discount, location_id.

1{
2 item_id: 'SKU_12345',
3 item_name: 'Trail Running Shoe',
4 affiliation: 'Online Store',
5 coupon: 'SPRING10',
6 discount: 5.00,
7 index: 0,
8 item_brand: 'Northbound',
9 item_category: 'Footwear',
10 item_category2: 'Running',
11 item_category3: 'Trail',
12 item_list_id: 'search_results',
13 item_list_name: 'Search Results',
14 item_variant: 'Blue / UK 9',
15 price: 89.99,
16 quantity: 1
17}

Three type rules that are not optional:

price and discount are numbers. price: "89.99" as a quoted string arrives as ep.price rather than epn.price and will not aggregate. This is the single most common cause of an item revenue figure that reads zero.

quantity is an integer.

item_category through item_category5 are a hierarchy, not a list of tags. FootwearRunningTrail, in that order. Putting unrelated attributes in the category levels makes the category reports meaningless.

price is the unit price, before quantity. Not the line total. GA4 multiplies by quantity itself. Sending the line total in price on a two-item line double-counts that line's revenue, and the error compounds across the basket.

The event sequence

view_item_list

Fires when a list of products becomes visible — category page, search results, recommendations block. Include item_list_id and item_list_name on every item so lists are comparable, and index so position analysis is possible.

1dataLayer.push({ ecommerce: null });
2dataLayer.push({
3 event: 'view_item_list',
4 ecommerce: {
5 item_list_id: 'search_results',
6 item_list_name: 'Search Results',
7 items: [
8 { item_id: 'SKU_12345', item_name: 'Trail Running Shoe', price: 89.99, index: 0, item_list_name: 'Search Results' },
9 { item_id: 'SKU_12346', item_name: 'Road Running Shoe', price: 79.99, index: 1, item_list_name: 'Search Results' }
10 ]
11 }
12});

Fire it on visibility, not on page load. A list below the fold that the user never scrolled to was not viewed.

select_item

Fires on the click into a product from a list. Carries the single clicked item, with the list context preserved — this is what lets you attribute a purchase back to the list it started from.

view_item

Product detail page.

1dataLayer.push({ ecommerce: null });
2dataLayer.push({
3 event: 'view_item',
4 ecommerce: {
5 currency: 'GBP',
6 value: 89.99,
7 items: [{
8 item_id: 'SKU_12345',
9 item_name: 'Trail Running Shoe',
10 item_brand: 'Northbound',
11 item_category: 'Footwear',
12 item_variant: 'Blue / UK 9',
13 price: 89.99,
14 quantity: 1
15 }]
16 }
17});

currency is required whenever value is present. Without it GA4 cannot convert to the property currency and the value is discarded.

add_to_cart and remove_from_cart

Carry only the items added or removed, with the quantity of that action — not the resulting cart contents.

1dataLayer.push({ ecommerce: null });
2dataLayer.push({
3 event: 'add_to_cart',
4 ecommerce: {
5 currency: 'GBP',
6 value: 179.98,
7 items: [{
8 item_id: 'SKU_12345',
9 item_name: 'Trail Running Shoe',
10 price: 89.99,
11 quantity: 2
12 }]
13 }
14});

Note value is 179.98 — price times quantity — while price stays at the unit price of 89.99.

view_cart, begin_checkout, add_shipping_info, add_payment_info

The checkout sequence. All carry the full basket, currency, and value for the whole basket.

add_shipping_info takes shipping_tier (Ground, Next Day). add_payment_info takes payment_type (Credit Card, PayPal). Both are worth having — checkout drop-off by payment method is one of the more actionable ecommerce reports.

purchase

The one that has to be right.

1dataLayer.push({ ecommerce: null });
2dataLayer.push({
3 event: 'purchase',
4 ecommerce: {
5 transaction_id: 'ORDER_98765',
6 value: 179.98,
7 tax: 30.00,
8 shipping: 4.99,
9 currency: 'GBP',
10 coupon: 'SPRING10',
11 items: [{
12 item_id: 'SKU_12345',
13 item_name: 'Trail Running Shoe',
14 item_brand: 'Northbound',
15 item_category: 'Footwear',
16 item_variant: 'Blue / UK 9',
17 price: 89.99,
18 quantity: 2
19 }]
20 }
21});

transaction_id is mandatory. It is how GA4 deduplicates. Without it, every page refresh on the confirmation page creates another purchase, and your revenue is whatever multiple of reality your customers' refresh habits produce.

Decide what value includes and document it. GA4 does not enforce a definition. Most implementations use the pre-tax, pre-shipping subtotal, with tax and shipping sent separately. Whatever you choose, it must match what finance reconciles against, and it must be written down.

refund

Full refund: transaction_id alone. Partial refund: transaction_id plus the items being refunded with their quantities. Often skipped, and then net revenue in GA4 permanently overstates reality.

The mistakes that cause revenue discrepancies

Purchase firing on refresh. No transaction_id, or a transaction_id that changes per page load. Check by refreshing the confirmation page with a capture running.

Purchase not firing at all for some payment methods. A payment provider that redirects off-domain and returns to a different confirmation URL than the one your trigger matches. PayPal and most bank-redirect methods do this. Test every payment method, not just card.

Cross-domain break at the gateway. The user leaves for the payment provider and comes back as a new session, so the purchase is attributed to a referral from the gateway rather than to the original campaign. Symptom: your payment provider appears near the top of the traffic acquisition report.

price as a string. Revenue reads zero or is wildly wrong.

value including tax and shipping in GA4 but not in the finance system. Or the reverse. A permanent 15% discrepancy that everybody eventually stops trying to explain.

Missing currency on a multi-currency site. Values silently dropped.

Item-scoped custom dimensions not registered. item_supplier in the items array is collected and invisible unless registered — see custom dimensions vs event parameters. Item-scoped definitions have their own limit of 10, which is easy to hit.

Testing the implementation

Walking the full journey with a live capture is the only meaningful test. Live tag debugging covers the workflow; for ecommerce specifically, check:

  1. Every event in the sequence fires, once, in order.
  2. transaction_id is stable and unique per order — and refreshing the confirmation page does not produce a second purchase.
  3. value matches the order total under your documented definition.
  4. epn.price and epn.quantity in the payload, not ep. — numeric, not string.
  5. currency present on every event carrying a value.
  6. cid and sid unchanged across the gateway redirect.
  7. Every payment method, including the redirect-based ones.
  8. A refund produces a refund event.

Then reconcile a full day of GA4 revenue against the order system. A gap under two percent is normal (ad blockers, consent). A gap over ten percent means something on this list is wrong.

How Tagfire helps

Tag Debugger captures the whole purchase sequence as you walk it, decoded per event, so you can see the items array and parameter types without reading raw query strings. The headless mode is particularly useful here — you can drive the checkout server-side rather than making real test purchases from your own browser, and the capture streams back live.

The part that matters more: once the sequence is correct, record it. Save the journey as steps, attach expectations — purchase must fire, value greater than zero, currency equal to GBP, transaction_id present — and group it into a scheduled monitor. Every week the journey replays headlessly in a real browser, the expectations are checked, a full-page screenshot is taken and diffed against the accepted baseline, and any failure emails the team.

That is the difference between an ecommerce implementation that was correct in February and one that is still correct in September. Checkout code changes more often than any other part of a site, and it is the part where a silent break costs the most.

GA4 Audit includes a dedicated ecommerce pass — whether purchase events are arriving, whether transaction_id is present, item-scoped dimension usage against the limit of 10, and currency configuration.

GA4 Sync Checker catches item-scoped and event-scoped parameters that your ecommerce tags send but GA4 never registered.

The documentation covers recording a journey and setting up a monitor.

Related reading
One person pointing at something on a laptop screen while another types
Tag Debugging

Live Tag Debugging for GA4 and GTM

How to debug GA4 and GTM tags by watching hits fire in real time — what GTM Preview and DebugView miss, a repeatable workflow, and how to turn a debugging session into an automated check.

Read article