DEV Community

Cover image for Shopify + Google Ads Conversion Tracking: A Developer's Guide
Anshul Sandal
Anshul Sandal

Posted on

Shopify + Google Ads Conversion Tracking: A Developer's Guide

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
Enter fullscreen mode Exit fullscreen mode

There are three common ways purchases reach Google today:

  1. Shopify's Google & YouTube channel. No code. It installs its own pixel and sends events for you.
  2. A custom pixel (Web Pixels API) feeding GTM. This is where you control the event payload.
  3. 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
      }))
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

A few notes on that snippet:

  • Pick one ID and keep it everywhere. transaction_id is 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 value includes. totalPrice includes 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 ecommerce before 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+/, '');
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Set consent default (denied)
  2. Load GTM
  3. When the visitor chooses, call consent update
  4. 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
            }))
          }
        }]
      })
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

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 } })
  });
}
Enter fullscreen mode Exit fullscreen mode

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 purchase event and read the data layer.
  • GA4 DebugView (add debug_mode: true to events) shows what GA4 actually received.
  • Measurement Protocol validation: send test payloads to https://www.google-analytics.com/debug/mp/collect first. 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, googleadservices or /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)