How to Set Up GA4 Ecommerce Tracking: Purchase Events & the Items Array
GA4 ecommerce reports are only as good as the data layer feeding them — and most implementations get the items array subtly wrong. This guide covers the full ecommerce event flow, the exact schema GA4 expects, and the duplicate-purchase bug that quietly doubles your revenue numbers.
Tools used in this guide
How GA4 ecommerce works
Unlike GA4's automatic events, ecommerce tracking is entirely manual: you push structured data to the data layer at each step of the funnel, and GA4 turns it into monetization reports. The power is in the items array — a list of product objects that rides along with every ecommerce event and populates product-level reporting.
Get the items array right and every ecommerce report just works. Get it wrong — misspelled keys, missing currency, wrong data types — and your reports are silently broken while the page appears to work fine.
Step 1: Map the ecommerce funnel to events
GA4 has a set of recommended ecommerce events. Implement the ones that match your funnel:
view_item_list— a product list or category page rendersselect_item— a user clicks a product in a listview_item— a product detail page rendersadd_to_cart/remove_from_cart— cart changesbegin_checkout— checkout startsadd_payment_info/add_shipping_info— checkout stepspurchase— order completed
Start with view_item, add_to_cart, begin_checkout, and purchase — those four power the core funnel report.
Step 2: Build the items array correctly
Every ecommerce event carries an items array. Here's a correct add_to_cart push. Note the exact key names — GA4 is case-sensitive and ignores keys it doesn't recognize:
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ ecommerce: null }); // clear the previous object
window.dataLayer.push({
event: 'add_to_cart',
ecommerce: {
currency: 'USD',
value: 59.99,
items: [{
item_id: 'SKU_12345',
item_name: 'Running Shoes',
item_brand: 'Acme',
item_category: 'Footwear',
price: 59.99,
quantity: 1
}]
}
});The item_id and item_name are the two most important fields — at least one is required for the item to show up in reports. Always include currency and value at the event level, or GA4 will not attribute revenue.
The items array field reference
Each object in the items array supports these fields. GA4 is case-sensitive and silently ignores any key it doesn't recognize, so match these names exactly:
| Field | Required? | Type | Notes |
|---|---|---|---|
item_id | One of id/name | string | Your SKU / product ID |
item_name | One of id/name | string | Human-readable product name |
price | Recommended | number | Per-unit price — a number, not a string |
quantity | Recommended | number | Defaults to 1 if omitted |
currency | Recommended | string | ISO code, e.g. USD — item-level or event-level |
item_brand | Optional | string | Brand for reporting |
item_category | Optional | string | Up to 5 levels: item_category, item_category2…item_category5 |
item_variant | Optional | string | Size, color, etc. |
discount | Optional | number | Per-unit discount amount |
coupon | Optional | string | Coupon code applied |
index | Optional | number | Position in a list (for view_item_list) |
item_list_id / item_list_name | Optional | string | Which list the item came from |
Event-level vs item-level: currency and value belong at the event level (inside ecommerce), while price, quantity, and product attributes belong on each item. For purchase, the event-level value should equal the order total (sum of price × quantity), and transaction_id is required for deduplication.
Step 3: Clear the ecommerce object between pushes
Notice the { ecommerce: null } push before the event. This is essential. GA4's data layer (version 2) merges objects across pushes, so if you don't clear ecommerce first, items from a previous event can bleed into the next one — leading to phantom products in your reports. Clear it before every ecommerce push.
Step 4: Configure the GTM tags
In Google Tag Manager, for each ecommerce event:
- Create a Data Layer Variable reading
ecommerce(this passes the whole object) - Create a GA4 Event tag with the matching event name
- In the tag, tick "Send Ecommerce data" and set the source to Data Layer
- Trigger on a Custom Event matching your data layer event name (e.g.
add_to_cart)

Step 5: The duplicate-purchase bug
This is the most common and most damaging ecommerce bug. The purchase event fires on your order confirmation page — and if a customer refreshes that page or navigates back to it, the event fires again, double-counting the order and its revenue.
Fix it by making purchase events idempotent: store the transaction_id in localStorage (or a cookie) once fired, and check before firing again.
const txnId = 'T_10042';
const fired = JSON.parse(localStorage.getItem('ga4_purchases') || '[]');
if (!fired.includes(txnId)) {
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: txnId,
value: 59.99,
currency: 'USD',
items: [{ item_id: 'SKU_12345', item_name: 'Running Shoes', price: 59.99, quantity: 1 }]
}
});
fired.push(txnId);
localStorage.setItem('ga4_purchases', JSON.stringify(fired));
}Step 6: Verify in DebugView
Use GTM Preview and GA4 DebugView to walk the funnel. For each event confirm the items array is populated, currency and value are present, and — critically — that purchase fires exactly once even after a page refresh.

Handling refunds
Revenue reporting is only accurate if you also send refunds. When an order is refunded, fire a refund event with the original transaction_id. For a full refund, the transaction_id alone is enough; for a partial refund, include the specific items and the refunded value.
window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({
event: 'refund',
ecommerce: {
transaction_id: 'T_10042', // original order id
value: 59.99,
currency: 'USD',
items: [{ item_id: 'SKU_12345', item_name: 'Running Shoes', quantity: 1 }]
}
});Refunds usually happen server-side (in your order-management system), not on a page the customer sees — which is why many teams send them via the Measurement Protocol or server-side GTM rather than the browser data layer.
Client-side vs server-side ecommerce tracking
The data-layer approach in this guide is client-side: events fire in the browser. It's the fastest to implement, but it inherits the browser's fragility — ad blockers, consent tools, and abandoned confirmation pages all cost you purchase events.
For higher accuracy, mature ecommerce setups send the purchase event server-side (from the order backend) via the GA4 Measurement Protocol or a server-side GTM container. The server always knows a real order happened, so you capture every purchase regardless of what the browser does. A common hybrid: fire browser events for the upper funnel (view_item, add_to_cart) and send the authoritative purchase server-side. If you run Meta ads too, the same server-side event stream can feed the [Meta Conversions API](/guides/how-to-set-up-meta-capi).
Common mistakes to avoid
- Wrong key names (
itemIdinstead ofitem_id) → silently dropped, empty reports - Missing currency → revenue not attributed
- Not clearing
ecommerce→ phantom items carried between events - Duplicate purchases → inflated revenue and conversion counts
- Price as a string (
"59.99") → send numbers, not strings, for price and quantity