Back to Blog
analytics· 11 min read

A GTM Event Taxonomy for E-commerce — The One We Actually Use

A complete event taxonomy for e-commerce GTM containers — naming conventions, parameter standards, and the hierarchy that survives a replatform. Copy-pasteable, opinionated, production-tested.

Why most GTM containers are a mess

Every agency we audit has the same pattern: 47 tags, 84 triggers, no naming convention. Half the events fire on every page. The other half were named by whoever was holding the keyboard that day — purchase_complete, Purchase, purchase_event, tag_purchase_v2_FINAL.

A bad taxonomy is the single biggest reason analytics teams "can't get clean numbers." It's not GA4's fault. It's that nobody decided what to call things and stuck to it.

Below is the exact taxonomy we deploy on every e-commerce GTM container. It's opinionated. Copy it.

The four rules

  1. Event names are GA4-standard or verb_object. No Purchase, no User Signed Up. Either purchase (GA4 standard) or submit_form (verb + object).
  2. Parameters are snake_case and reusable. currency, value, item_id — same field name everywhere.
  3. Every event has event_id, event_time_ms, and page_location. Always. No exceptions. These three give you the audit trail.
  4. Custom events go in a custom_* namespace. custom_quiz_completed, not quiz_completed. This makes them trivially filterable in BigQuery later.

That's it. Most of the rest is just applying these rules.

The standard event list

GA4 has a list of "recommended events" that get special treatment in reports. Use them when they fit, even if the name feels weird.

view_item_list           // category page, search results
view_item                // PDP
select_item              // click on product card
add_to_cart
remove_from_cart
view_cart                // cart drawer or /cart
begin_checkout
add_payment_info
add_shipping_info
purchase
refund

login
sign_up
generate_lead            // newsletter, gated content, contact form

search                   // site search
view_promotion           // hero or banner viewed
select_promotion         // hero or banner clicked

These are the names. Don't invent your own variants. GA4's "Monetization" reports break if you fire purchased instead of purchase. Stick to the list.

The parameter contract

Every commerce event includes the same shape:

interface CommerceEvent {
  // Required, every event
  event_id: string;            // UUID v4, generated once per event
  event_time_ms: number;       // Date.now() at the moment of trigger
  page_location: string;       // location.href
  page_title: string;          // document.title

  // For commerce events
  currency?: string;           // 'USD' — ISO 4217
  value?: number;              // 49.99 — number, not string

  items?: Array<{
    item_id: string;           // SKU
    item_name: string;
    item_brand?: string;
    item_category?: string;    // 'apparel/shirts/tees'
    item_variant?: string;     // 'large/red'
    price: number;
    quantity: number;
    discount?: number;
    item_list_id?: string;     // 'home_featured', 'cart_recommendations'
    item_list_name?: string;
    index?: number;            // position in list
  }>;

  // Identity, when known
  user_id?: string;            // your stable customer ID
  email_sha256?: string;       // hashed in the browser
}

Every event extends this. If a field doesn't apply (no value on view_item_list), omit it — don't send null or 0.

The items array shape is GA4's spec. If you implement this you also automatically populate Enhanced Ecommerce reports without separate work.

Naming conventions for custom events

When the event isn't on GA4's standard list, follow verb_object and prefix with custom_:

custom_size_guide_opened
custom_review_submitted
custom_quiz_completed
custom_subscription_paused
custom_chat_started
custom_video_played
custom_pdf_downloaded

Two anti-patterns:

  • Don't bake the page into the name. homepage_hero_clicked is wrong; select_promotion with promotion_id: 'home_hero_v3' is right. The page is a parameter, not part of the event name.
  • Don't bake variant info into the name. cta_clicked_blue is wrong; select_promotion with creative_name: 'cta_blue_v2' is right.

Once you start putting context into the name, you end up with 200 unique events and no way to roll them up.

The dataLayer pattern

Every event hits dataLayer.push with the same shape:

window.dataLayer = window.dataLayer || [];

function trackEvent(eventName, params = {}) {
  const baseParams = {
    event_id: crypto.randomUUID(),
    event_time_ms: Date.now(),
    page_location: location.href,
    page_title: document.title,
  };

  window.dataLayer.push({
    event: eventName,
    ...baseParams,
    ...params,
  });
}

// Usage
trackEvent('add_to_cart', {
  currency: 'USD',
  value: 49.99,
  items: [{
    item_id: 'TEE-001',
    item_name: 'Classic Tee',
    item_brand: 'Acme',
    item_category: 'apparel/shirts/tees',
    item_variant: 'large/red',
    price: 49.99,
    quantity: 1,
  }],
});

The trackEvent helper is the only thing the rest of your code calls. Every call site is consistent. When you change the contract (add a new required param), you change one function.

GTM tag setup

In GTM, you have one GA4 Event tag that fires on every dataLayer push, and pulls all params dynamically. Not one tag per event. One tag.

The trigger:

Trigger Type:    Custom Event
Event Name:      .*                              (regex match)
Use Regex:       ✓

The tag config:

Tag Type:           GA4 Event
Measurement ID:     {{GA4 Measurement ID}}        (constant variable)
Event Name:         {{Event}}                      (built-in)
Event Parameters:   (read all from dataLayer)

For event parameters, you have two options:

Option A — explicit per-param variables. Create a dataLayer variable for each known param (currency, value, items, etc) and add them all to the tag. More setup, more clarity in GTM debug mode.

Option B — flatten everything. Use a small Custom JavaScript variable that returns the full event object minus the event key. One line in the tag, captures everything you push.

We use Option A for the standard ecommerce params and Option B for the long tail of custom_* params. Best of both.

The fields you forget every time

Three params that get missed in nearly every implementation:

session_id. GA4's reports collapse hits without a session into "(other)." You want the GA4 client-side session ID forwarded into the dataLayer for any server-side or replay events:

function getGa4SessionId() {
  const m = document.cookie.match(/_ga_[A-Z0-9]+=GS\d+\.\d+\.(\d+)/);
  return m ? m[1] : null;
}

Pass it as session_id on every event. It also helps if you ever need to stitch GA4 data with raw event logs.

engagement_time_msec. Required for any event sent through Measurement Protocol. Without it, GA4 marks the event as non-engaged and it doesn't show up in standard reports.

trackEvent('purchase', { engagement_time_msec: 1, /* ... */ });

One millisecond is enough. Don't try to be clever and time how long the user "engaged" with the purchase. GA4 doesn't care.

item_list_id and item_list_name. When a user clicks a product on a category page, you want select_item to record where they came from. Pass item_list_id: 'category_apparel' on both the view_item_list and select_item events. Without it, you can't answer "which category page produces the most PDP views that convert."

The events you should not have

Cull these from any container we inherit:

  • gtm.js, gtm.dom, gtm.load — GTM's internal page lifecycle events. You don't fire tags on these. Use the Initialization, Consent Initialization, or Page View triggers instead.
  • Page View as a separate event from page_view. Pick one. GA4 auto-fires page_view on its own — you don't need a dataLayer push for it unless you're building an SPA. (See SPA section below.)
  • Any *_v2, *_new, *_test event names left in production. Either ship the new one and remove the old, or you're double-counting.
  • track_* prefix anywhere. The track is implied by being in the dataLayer at all.
  • "Click" events fired by GTM's Click trigger that duplicate select_item or select_promotion. Pick one path.

SPA-specific concerns

Single-page apps (Next.js, TanStack Start, React Router, etc) need explicit page_view events because the GA4 tag doesn't auto-fire on client-side navigation.

The pattern:

// On every router navigation (not first page load — GA4 handles that)
router.subscribe('onResolved', ({ toLocation }) => {
  if (typeof window === 'undefined') return;
  trackEvent('page_view', {
    page_location: window.location.origin + toLocation.pathname + toLocation.search,
    page_title: document.title,
  });
});

Fire page_view after the new page has rendered, not before. Otherwise page_title is still the previous page's title.

In GTM, exclude this from triggering the auto-fire page_view to avoid double-counting:

Trigger:   Custom Event
Event:     page_view
Fire on:   Some Custom Events
Exclude:   {{Page Path}} matches RegEx /^/$/  (only on initial home if you have one)

Or simpler: turn off "Send a page view event when this configuration loads" in your GA4 Configuration tag, then fire page_view only via dataLayer.

Verifying it works

Three checks, in order:

1. GA4 DebugView. Open ?gtm_debug=1 in your browser. Trigger every event. Each should appear in DebugView with the expected parameters. If currency is missing on purchase, your dataLayer push is wrong.

2. GTM Preview Mode. For each event, Preview shows you the exact payload that hit GA4. Catches typos, wrong data types, missing fields.

3. BigQuery, after a day. GA4 BigQuery export is the source of truth. Run:

SELECT
  event_name,
  COUNT(*) as count,
  COUNT(DISTINCT event_params.value.string_value) FILTER (
    WHERE event_params.key = 'event_id'
  ) as unique_event_ids
FROM `your-project.analytics_XXXXXX.events_*`,
UNNEST(event_params) as event_params
WHERE _TABLE_SUFFIX BETWEEN '20260501' AND '20260502'
GROUP BY 1
ORDER BY 2 DESC;

If count and unique_event_ids differ wildly, you have duplicate event_id generation (bad) or missing event_id on some events (also bad).

What this gets you

A taxonomy this strict pays off in three places:

  1. GA4 reports just work. Enhanced Ecommerce, Funnel exploration, Path exploration — all of them assume the standard event/param names. Use them and you don't need to build custom reports for basics.
  2. BigQuery is queryable. Consistent param names mean every query is the same shape. No IF(event_name = 'Purchase' OR event_name = 'purchase' OR ...) defensive code.
  3. Replatforming is survivable. When you migrate from Shopify to BigCommerce, or Webflow to Next.js, the dataLayer contract stays the same. The trigger is the implementation detail.

What this taxonomy doesn't cover

Things we keep separate, not because they don't matter, but because they're project-specific:

  • Server-side events. The taxonomy here is the client-side dataLayer. Server-side events (purchase webhooks, refund webhooks, subscription state changes) get the same naming conventions but live in a separate doc with their own validation. We covered the Cloudflare Worker pattern here.
  • Meta CAPI mappings. Same events, but Meta wants Purchase not purchase, AddToCart not add_to_cart. We map at the destination, not in the dataLayer. The CAPI dedup post covers it.
  • Marketing platform pixels. TikTok, Pinterest, LinkedIn, Snapchat — each has its own event naming. Same mapping pattern: standard dataLayer, platform-specific destination tag.

If your GTM container has the messy hallmarks — duplicate events, mixed naming, no consistent params — and you want a second opinion on the cleanup path, send us your container ID. The audit is free; we'll send you a written diff of what's there vs. what we'd ship.

Need help with this in your business?

Our Web Analytics service starts with a free audit.