purchase
The purchase event is the single most important event in your entire GA4 ecommerce implementation. Every other ecommerce event — view_item, add_to_cart, begin_checkout — exists to give context to this one. If your purchase event is broken, duplicated, or missing data, your revenue reporting is wrong, your ROAS calculations are fiction, and every optimization decision you make based on that data is compromised.
This is the event you triple-check.
The complete dataLayer push
Section titled “The complete dataLayer push”Here is a production-ready purchase push with all standard parameters. Every real implementation should look like this — not an abbreviated version with one item and half the fields missing.
// Always clear previous ecommerce data firstdataLayer.push({ ecommerce: null });
dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'TXN-2024-98765', value: 142.97, tax: 28.59, shipping: 5.99, currency: 'USD', coupon: 'SUMMER15', items: [ { item_id: 'SKU-001-BLK', item_name: 'Classic Leather Jacket', item_brand: 'Heritage Co.', item_category: 'Apparel', item_category2: 'Outerwear', item_category3: 'Jackets', item_category4: 'Leather', item_variant: 'Black / Large', price: 89.99, quantity: 1, index: 0, affiliation: 'Online Store', coupon: 'SUMMER15', discount: 13.50 }, { item_id: 'SKU-047-WHT', item_name: 'Cotton Crew T-Shirt', item_brand: 'Heritage Co.', item_category: 'Apparel', item_category2: 'Tops', item_category3: 'T-Shirts', item_category4: 'Crew Neck', item_variant: 'White / Medium', price: 24.99, quantity: 2, index: 1, affiliation: 'Online Store', coupon: '', discount: 0 }, { item_id: 'SKU-112-TAN', item_name: 'Woven Leather Belt', item_brand: 'Heritage Co.', item_category: 'Accessories', item_category2: 'Belts', item_category3: 'Leather Belts', item_category4: 'Casual', item_variant: 'Tan / 34', price: 34.50, quantity: 1, index: 2, affiliation: 'Partner Marketplace', coupon: '', discount: 0 } ] }});Why the ecommerce null clear matters
Section titled “Why the ecommerce null clear matters”This line is not optional:
dataLayer.push({ ecommerce: null });GTM’s dataLayer uses a shallow merge model. If a previous event — say begin_checkout — pushed an ecommerce object, its data persists in GTM’s internal state. Without clearing, your purchase event might inherit stale data from the checkout step, or worse, the checkout event’s items could bleed into the purchase.
On single-page applications this is especially dangerous because the dataLayer never resets between “page” navigations. Always clear before every ecommerce push.
Without clearing
// ❌ Previous ecommerce data persistsdataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'TXN-123', value: 99.99, currency: 'USD', items: [{ item_id: 'SKU-001' }] }});// Risk: stale fields from begin_checkout// may contaminate this eventWith clearing
// ✅ Clean slate before pushingdataLayer.push({ ecommerce: null });dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'TXN-123', value: 99.99, currency: 'USD', items: [{ item_id: 'SKU-001' }] }});// Clean — only this event's data existsEvent schema
Section titled “Event schema”| Parameter | Type | Required | Description |
|---|---|---|---|
| event | string | Required | Must be "purchase" |
| ecommerce.transaction_id | string | Required | Unique identifier for the transaction. Used for deduplication in GA4. |
| ecommerce.value | number | Required | Total monetary value of the transaction. Must be a number, not a string. |
| ecommerce.currency | string | Required | ISO 4217 currency code (e.g. "USD", "EUR", "GBP"). Required for any event with a value parameter. |
| ecommerce.tax | number | Optional | Total tax amount for the transaction. |
| ecommerce.shipping | number | Optional | Total shipping cost for the transaction. |
| ecommerce.coupon | string | Optional | Order-level coupon code. Item-level coupons go in the items array. |
| ecommerce.items[] | Array<Item> | Required | Array of purchased items. Must contain at least one item. |
| items[].item_id | string | Required | SKU or unique product identifier. |
| items[].item_name | string | Required | Product name. Either item_id or item_name is required — include both. |
| items[].item_brand | string | Optional | Brand or manufacturer name. |
| items[].item_category | string | Optional | Primary product category. Up to 5 levels (item_category through item_category5). |
| items[].item_variant | string | Optional | Variant such as color, size, or style. |
| items[].price | number | Optional | Unit price of the item. Must be a number, not a formatted string. |
| items[].quantity | number | Optional | Number of units purchased. Defaults to 1 if omitted. |
| items[].index | number | Optional | Position of the item in a list (0-based). |
| items[].affiliation | string | Optional | Store or seller affiliation. Useful for marketplaces. |
| items[].coupon | string | Optional | Item-level coupon code applied to this specific product. |
| items[].discount | number | Optional | Monetary discount applied to the item. |
GTM configuration
Section titled “GTM configuration”Tag setup
Section titled “Tag setup”GA4 - Event - Purchase
- Type
- Google Analytics: GA4 Event
- Trigger
- CE - purchase
- Variables
-
DLV - ecommerce.transaction_idDLV - ecommerce.valueDLV - ecommerce.currencyDLV - ecommerce.taxDLV - ecommerce.shippingDLV - ecommerce.couponDLV - ecommerce.items
Step-by-step GTM setup
Section titled “Step-by-step GTM setup”-
Create a Custom Event trigger. Go to Triggers > New > Custom Event. Set the event name to
purchase. Name itCE - purchase. -
Create dataLayer variables. You need a Data Layer Variable for each ecommerce parameter. Go to Variables > New > Data Layer Variable. Create variables for:
ecommerce.transaction_id(name itDLV - ecommerce.transaction_id)ecommerce.value(name itDLV - ecommerce.value)ecommerce.currency(name itDLV - ecommerce.currency)ecommerce.tax(name itDLV - ecommerce.tax)ecommerce.shipping(name itDLV - ecommerce.shipping)ecommerce.coupon(name itDLV - ecommerce.coupon)ecommerce.items(name itDLV - ecommerce.items)
-
Create the GA4 Event tag. Go to Tags > New > Google Analytics: GA4 Event.
- Measurement ID: Use your GA4 measurement ID constant variable (or your GA4 Configuration tag if using the older setup).
- Event Name:
purchase - Event Parameters: Map each parameter name to its corresponding Data Layer Variable. Add
transaction_id,value,currency,tax,shipping,coupon, anditems.
-
Enable ecommerce data. In the tag settings under “More Settings”, check Send Ecommerce data and select Data Layer as the source. This tells GTM to automatically read the
ecommerce.itemsarray from the dataLayer and send it as the items parameter. -
Set the trigger. Attach the
CE - purchasetrigger to the tag. -
Test in Preview mode. Navigate to a test order confirmation page, verify the event fires, and check that all parameters appear in the GA4 DebugView.
TypeScript type definition
Section titled “TypeScript type definition”Use this type in your frontend code to enforce correct purchase event structure at compile time:
interface EcommerceItem { item_id: string; item_name: string; item_brand?: string; item_category?: string; item_category2?: string; item_category3?: string; item_category4?: string; item_category5?: string; item_variant?: string; price: number; quantity: number; index?: number; affiliation?: string; coupon?: string; discount?: number; item_list_id?: string; item_list_name?: string;}
interface PurchaseEvent { event: 'purchase'; ecommerce: { transaction_id: string; value: number; currency: string; tax?: number; shipping?: number; coupon?: string; items: EcommerceItem[]; };}
function pushPurchaseEvent(data: PurchaseEvent): void { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push(data);}Platform examples
Section titled “Platform examples”// Assumes orderData is available from your backend (e.g., injected as JSON)function trackPurchase(orderData) { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null });
window.dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: orderData.orderId, value: orderData.totalRevenue, tax: orderData.taxAmount, shipping: orderData.shippingCost, currency: orderData.currency, coupon: orderData.couponCode || '', items: orderData.lineItems.map((item, index) => ({ item_id: item.sku, item_name: item.name, item_brand: item.brand, item_category: item.category, item_variant: item.variant, price: item.unitPrice, quantity: item.quantity, index: index, affiliation: item.seller || 'Online Store', coupon: item.itemCoupon || '', discount: item.discountAmount || 0 })) } });}
// Call once on order confirmation page loaddocument.addEventListener('DOMContentLoaded', function () { if (window.__ORDER_DATA__) { trackPurchase(window.__ORDER_DATA__); }});import { useEffect, useRef } from 'react';import type { OrderData } from '@/types/order';
interface Props { order: OrderData;}
export function OrderConfirmation({ order }: Props) { const hasFired = useRef(false);
useEffect(() => { // Prevent duplicate purchase events on re-renders if (hasFired.current) return; hasFired.current = true;
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: order.id, value: order.total, tax: order.tax, shipping: order.shipping, currency: order.currency, coupon: order.coupon ?? '', items: order.items.map((item, index) => ({ item_id: item.sku, item_name: item.name, item_brand: item.brand, item_category: item.category, item_variant: item.variant, price: item.price, quantity: item.quantity, index, affiliation: item.affiliation ?? 'Online Store', coupon: item.coupon ?? '', discount: item.discount ?? 0, })), }, }); }, [order]);
return ( <div> <h1>Thank you for your order!</h1> <p>Order #{order.id} confirmed.</p> </div> );}import { redirect } from 'next/navigation';import { getOrder } from '@/lib/orders';import { PurchaseTracker } from './PurchaseTracker';
export default async function OrderConfirmationPage({ searchParams,}: { searchParams: { orderId?: string };}) { const orderId = searchParams.orderId; if (!orderId) redirect('/');
const order = await getOrder(orderId); if (!order) redirect('/');
return ( <main> <h1>Order Confirmed</h1> <p>Order #{order.id} — Total: {order.currency} {order.total}</p>
{/* Client component handles the dataLayer push */} <PurchaseTracker order={order} /> </main> );}
// PurchaseTracker.tsx — client component'use client';
import { useEffect, useRef } from 'react';import type { Order } from '@/types/order';
export function PurchaseTracker({ order }: { order: Order }) { const tracked = useRef(false);
useEffect(() => { if (tracked.current) return; tracked.current = true;
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: order.id, value: order.total, tax: order.tax, shipping: order.shipping, currency: order.currency, coupon: order.coupon ?? '', items: order.items.map((item, idx) => ({ item_id: item.sku, item_name: item.name, item_brand: item.brand, item_category: item.category, item_variant: item.variant, price: item.price, quantity: item.quantity, index: idx, affiliation: item.affiliation ?? 'Online Store', coupon: item.coupon ?? '', discount: item.discount ?? 0, })), }, }); }, [order]);
return null;}Preventing duplicate purchase events
Section titled “Preventing duplicate purchase events”Duplicate purchases are one of the most common ecommerce tracking problems. A user refreshes the confirmation page, or the component re-renders, and suddenly you have two purchase events for the same order. Your revenue doubles overnight and someone in finance starts asking questions.
The deduplication pattern
Section titled “The deduplication pattern”The most reliable approach is server-rendered: only output the dataLayer push once, using a flag that the backend controls.
// Backend injects this flag — only true on the FIRST render of this orderif (window.__PURCHASE_TRACKED__ !== true) { window.__PURCHASE_TRACKED__ = true;
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'TXN-2024-98765', // ... full event data } });}For SPAs, use sessionStorage keyed by transaction ID:
function trackPurchaseOnce(purchaseData) { const key = `purchase_tracked_${purchaseData.ecommerce.transaction_id}`;
if (sessionStorage.getItem(key)) { console.warn('Purchase already tracked:', purchaseData.ecommerce.transaction_id); return; }
sessionStorage.setItem(key, 'true');
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push(purchaseData);}Validation checklist
Section titled “Validation checklist”Before you ship your purchase tracking to production, verify every item on this list:
-
transaction_idis unique per order. Check that the same ID never appears twice for different orders. Use your order system’s actual ID — do not generate one client-side. -
valueis a number, not a string.value: 142.97is correct.value: '142.97'is wrong.value: '$142.97'is very wrong. GA4 will silently ignore non-numeric values. -
currencyis present and valid. Must be an ISO 4217 code (USD,EUR,GBP). Without currency, GA4 cannot calculate revenue. This is the most commonly missing parameter. -
itemsarray is populated. An empty items array means GA4 records revenue but cannot attribute it to any products. Your ecommerce reports will show totals with no item breakdown. -
Each item has
item_idanditem_name. Both are technically optional (you need at least one), but in practice you need both for useful reports. -
Prices are unit prices, not line totals.
price: 24.99withquantity: 2is correct.price: 49.98withquantity: 2is wrong — GA4 will calculate line total as 99.96. -
The event fires exactly once per transaction. Test page refreshes, browser back-button, and SPA re-renders.
-
Ecommerce null clear is present. Confirm
dataLayer.push({ ecommerce: null })appears before the purchase push. -
Data appears in GA4 DebugView. Open GA4 > Admin > DebugView. Enable debug mode via GTM Preview or the GA Debugger extension. Verify all parameters appear correctly.
-
Revenue matches your backend. Compare GA4 reported revenue against your actual order system for a sample of test transactions.
Common mistakes
Section titled “Common mistakes”Missing currency
Section titled “Missing currency”// ❌ No currency — GA4 ignores the value entirelydataLayer.push({ event: 'purchase', ecommerce: { transaction_id: 'TXN-001', value: 99.99, items: [{ item_id: 'SKU-001', item_name: 'Widget', price: 99.99, quantity: 1 }] }});Without currency, GA4 cannot attribute monetary value. Your ecommerce reports will show the event happened, but revenue will be zero. Always include currency on every event that has a value parameter.
Forgetting to clear ecommerce
Section titled “Forgetting to clear ecommerce”On SPAs especially, failing to push { ecommerce: null } before the purchase push means stale data from previous ecommerce events (like begin_checkout or add_to_cart) can merge into your purchase event. This leads to phantom items, wrong quantities, or corrupted item arrays.
Value as a formatted string
Section titled “Value as a formatted string”// ❌ All of these are wrongvalue: '$142.97' // Currency symbolvalue: '142.97' // String instead of numbervalue: '1,429.70' // Comma formattingvalue: '142,97' // European decimal format
// ✅ Correct — plain number, no formattingvalue: 142.97GA4 expects a raw number. Any string formatting, currency symbols, or thousand separators will cause the value to be silently dropped or misinterpreted.
Duplicate purchase events
Section titled “Duplicate purchase events”As covered above, page refreshes and SPA re-renders are the usual culprits. Implement client-side deduplication using sessionStorage or a useRef guard, and always include transaction_id so GA4’s server-side deduplication can catch anything you miss.
Missing or generic transaction_id
Section titled “Missing or generic transaction_id”// ❌ Terrible — same ID for every ordertransaction_id: 'purchase'
// ❌ Bad — generated client-side, not tied to actual ordertransaction_id: Date.now().toString()
// ✅ Good — actual order ID from your backendtransaction_id: 'ORD-2024-00847'The transaction_id is your primary key for deduplication and for reconciling GA4 data against your order system. It must be the real order ID from your backend — not a timestamp, not a random string, and definitely not a static value.