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:
- 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.
- Revenue inflated by 2–4x. Customer Events fires
purchaseon the order-status page. The customer refreshes (or hits back, or shares the URL with their bookkeeper) andpurchasefires again. GA4 has weak built-in dedup that only works within a session — across sessions it counts both. - 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 = {{DLV — ecommerce.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_idarrives: 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 — purchasetag 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 populateditems[]array includingitem_id,item_name,price, andquantity.
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-startertemplate 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-capibundle 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
- GitHub: github.com/aumlytics/gtm-container-templates
- Companion Liquid snippets: github.com/aumlytics/shopify-liquid-snippets
- MIT licensed. Fork it, use it in client work, embed it in a course, build a SaaS on top — we just ask for a star if it saves you a few hours.
If you import it and something breaks in your specific setup, open an issue. We read them.
Share this article