If you've inherited a Shopify store, you've probably met this ticket: "Google Ads says 82 conversions, Shopify says 100 orders, and last week it was 140 conversions. Fix tracking."
The cause is almost never one dramatic bug. It's usually two or three small ones: a purchase counted by two implementations, a transaction ID that doesn't match between browser and server, or a consent default that fires too late. This post walks through the pipeline from checkout to Google Ads, with the places each of those goes wrong and code for the parts you'll actually write.
The pipeline, and where each piece can fail
Shopify checkout
└─ checkout_completed (customer event)
├─ Web Pixel (custom pixel, sandboxed)
│ └─ dataLayer → GTM → GA4 tag / Google Ads tag
└─ orders/paid webhook (optional server path)
└─ GA4 Measurement Protocol / Google Ads API / sGTM
There are three common ways purchases reach Google today:
- Shopify's Google & YouTube channel. No code. It installs its own pixel and sends events for you.
- A custom pixel (Web Pixels API) feeding GTM. This is where you control the event payload.
- A server path triggered by an order webhook, which doesn't depend on the customer's browser.
Any of these works on its own. The trouble starts when two of them are live and both send purchase.
Audit before you add anything
Before writing code, list every source that can send a purchase for the store:
- Settings → Customer events in Shopify (all installed pixels, including ones added by apps)
- The Google & YouTube channel's connected conversion actions
- Google Ads → Goals → Conversions → Summary. Look at the Source column for each action, and check whether more than one action tracks purchases
- Any GTM containers, and any other tracking apps
Two settings in Google Ads cause a lot of silent damage here:
- Primary vs. secondary. Only primary actions marked "Include in conversions" feed bidding. If add-to-cart and begin-checkout are primary alongside purchase, one visit generates several "conversions", and your optimization signal fills up with events that aren't revenue.
- Counting. For purchases you generally want "Every". "One" is for leads, where several submits from one click shouldn't each count.
Custom pixel to GTM: building a clean purchase event
Custom pixels run in a sandbox, not in your theme's page. That means no access to the theme's window, its dataLayer, or its DOM. You subscribe to Shopify's standardized events and forward what you need. (Verify the exact sandbox behavior and event schema against Shopify's current Web Pixels docs, as they've changed over time.)
A minimal version that pushes a GA4-shaped purchase to GTM:
// Customer events → Add custom pixel
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
// 1. Consent defaults must be set BEFORE GTM loads
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
wait_for_update: 500
});
// 2. Load GTM
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});
var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';
j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXX');
// 3. Forward the purchase
analytics.subscribe('checkout_completed', (event) => {
const c = event.data.checkout;
dataLayer.push({ ecommerce: null }); // clear previous ecommerce object
dataLayer.push({
event: 'purchase',
ecommerce: {
transaction_id: String(c.order?.id ?? c.token),
value: Number(c.totalPrice.amount),
currency: c.currencyCode,
items: c.lineItems.map((li) => ({
item_id: li.variant?.sku || String(li.variant?.id),
item_name: li.title,
price: Number(li.variant?.price?.amount),
quantity: li.quantity
}))
}
});
});
A few notes on that snippet:
-
Pick one ID and keep it everywhere.
transaction_idis what Google Ads and GA4 use to avoid counting the same order twice. If the browser sends the numeric order ID and your server sends the order name (#1042), the two paths look like two different orders. Decide once, then use the same value in every path. -
Check what
valueincludes.totalPriceincludes tax and shipping. Many stores want revenue excluding shipping, or excluding tax. Whatever you choose, make sure it's the same number you'll compare against in Shopify reports, and that every platform gets that number. -
Clear
ecommercebefore each push. Otherwise GTM's data model can merge values from earlier pushes into the new event. - Currency matters in multi-currency stores. Check whether you're sending presentment currency or shop currency, and keep it consistent.
What the destinations need
| Field | GA4 purchase
|
Google Ads conversion |
|---|---|---|
| Transaction ID |
transaction_id (used for dedup) |
transaction_id / order ID (used for dedup per conversion action) |
| Value | value |
value |
| Currency | currency |
currency |
| Items | items[] |
not required |
| Click identity |
client_id / session |
gclid / gbraid / wbraid (from cookies, or user data via enhanced conversions) |
Enhanced conversions: hash it correctly
Enhanced conversions improve click matching by sending hashed first-party data with the conversion. If the Google tag hashes for you, great. If you hash yourself, normalization is where mistakes happen: a SHA-256 of " Jane@Gmail.com " won't match the hash of jane@gmail.com.
async function sha256(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
function normalizeEmail(email) {
let e = email.trim().toLowerCase();
const [local, domain] = e.split('@');
if (domain === 'gmail.com' || domain === 'googlemail.com') {
return local.replace(/\./g, '') + '@' + domain; // Google strips dots for Gmail
}
return e;
}
function normalizePhoneE164(phone, defaultCountryCode = '91') {
const digits = phone.replace(/[^\d+]/g, '');
if (digits.startsWith('+')) return digits;
return '+' + defaultCountryCode + digits.replace(/^0+/, '');
}
Hash after normalizing, send as lowercase hex, and only send what you have consent to send. Check Google's current enhanced conversions documentation for the exact field list and normalization rules before shipping.
Consent Mode v2: ordering is the bug
The most common consent bug is a timing one. The default consent state has to be set before any Google tag runs. If GTM loads first and your consent banner calls gtag('consent', 'update', ...) afterward, early events can fire with the wrong state.
The sequence that works:
- Set
consent default(denied) - Load GTM
- When the visitor chooses, call
consent update - Fire events
In a custom pixel you can read Shopify's consent state (the Customer Privacy API is exposed to pixels; see the docs for the current init.customerPrivacy shape) and map it to ad_storage, ad_user_data, ad_personalization and analytics_storage.
With Consent Mode on, denied visitors can still send cookieless pings, and Google may model some of the missing conversions. That is one more reason Shopify and Google Ads will never match exactly.
The server-side path: order webhooks
Browser tracking depends on the customer's device. The order will exist regardless of whether the pixel ran. A webhook-driven path covers that gap:
// Node + Express (sketch)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/webhooks/orders-paid', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. Verify the HMAC on the RAW body
const header = req.get('X-Shopify-Hmac-Sha256') || '';
const digest = crypto.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
.update(req.body).digest('base64');
const a = Buffer.from(digest), b = Buffer.from(header);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
res.sendStatus(200); // ack fast, process after
const order = JSON.parse(req.body.toString('utf8'));
// 2. Idempotency: webhooks can be delivered more than once
if (await alreadyProcessed(order.id)) return;
// 3. Pull identity captured earlier in the browser
const attrs = Object.fromEntries((order.note_attributes || []).map(a => [a.name, a.value]));
const clientId = attrs.ga_client_id; // e.g. "1234567890.1700000000"
if (!clientId) return; // log this: it's a stitching gap
// 4. Send to GA4 Measurement Protocol
await fetch(
`https://www.google-analytics.com/mp/collect?measurement_id=${GA_ID}&api_secret=${GA_SECRET}`,
{
method: 'POST',
body: JSON.stringify({
client_id: clientId,
events: [{
name: 'purchase',
params: {
transaction_id: String(order.id),
value: Number(order.total_price),
currency: order.currency,
items: order.line_items.map(li => ({
item_id: li.sku || String(li.variant_id),
item_name: li.title,
price: Number(li.price),
quantity: li.quantity
}))
}
}]
})
}
);
});
The hard part isn't the webhook. It's identity stitching. The server doesn't know the visitor's GA client_id or their gclid unless you captured them in the browser and attached them to the cart:
// storefront theme or app embed: run on page load
const ga = document.cookie.match(/(?:^|; )_ga=GA\d\.\d\.(\d+\.\d+)/)?.[1];
const gclid = new URLSearchParams(location.search).get('gclid');
if (ga || gclid) {
fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attributes: { ga_client_id: ga, gclid } })
});
}
Cart attributes come through as note_attributes on the order. For the Google Ads side, you can upload click conversions with the stored gclid through the Google Ads API (using the order ID so it dedupes) or route everything through a server-side GTM container.
If both browser and server send purchases, the dedup key is the shared transaction_id. Test it. Send both, then confirm one conversion appears, not two. Don't assume.
Debugging checklist
-
GTM Preview shows whether the tag fired, not whether the payload was right. Open the
purchaseevent and read the data layer. -
GA4 DebugView (add
debug_mode: trueto events) shows what GA4 actually received. -
Measurement Protocol validation: send test payloads to
https://www.google-analytics.com/debug/mp/collectfirst. It returns validation messages the real endpoint won't. - Google Ads conversion action → Diagnostics shows whether tags are recording and flags enhanced-conversion issues.
-
Network tab: filter for
collect,googleadservicesor/pagead/, and check the request parameters, not just the status code. - Compare one real order across every system by transaction ID, rather than comparing totals. A 17-order gap is easier to solve as 17 rows than as a percentage.
Why Shopify and Google Ads won't match
Even with a clean setup, they count different things. Shopify counts orders. Google Ads counts conversions tied to ad interactions, under its own attribution windows and click-matching rules, with modeled conversions filling some consent gaps. The numbers should be explainable, not equal. A stable ratio is fine. A sudden change in the ratio means something broke.
A tool for the multi-platform case
Everything above works for one destination. Once you also have Meta, TikTok and Pinterest, you're maintaining the same event contract and the same deduplication logic several times over.
That's the case Webgarh GTM Assistant is built for. It's a Shopify app that handles client-side and server-side tracking through GTM. It supports GA4, Google Ads, Meta, TikTok, Pinterest and more, with event deduplication, Consent Mode v2 and tracking diagnostics included. If you'd rather not own the plumbing for every platform, try it on a dev store and run the diagnostics against a test order. Then compare what it sends to what your own pixel sends.
Disclosure: [add your relationship to Webgarh here, e.g. "I work at Webgarh"].
Top comments (0)