Freebies 9 min read

Free GTM Container for Shopify + GA4 Ecommerce (With Transaction ID Deduplication)

A hand-authored GTM container that ships the full GA4 ecommerce funnel for standard Shopify — 13 tags, 12 triggers, and a Custom JS dedup pattern that stops revenue double-counting on order-status refresh. MIT-licensed.

A
Ann McBride
·

If you run GTM on a Shopify store and your GA4 revenue is 2–4x higher than what Shopify actually rang up, you’re looking at a transaction_id deduplication problem. And if your refund events never show up in GA4, that’s a different problem with the same root cause: standard Shopify’s checkout is a separate domain (checkout.shopify.com) that your storefront’s GTM can’t see directly.

Both are solvable without an app. Both are solved by a thoughtfully-built GTM container. We’ve open-sourced ours.

Repo: github.com/aumlytics/gtm-container-templates Template: shopify-ga4-ecommerce/container.json — import it into a fresh GTM workspace and you’re 80% of the way to a working GA4 ecommerce setup.

Why most Shopify GA4 setups are broken

We see the same three failure modes on almost every Shopify GTM audit:

  1. Purchase event = 0 in GA4. The storefront has GTM. The checkout doesn’t. Nobody ever wired up Customer Events / Web Pixels, so the most important event in the entire funnel never fires.
  2. Revenue inflated by 2–4x. Customer Events fires purchase on the order-status page. The customer refreshes (or hits back, or shares the URL with their bookkeeper) and purchase fires again. GA4 has weak built-in dedup that only works within a session — across sessions it counts both.
  3. No refunds, ever. Shopify doesn’t fire a client-side refund event. If you don’t have a server-side hook pushing one, GA4 will show $100K in revenue against a store that actually has $80K after refunds.

The container we’re shipping fixes the first two completely and gives you a clear path for the third.

What’s inside

13 tags
├── 1 Google Tag (GA4 configuration)
├── 1 Conversion Linker (captures gclid, fbclid, etc.)
└── 11 GA4 Event tags
    ├── view_item_list
    ├── view_item
    ├── select_item
    ├── add_to_cart
    ├── remove_from_cart
    ├── view_cart
    ├── begin_checkout
    ├── add_shipping_info
    ├── add_payment_info
    ├── purchase (dedup-protected)
    └── refund

12 triggers
├── 10 Custom Event triggers (one per ecomm event)
├── 1 Initialization trigger
└── 1 Exception trigger (Duplicate Transaction)

9 user-defined variables
├── 1 Constant — GA4 Measurement ID (the one thing you have to change)
├── 7 DataLayer Variables (transaction_id, value, currency, items, coupon, shipping, tax)
└── 1 Custom JavaScript — isDuplicateTransaction

Every tag uses GA4’s getEcommerceDataFrom: dataLayer setting, which means as long as your storefront pushes the standard GA4 ecommerce object structure, the tags work without per-tag wiring.

The deduplication trick

This is the part most agencies get wrong (or skip entirely). Here’s the logic in one Custom JavaScript variable:

function() {
  var tid = {{DLVecommerce.transaction_id}};
  if (!tid) return false;
  var KEY = 'aumlytics_seen_tids';
  var MAX = 50;
  try {
    var raw = window.localStorage.getItem(KEY) || '';
    var seen = raw ? raw.split(',') : [];
    if (seen.indexOf(String(tid)) !== -1) return 'true';
    seen.push(String(tid));
    if (seen.length > MAX) seen = seen.slice(-MAX);
    window.localStorage.setItem(KEY, seen.join(','));
    return 'false';
  } catch (e) {
    return 'false';   // fail-open: never drop a real purchase
  }
}

Pair it with an exception trigger that fires when the variable returns 'true', and add that trigger as a blocking trigger on the GA4 purchase tag. Now:

  • First time a transaction_id arrives: purchase fires, ID is recorded
  • Every subsequent time that same ID arrives (refreshes, back button, returning to the order-status URL from email): purchase tag is blocked
  • localStorage rolls off the oldest IDs once you hit 50, so it doesn’t grow unbounded
  • If localStorage is blocked (private browsing, quota exhaustion), the dedup fails open — a possible duplicate is far less bad than a missing real purchase

This is genuinely the simplest pattern that’s reliable across all the edge cases. We tried Once Per Page (still fires on hard reload), GA4 internal dedup (only same-session), and cookie-based (wasteful on every request). localStorage wins.

What you need to install it

Three things have to be in place. The container takes care of the GTM side; the other two are existing Liquid snippets from our other free repo:

1. The container

Import container.json into a new GTM workspace. Don’t import into your live workspace — pick “Create new workspace” in the import dialog. Use Merge → Rename conflicting so nothing existing gets clobbered.

2. The storefront dataLayer pushes

From github.com/aumlytics/shopify-liquid-snippets — install gtm-custom-pixel-datalayer. This pushes view_item, view_item_list, add_to_cart, view_cart, begin_checkout, etc. from your theme as the customer browses.

3. The checkout pixel

Also from the snippets repo — install ga4-custom-pixel-purchase via Shopify Admin → Settings → Customer events → Add custom pixel. This subscribes to Shopify’s Web Pixels API events (checkout_completed, payment_info_submitted, etc.) and pushes them into the dataLayer that the container is listening on.

Total setup time once you’ve imported everything: 15–20 minutes for someone who knows GTM.

The refund problem (and three ways to solve it)

The container has the GA4 — refund tag built and ready, but there’s no event source for it. Shopify won’t fire it for you. Pick one:

  • Webhook to server-side GTM — clean, scales, but needs sGTM infrastructure. (We’re building a free sGTM starter container next.)
  • Manual gtag('event', 'refund', {...}) from a small admin script — fine if you do refunds occasionally
  • Use a third-party app like Elevar or UpTag — paid, but turnkey

Whichever path, the GTM side is already wired — you only have to send the event.

Test before you publish

We ship a full TESTING-BEFORE-PUBLISH.md checklist with the template. Run through it. Specifically:

  • Refresh the thank-you page 3 times after a test purchase. The GA4 — purchase tag should show Not fired — blocked by Exception — Duplicate Transaction in GTM Preview from the 2nd refresh onward.
  • Open localStorage.getItem('aumlytics_seen_tids') in the browser console. You should see the transaction_id you just completed.
  • In GA4 DebugView, the purchase event should appear once, with value, currency, transaction_id, and a populated items[] array including item_id, item_name, price, and quantity.

If any of those fails, walk back through docs/troubleshooting.md — most issues come down to either a missing dataLayer push or the Measurement ID variable still being set to the placeholder.

What this isn’t (yet)

We deliberately kept v1 scoped to GA4 ecommerce only. The container doesn’t include:

  • Consent Mode v2 gating. The tags fire unconditionally. For EEA, wait for our upcoming gtm-consent-mode-v2-starter template or add consent gating manually before publishing.
  • Google Ads conversion tracking. Separate bundle coming — gtm-google-ads-enhanced-conversions.
  • Meta Pixel or CAPI. Same — separate gtm-meta-pixel-capi bundle planned.
  • Shopify Plus checkout extensibility. Standard Shopify only for now.

Bundling everything into one container would have made it harder to import cleanly into an existing setup and a lot harder to maintain. Better to ship one focused thing that works.

Get it

If you import it and something breaks in your specific setup, open an issue. We read them.

#shopify#gtm#ga4#gtm-container#ecommerce-tracking#transaction-id-dedup#free-tools#shopify-ga4#customer-events

Share this article

Want This Implemented Correctly?

Let our team apply these concepts to your specific setup — with QA validation and 30 days of support.