GA4 Ecommerce Tracking: Getting the Implementation Right
Ecommerce tracking is the part of GA4 that finance eventually checks. The moment someone puts GA4 revenue next to the order system and the two disagree by 30%, every report built on top of it loses credibility, and paid media bidding inherits whatever errors sit in the purchase event.
GA4's ecommerce model is well specified, and most faults we find are implementation shortcuts. This guide is written so a developer and an analyst can implement it and check it from this page alone: every recommended event and its parameters, the items array, working data layer code for Google Tag Manager, the faults that recur, a QA checklist, and how to reconcile what GA4 reports against what actually sold.
The Recommended Event Funnel
Google publishes a recommended set of ecommerce events, and the single best decision you can make is to use those names exactly. GA4's monetisation reports and Google Ads integrations are built around them; a custom checkout_step_2 gets none of that. As we argued in our piece on event taxonomy, recommended events come first and custom events fill the gaps.
The table below lists each event, the moment it should fire, and its parameters. It is checked against Google's recommended events reference, which is the document to keep open while you build. Two rules apply across the whole table. First, wherever value appears, currency is required with it, or revenue metrics are not computed accurately. Second, every event carries an items array, and it is required on all of them except refund and select_promotion, where it is optional but strongly advised.
| Event | When it fires | Required and key parameters |
|---|---|---|
view_item_list | A category page, search results or recommendation carousel is shown | items; also item_list_id, item_list_name |
select_item | The user clicks a product in a list | items; also item_list_id, item_list_name |
view_item | A product detail page loads | currency, value, items |
add_to_cart | A product is added to the cart | currency, value, items |
remove_from_cart | A product is removed from the cart | currency, value, items |
view_cart | The cart page or drawer is viewed | currency, value, items |
begin_checkout | The first checkout step loads | currency, value, items; also coupon |
add_shipping_info | Shipping details are submitted | currency, value, items; also shipping_tier, coupon |
add_payment_info | Payment method is submitted | currency, value, items; also payment_type, coupon |
purchase | The order is confirmed | transaction_id, currency, value, items; also tax, shipping, coupon |
refund | A refund is processed | transaction_id, currency, value; items for item-level refunds |
view_promotion | An internal promotion banner is shown | items; also promotion_id, promotion_name, creative_name, creative_slot |
select_promotion | An internal promotion is clicked | Same as view_promotion; items optional |
You do not need all thirteen on day one. The minimum we will sign off for a store running paid media is view_item, add_to_cart, begin_checkout and purchase, with refund close behind. The list events (view_item_list, select_item) earn their place once you want merchandising analysis, and promotions only matter if you run internal banners worth evaluating. One subtlety: value on view_item does not contribute to revenue, so it is there for analysis, not reporting.
The Items Array: Required and Recommended Parameters
Each event's items array can hold up to 200 products. Google strictly requires only one field per item: an item_id or an item_name. Its guidance is to send both, and more generally to set every ecommerce parameter you have data for, even when it is optional. In the table, "Recommended" is our call on what a serious implementation should always send; Google marks everything other than the ID or name as optional.
| Parameter | Status | Notes |
|---|---|---|
item_id | Required (or item_name) | Use the same SKU as your product feed and order system |
item_name | Required (or item_id) | Send both; names change, IDs should not |
price | Recommended | Unit price as a number, after any item discount |
quantity | Recommended | Defaults to 1 if omitted, which hides multi-unit orders |
discount | Recommended where relevant | Unit discount amount, reported alongside the discounted price |
item_brand | Recommended | Needed for brand-level reporting |
item_category | Recommended | Up to five levels, item_category2 to item_category5 |
item_variant | Recommended | Size, colour or configuration |
index | Optional | Position of the item in a list |
item_list_id, item_list_name | Optional | Item-level values override event-level ones |
coupon | Optional | Item-level coupon, independent of the event-level one |
affiliation, location_id | Optional | Item scope only; useful for marketplaces and physical stores |
Keep item IDs identical to your product feed and order system, or product-level reconciliation becomes impossible. The same IDs are what Google Ads uses to tie conversions back to products in Shopping and Performance Max, which is one more reason the feed work we describe in Merchant Center feed optimisation and your GA4 tagging should share a single source of truth.
The discount convention trips up nearly every implementation, so here it is explicitly. Google's reference says that if a discount applies, price is the discounted unit price and discount holds the unit discount amount. value is then the sum of price multiplied by quantity across all items, with shipping and tax excluded. Because the discount is already inside price, you do not subtract it a second time. Our convention for Australian stores, which the code below follows, is: price and value are GST-exclusive and net of item discounts, shipping is the GST-exclusive shipping fee, and tax holds the GST on the whole order. If finance prefers GST-inclusive revenue, that is a defensible choice too; what matters is that it is written down and identical on every template.
Pushing Ecommerce Data Through the Data Layer
In a Google Tag Manager implementation the site pushes ecommerce data into the dataLayer, and GTM reads it. The pattern Google documents is to clear the previous ecommerce object first, then push the event. Without the clear, GTM's data layer merges objects, and items from an earlier view_item or add_to_cart can leak into the next event.
Here is a purchase push for an order with two lines: two merino tees on sale ($66.00 marked down by $6.00 each) and a cap with a $5.00 coupon. All prices are GST-exclusive.
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null }); // Clear the previous ecommerce object
window.dataLayer.push({
event: "purchase",
ecommerce: {
transaction_id: "MS-100482", // Unique order number from the order system
value: 160.00, // Sum of price quantity: (60.00 2) + (40.00 * 1). Excludes tax and shipping
tax: 17.00, // GST at 10% on (160.00 + 10.00)
shipping: 10.00,
currency: "AUD",
coupon: "CAP5",
items: [
{
item_id: "TEE-MER-NVY-M",
item_name: "Merino Crew Tee",
item_brand: "Example Brand",
item_category: "Apparel",
item_category2: "Tops",
item_variant: "Navy / M",
price: 60.00, // Discounted unit price (was 66.00)
discount: 6.00, // Unit discount, already reflected in price
quantity: 2,
index: 0
},
{
item_id: "CAP-WAX-OLV",
item_name: "Waxed Canvas Cap",
item_brand: "Example Brand",
item_category: "Accessories",
item_category2: "Headwear",
item_variant: "Olive",
coupon: "CAP5",
price: 40.00, // Discounted unit price (was 45.00)
discount: 5.00,
quantity: 1,
index: 1
}
]
}
});The customer paid $187.00: the $160.00 item subtotal, $10.00 shipping and $17.00 GST. When you reconcile, that relationship is what you check: value plus shipping plus tax should equal the order total in the order system, to the cent.
An add_to_cart push is shorter. The value is the value of what was added, not the whole cart.
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: "add_to_cart",
ecommerce: {
currency: "AUD",
value: 60.00, // price * quantity for the item(s) added
items: [
{
item_id: "TEE-MER-NVY-M",
item_name: "Merino Crew Tee",
item_brand: "Example Brand",
item_category: "Apparel",
item_variant: "Navy / M",
price: 60.00,
discount: 6.00,
quantity: 1
}
]
}
});Note that every numeric field is a number, not a string. "$60.00" as a string is one of the commonest reasons product revenue reads zero.
On the GTM side, the setup is deliberately thin. Create a Google Analytics: GA4 Event tag with the event name set to purchase (or use {{Event}} to handle all ecommerce events in one tag), then under More Settings, Ecommerce, tick Send Ecommerce data with the data source set to Data Layer. The tag then reads the ecommerce object directly, so you do not map transaction_id, value or items as individual parameters. Trigger it with a Custom Event trigger matching the event name; for a single tag covering the funnel, a regex trigger such as view_item|add_to_cart|begin_checkout|purchase works well. Make sure the Google tag for your GA4 property has loaded before these fire, and that the event names in the push match the trigger exactly, including case.
Deduplication and the Thank-You Page Reload
The fault we find most often is duplicate purchases. A customer reaches the confirmation page, the tag fires, then they refresh or return from an email and it fires again.
GA4 does help here. According to Google's ecommerce measurement guidance, purchase events sharing a transaction_id from the same user are deduplicated, but only for web streams, not app streams. Never send an empty string or a reused ID, or unrelated orders collapse into one. And remember that deduplication happens in GA4's processing, not in the raw data, so the BigQuery export will still contain both events and your SQL needs to deduplicate on transaction_id itself. The robust fix is belt and braces: rely on GA4's dedupe, and also store a flag keyed to the order once the purchase has been sent, so the page never fires twice. In practice that means the order system records "analytics sent" against the order, or the confirmation template only renders the data layer push on the first view.
Common Faults Beyond Duplicates
Most of the remaining problems come from inconsistency rather than missing tags.
- Value definition drift. Decide whether
valueis net of tax, includes shipping, and reflects discounts, then write it down. Google's convention is the item subtotal withtaxandshippingas separate parameters, which is the cleaner choice. What breaks reporting is mixing: GST-inclusive on one template, exclusive on another. - Native integration plus GTM. Platform apps such as Shopify's Google and YouTube channel send the core ecommerce events themselves. Adding a GTM purchase tag on top double counts. Pick one source of truth per event and remove the other.
- Broken items arrays. Arrays that arrive empty on
add_to_cartorbegin_checkout, or with price as a string including a dollar sign, quietly empty the product reports. - Stale ecommerce objects. Skipping the
ecommerce: nullclear lets items from one event bleed into the next, so abegin_checkoutshows a product the user only viewed. - Refunds never sent. Without
refundevents, GA4 revenue overstates net revenue indefinitely. Send them from the order system when a refund is processed, with the originaltransaction_id, and include the refunded items so product-level refund metrics populate.
Every one of these turns up regularly when we are auditing a GA4 property, and most would have been caught by writing the value convention into a measurement plan before any tag was built.
A QA Checklist Before You Trust the Numbers
Run this list on staging, again on production after launch, and again after any checkout or platform release. Use DebugView together with Tag Assistant or GTM preview mode, which switches debug mode on for your own browser only. Remember to exclude developer traffic so test orders do not end up in reports.
- Open Tag Assistant, connect to the site, and walk the full funnel: list page, product click, product page, add to cart, remove from cart, cart, checkout steps, purchase. Confirm each event appears once, in order, in both Tag Assistant and DebugView.
- In Tag Assistant's data layer view, check that every ecommerce push is preceded by the
ecommerce: nullclear and that no event carries items from a previous step. - Click into each event in DebugView and confirm
currencyis present and equalsAUDon every event that sendsvalue. - Check the maths on the purchase:
valueequals the sum ofpricemultiplied byquantity, andvalueplusshippingplustaxequals the order total shown on the confirmation page. - Confirm
itemsis populated on every event, withitem_idmatching the SKU in the product feed and order system, andpriceandquantitysent as numbers. - Test discounts: place an order with a sale item and a coupon, and confirm
priceis the discounted unit price,discountholds the unit discount, and the coupon appears where expected. - Confirm
transaction_idequals the real order number and is unique. Place two test orders and check they arrive with different IDs. - Reload the confirmation page, then revisit it from the order confirmation email link. No second
purchaseshould fire. - Pay through every gateway you offer (cards, PayPal, Afterpay or similar) and confirm the user returns to your confirmation page and the purchase fires. Add any gateway domains to the unwanted referrals list in your Google tag settings so the sale is not credited to the gateway.
- If checkout runs on a different domain or subdomain, confirm cross-domain measurement is configured and the session and source survive the hop.
- Test consent states. With analytics consent granted, events appear in DebugView. With consent denied, they should not, and your consent platform should behave as your policy says. Check that the purchase still fires after a user changes consent mid-checkout.
- Process a test refund (full and partial) and confirm the
refundevent arrives with the originaltransaction_idand the refunded items. - Check that nothing else is sending the same events: a platform app, a hard-coded gtag snippet or a second GTM container. One source per event.
- After launch, run the seven-day reconciliation described below before anyone uses GA4 revenue for bidding or reporting.
Reconciling GA4 Against the Order System
GA4 will never match your order system exactly. Consent declines under Consent Mode v2, ad blockers, payment redirects that never return, and browser restrictions all remove purchases from GA4 that the platform still records. The question is whether the gap is stable and explained.
Our working benchmark: after any implementation or major release, take seven full days and compare transaction counts and revenue, by day, against the order system. Exclude test orders, phone and marketplace orders that never touch the website, and compare like with like on value (if GA4 value is ex-GST and ex-shipping, use the same definition from the order system). In our experience, a healthy web store lands within roughly 5% of the order system on transactions where consent is not opt-in, and the gap widens in line with the decline rate where it is. Agree a tolerance with finance before you look, so the number is a test rather than a debate. A stable gap inside tolerance is normal; a gap that jumps after a site release is a tagging fault.
Check transaction IDs, not just totals. Exporting both lists and matching on ID shows instantly whether you are missing orders, duplicating them, or recording them with the wrong value. Missing orders that cluster on one payment method point to a gateway return problem; orders present in GA4 with a lower value usually mean a discount or GST definition mismatch; duplicate IDs mean the dedupe fix above has not landed. Once the seven-day check passes, move to a weekly version of the same comparison so drift is caught within days rather than at the end of the quarter.
Where Server-Side Fits
Server-side collection improves resilience, but it is not a dedupe strategy on its own. Routing the browser purchase through a server-side GTM container keeps the event structure intact while giving you control over what is sent. Sending purchases or refunds straight from the backend uses the Measurement Protocol, which Google positions as augmenting tag-based collection rather than replacing it, and which needs a valid client ID to attribute the sale to a session. Refunds are the natural first candidate, because they happen in the order system long after the customer has left the site. The trap is sending the purchase from both the browser and the server: same order, two sources, and only careful use of transaction_id saves you.
If your GA4 revenue and your order system have stopped agreeing, our GA4 configuration work starts with exactly this reconciliation and ends with an implementation finance can trust, with server-side tagging where it earns its place. Get in touch.