Skip to content
Home Guides Analytics
Analytics Intermediate

How to Fix Tracking Discrepancies Between Google Analytics and Shopify

Step-by-step guide to find and resolve data mismatches between Google Analytics 4 and Shopify reports: causes, order-level reconciliation, one purchase path in the browser, a server path through webhooks and the Measurement Protocol, GA4 settings and testing.

Fabian Weiss, founder of FW Delta Fabian Weiss
8 min 2 to 4 hours
The Problem

Revenue and conversion data doesn't match between GA4 and the Shopify backend and nobody can say which part of the gap is normal

The Fix

Measure the gap at order level, run exactly one purchase path in the browser, replay purchases and refunds through webhooks and the Measurement Protocol and check the GA4 settings that change the numbers

Google Analytics 4ShopifyGoogle Tag Manager

Why Your Numbers Don’t Match

If Google Analytics 4 (GA4) and your Shopify sales report show different numbers, that is not a bug in itself. The two systems measure different things with different mechanisms. Shopify’s own analytics discrepancies help page lists browser extensions that block Google Analytics, different reporting time zones, the counting of page reloads and the fact that Google can only count visitors with JavaScript and cookies enabled.

So a residual gap always remains. The goal of this guide is not an exact match but a gap you can explain. Our Analytics team keeps seeing the same patterns in Shopify setups. Here are the causes you need to know in September 2026.

Common Symptoms

  • GA4 shows lower revenue than the Shopify backend
  • Orders appear in Shopify but not in GA4
  • GA4 counts single purchases twice or three times
  • Sessions and conversion rate differ between the two systems
  • GA4 reports orders Shopify does not know, such as test orders

The Causes in September 2026

  1. Consent. Shopify applies visitor consent to pixels. In regions where you require consent, Shopify states that non-essential purposes are not allowed by default until consent is given (Customer Privacy API). Whoever declines shows up in Shopify as an order but not in GA4.
  2. Ad blockers, Safari ITP and other browser restrictions. Shopify explicitly names browser extensions as a reason why sessions and purchases are missing in Google Analytics (analytics discrepancies). The share depends on your audience and is often noticeable with a technical customer base.
  3. Sandboxed pixels. According to Shopify, app pixels run in a strict sandbox based on web workers and custom pixels in a lax sandbox, an iframe with the sandbox attribute. Anything that reads or writes the DOM does not work there or does not work the same way (About web pixels). Old tracking snippets copied into a custom pixel therefore often fire silently into nothing.
  4. The thank-you page never loads. Shopify states that checkout_completed fires once per checkout, typically on the thank-you page. With post-purchase upsells it fires on the first upsell page instead. If the page fails to load, the event does not fire (checkout_completed). The order still exists in Shopify.
  5. Additional scripts and script tags are history. Since 28 August 2025 the additional scripts section in the checkout settings is view-only and 26 August 2026 was the deadline for non-Plus stores to upgrade their thank-you and order status pages to the new version (checkout upgrade). Script tags are deprecated as well: from 1 October 2026 they can no longer be created or updated, from 1 March 2027 they are no longer injected (Shopify changelog). Purchase tracking that still relies on checkout.liquid, additional scripts or script tags sends nothing today.
  6. Double counting. If the Google & YouTube app, a custom pixel and a GTM container all report the same purchase, GA4 counts it several times. Google explicitly warns against running duplicate tags between the app and the storefront or a custom pixel (Set up your Google tag on Shopify).
  7. Different revenue definitions. Shopify defines total sales as gross sales minus discounts minus sales reversals plus taxes plus duties plus shipping charges plus fees; net sales is gross sales minus discounts minus sales reversals (sales reports). GA4 only knows the value your pixel sends. Since 30 April 2025, checkout.subtotalPrice.amount in web pixels also includes both product-level and order-level discounts, where before it only included product-level discounts (Shopify changelog). A pixel that subtracts the order discount itself has been under-reporting ever since.
  8. Refunds and cancellations. Shopify books sales reversals on the day they were processed, not on the order date. GA4 only learns about them if you send a refund event with the same transaction_id (ecommerce events in GA4).
  9. Currency and taxes. GA4 converts purchases into the property currency, according to Google based on the exchange rate one day before the transaction (currency reference). A store with several presentment currencies deviates structurally because of this. Whether your value includes taxes and shipping decides which Shopify metric is comparable at all.
  10. Time zones. Shopify reports in the store’s time zone, GA4 in the property’s time zone. According to Google, changing the property time zone only affects data going forward and produces a flat spot or a spike in the data (set up a property).
  11. Sessions. From 21 to 23 September 2026 Shopify changed its session definition: a session now ends after 30 minutes of inactivity instead of at midnight UTC and sessions without a page view are counted. Conversion rate is calculated from sessions and therefore shifts even if orders stay the same (changes to sessions in Shopify Analytics). GA4 also uses 30 minutes as the default, adjustable up to 7 hours 55 minutes (sessions in GA4). They are still not equal: bots, reloads and consent are handled differently.
  12. Attribution windows. GA4 attributes key events with a lookback window of 90 days by default, adjustable to 30 or 60 days; acquisition events use 30 or 7 days (attribution settings). Shopify assigns revenue to the order date. Channel reports from the two systems are therefore never congruent.
  13. More page views since July 2025. Since 21 July 2025 web pixels also load on customer accounts and the order status page and send page_viewed there (Shopify changelog). Anyone comparing page views per order has seen more post-purchase views since then.
  14. Test orders. According to Shopify, test orders through the test gateway or Shopify Payments test mode do not display in payouts or reports (test orders). Pixels fire anyway. GA4 therefore counts purchases Shopify never reports.
  15. GA4-side processing. Thresholds, reporting identity, data retention and hostname filters change what you see in GA4 at all. More on that in step 4 below.

If you compare your setup with professional analytics implementations, one thing stands out: they run exactly one purchase path in the browser and an independent server path for reconciliation. That is exactly what we build now.


Step 1: Measure the Gap Properly

Before you fix anything, you need a number that means something.

Same Definition, Same Period, Same Time Zone

  • Period: A completed period that ended at least one day ago. Shopify names later processing by Google as a possible reason for differences.
  • Time zone: Check that the store time zone and the GA4 property time zone match. If not, compare whole weeks instead of single days.
  • Metric: Decide what your value contains. If it includes taxes and shipping, compare it with Shopify total sales. If it only contains item prices after discounts, compare it with net sales. In GA4 you find purchase revenue under Reports, Monetization, Ecommerce purchases (Ecommerce purchases report); according to Google the item revenue there excludes tax and shipping.
Gap in percent = (Shopify value - GA4 value) / Shopify value × 100

Compare at Order Level

The percentage tells you that something is missing. It does not tell you what. So export the order numbers from Shopify for the period and set them against a GA4 exploration of the Transaction ID dimension. Three lists emerge:

  • orders present in both systems: the shared core
  • orders only in Shopify: missing purchases (consent, blockers, thank-you page not loaded)
  • transaction IDs only in GA4 or repeated in GA4: test orders or double counting

Only this split tells you which of the following steps you really need. You will find a detailed checklist for the whole setup in the Shopify tracking checklist.


Step 2: Exactly One Purchase Event in the Browser

Google & YouTube App as the Default Path

Shopify connects GA4 today through the Google & YouTube app (set up Google Analytics 4). The app sends the ecommerce events including purchase itself and respects the store’s customer privacy settings. For most stores this is the right default path because it needs no theme code and Shopify maintains it.

Then verify that this is really the only path:

  1. Shopify admin, Settings, Customer events: List all app pixels and custom pixels. Each one that sends purchase to the same measurement ID is a candidate for double counting.
  2. Online Store, Themes, Edit code: Search theme files for gtag, googletagmanager and G-. A Google tag in the theme plus the app means two page views per page.
  3. Google Tag Manager: Filter for GA4 tags and check whether a purchase tag listens to a thank-you page event. Export the container as a backup before you delete anything.

Google recommends setting up new conversions as primary conversions and removing legacy tags instead of running both in parallel (Set up your Google tag on Shopify).

Custom Pixel If You Send the Purchase Yourself

If you deliberately run GA4 through a custom pixel instead of the app, for example because you need your own parameters, a clean purchase event looks like this. The pixel loads gtag.js inside the sandbox and listens to checkout_completed:

const script = document.createElement("script");
script.setAttribute("src", "https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX");
script.setAttribute("async", "");
document.head.appendChild(script);

window.dataLayer = window.dataLayer || [];
function gtag() {
  dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-XXXXXXXXXX", { send_page_view: false });

analytics.subscribe("checkout_completed", (event) => {
  const checkout = event.data.checkout;
  gtag("event", "purchase", {
    transaction_id: checkout.order.id,
    value: Number(checkout.totalPrice.amount),
    currency: checkout.currencyCode,
    tax: Number(checkout.totalTax.amount),
    shipping: checkout.shippingLine ? Number(checkout.shippingLine.price.amount) : 0,
    items: checkout.lineItems.map((line) => ({
      item_id: line.variant.product.id,
      item_name: line.title,
      item_variant: line.variant.title,
      price: Number(line.variant.price.amount),
      quantity: line.quantity
    }))
  });
});

Three things about it matter:

  • transaction_id is the order ID from checkout.order.id. Google names transaction_id as the means against duplicate purchase events (GA4 events reference). The same value must later appear in the server path.
  • value is totalPrice, according to Shopify the sum of all prices including duties, taxes and discounts. If you send subtotalPrice instead, remember the change of 30 April 2025: the value is already net of all discounts.
  • currency comes from checkout.currencyCode, not from a fixed constant. Without a currency, Google says GA4 cannot compute revenue metrics accurately.

Step 3: Server Path Through Webhook and Measurement Protocol

What the Server Path Can and Cannot Do

According to Google, the Measurement Protocol is meant to augment collection through gtag or Tag Manager, not to replace it. Anyone who sends only server-side gets partial reporting. GA4 takes geographic and device data from the most recent browser interaction of the same client_id; if an event should belong to a specific session, you need the session_id and must send within 24 hours of the session start (Measurement Protocol overview).

So the server path is not a replacement for the pixel. It is the path that bypasses consent, blockers and thank-you pages that never load and the path you need for refunds anyway. Two rules:

  • Google states that the api_secret is private and does not belong in client code (sending events). The earlier approach of calling the Measurement Protocol directly from the order status page is therefore doubly dead: it leaks the secret and the scripts no longer run there since August 2026 anyway.
  • Send purchases into the reports either from the browser or from the server, not both at the same time with different client_id values. Do not rely on GA4 merging two purchases with the same transaction_id from two different clients.

A proven pattern: the browser sends the purchase when it is allowed to. The server sends all refunds and the purchase only for orders for which no browser purchase arrived within a set time. Anyone who does not want to build that check uses the server path only for refunds and order reconciliation.

Attach the Client ID to the Order

For a server event to belong to the right user, it needs the same client_id as the browser tag. The way: in the theme, the value from the _ga cookie is stored as a cart attribute, which Shopify attaches to the order as a note attribute (Ajax Cart API). This snippet belongs in theme.liquid inside a script element before the closing body tag:

(function () {
  const match = document.cookie.match(/_ga=GA\d\.\d\.([^;]+)/);
  if (!match || sessionStorage.getItem("gaClientIdStored")) {
    return;
  }
  fetch("/cart/update.js", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ attributes: { ga_client_id: match[1] } })
  }).then(() => sessionStorage.setItem("gaClientIdStored", "1"));
})();

This only works if the GA4 tag was allowed to set a cookie. Visitors without consent have no client_id and that is intended: the server sends no purchase into the reports for them.

Receive the Webhook and Send the Purchase

In the Shopify admin under Settings, Notifications, Webhooks create a webhook for the Order payment event (webhooks in Shopify) or subscribe to orders/paid through your app. Every delivery carries the header X-Shopify-Hmac-Sha256, a Base64-encoded HMAC signature you use to verify that the request came from Shopify (webhooks). The endpoint must not be on localhost or on a domain of the store.

A minimal receiver in Node that forwards the purchase to the Measurement Protocol (Measurement Protocol reference):

import crypto from "node:crypto";
import express from "express";

const app = express();
const webhookSecret = process.env.SHOPIFY_WEBHOOK_SECRET;
const measurementId = process.env.GA4_MEASUREMENT_ID;
const apiSecret = process.env.GA4_API_SECRET;
const endpoint = `https://www.google-analytics.com/mp/collect?measurement_id=${measurementId}&api_secret=${apiSecret}`;

function verify(req) {
  const digest = crypto.createHmac("sha256", webhookSecret).update(req.body).digest("base64");
  const header = req.get("X-Shopify-Hmac-Sha256") || "";
  return digest.length === header.length && crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(header));
}

function clientIdFromOrder(order) {
  const attribute = (order.note_attributes || []).find((entry) => entry.name === "ga_client_id");
  return attribute ? attribute.value : null;
}

app.post("/webhooks/orders-paid", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);

  const order = JSON.parse(req.body.toString("utf8"));
  const clientId = clientIdFromOrder(order);
  if (!clientId || order.test) {
    return;
  }

  await storeClientId(order.id, clientId);

  const payload = {
    client_id: clientId,
    events: [{
      name: "purchase",
      params: {
        transaction_id: String(order.id),
        value: Number(order.current_total_price),
        currency: order.currency,
        tax: Number(order.current_total_tax),
        shipping: order.shipping_lines.reduce((sum, line) => sum + Number(line.price), 0),
        items: order.line_items.map((item) => ({
          item_id: String(item.product_id),
          item_name: item.title,
          item_variant: item.variant_title,
          price: Number(item.price),
          quantity: item.quantity
        }))
      }
    }]
  };

  await fetch(endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
});

storeClientId is your own store, for example a table with order ID and client_id. You will need it for refunds in a moment. The fields id, current_total_price, current_total_tax, currency, note_attributes, shipping_lines and line_items are documented in the Order resource; current_total_price is the current total price in the shop currency. For EU data collection Google additionally names the endpoint https://region1.google-analytics.com/mp/collect.

If the server purchase should only run as a fallback, do not send it immediately. Wait a set time and send it only if the transaction ID has not shown up through the GA4 Data API by then. Anyone who does not want to build that limits the server path to refunds and order reconciliation.

Replay Refunds

For the refunds/create webhook you send a refund event with the same transaction_id. Google requires currency, transaction_id and value and recommends item data for item-level metrics (GA4 events reference). The fields order_id, transactions and refund_line_items are in the Refund resource:

app.post("/webhooks/refunds-create", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(req)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200);

  const refund = JSON.parse(req.body.toString("utf8"));
  const clientId = await loadClientId(refund.order_id);
  if (!clientId) {
    return;
  }

  const successful = refund.transactions.filter((entry) => entry.kind === "refund" && entry.status === "success");
  if (successful.length === 0) {
    return;
  }

  const payload = {
    client_id: clientId,
    events: [{
      name: "refund",
      params: {
        transaction_id: String(refund.order_id),
        value: successful.reduce((sum, entry) => sum + Number(entry.amount), 0),
        currency: successful[0].currency,
        items: refund.refund_line_items.map((entry) => ({
          item_id: String(entry.line_item.product_id),
          item_name: entry.line_item.title,
          quantity: entry.quantity
        }))
      }
    }]
  };

  await fetch(endpoint, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload)
  });
});

Cancellations without payment create no refund. If your store has many of them, also subscribe to orders/cancelled and treat them like a refund of the full amount.

Validate the Payload

According to Google, the Measurement Protocol returns a 2xx status code as soon as the HTTP request is received. It returns no error code if the payload is malformed or if GA4 does not process the data (Measurement Protocol reference). A green status code therefore proves nothing. Google provides a validation endpoint for this that accepts the same payload and returns validationMessages (validating events):

curl -X POST "https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=YOUR_API_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"client_id":"123.456","events":[{"name":"purchase","params":{"transaction_id":"TEST-1","value":10,"currency":"EUR"}}]}'

An empty validationMessages list means the payload is formally valid. Check every change to the server path with it before it goes live. Also note the limits from the reference: up to 25 events per request, up to 25 parameters per event, backdating through timestamp_micros up to 72 hours.


Step 4: GA4 Settings That Change the Numbers

These settings create differences that have nothing to do with Shopify. Check them under Admin:

  • Thresholds. GA4 withholds data when a report could allow inferences about individual users, for example with demographic data or small date ranges. The report then shows the notice that thresholding has been applied (data thresholds). A larger date range can make the data visible again.
  • Reporting identity. The options Blended, Observed and Device-based decide how GA4 merges users. Blended and Observed use the User-ID when available, Device-based ignores all other IDs (reporting identity). For reconciliation with Shopify, Device-based is the setting whose numbers depend least on modeling.
  • Data retention. The setting for event data (2 or 14 months, longer for 360) affects according to Google only explorations and funnel reports, not the aggregated standard reports (data retention). The order reconciliation from step 1 runs through an exploration, so it only works within this window.
  • Hostname filters. Since 11 June 2026 you can exclude events from foreign hostnames with a data filter and since 21 September 2026 you can also maintain an allowlist of permitted hostnames. Include filters automatically block events without a hostname and, according to Google, do not apply to Measurement Protocol events (what’s new in Google Analytics). Filtered data is gone permanently, so test the filter in testing mode first (data filters). Anyone with purchases from a staging domain or spam hits in their revenue solves it here.
  • Consent and Google Ads. Since 15 June 2026 Consent Mode alone controls which advertising data flows from GA4 to a linked Google Ads account; the Google signals switch now only controls the association with signed-in users for behavioral reporting (updates to data controls). If purchases are correct in GA4 but missing in Google Ads, since then it is down to ad_storage and ad_user_data, not Google signals.
  • Session timeout, currency, time zone. See causes 9 to 11. Change currency and time zone only if you know the data will show a break from the day of the change.

Step 5: Test and Reconcile

Test Order

  1. Place a test order through the test gateway or Shopify Payments test mode (test orders). Note the order ID.
  2. Enable DebugView by using Tag Assistant or by setting debug_mode in the Google tag (DebugView). A URL parameter alone is not enough.
  3. Check in DebugView that exactly one purchase arrives with transaction_id, value, currency and items.
  4. Check in the webhook log that the server saw the same order ID and skipped it in the test case.
  5. The Realtime report shows the event shortly afterwards. The standard reports take longer, so compare them only on the next day.

Test orders do not appear in Shopify reports, but they do in GA4. Exclude them through a developer traffic filter or mark them with your own parameter so they do not distort your reconciliation.

Scenarios

  • Guest checkout without an account
  • Logged-in customer with a customer account
  • Several items and several quantities
  • Discount code at order level and discount at item level
  • Shipping costs and free shipping
  • Different payment methods, especially those with a redirect
  • Post-purchase upsell, if active
  • Decline in the consent banner
  • Partial and full refund

Compare After Deployment

Repeat the reconciliation from step 1 for a full period after the change and set it against the period before. Do not expect zero. Expect the list of orders only in Shopify to shrink to cases you can name: declined consent, blockers, a thank-you page that never loaded. If you want to put a number on your share, the tracking coverage calculator helps.


Step 6: Ongoing Monitoring

Reconciling once is not enough. Shopify and Google change their platforms continuously, as the dates in this guide show. Set up a weekly routine:

  • Order reconciliation: Order numbers from Shopify against transaction IDs from the GA4 Data API or the BigQuery export. Missing and duplicate IDs as a list, not just as a percentage.
  • Baseline and alert: Record the gap of the first four clean weeks as a baseline. An alert is worth it when a week deviates clearly from it, not at a fixed percentage someone once wrote down.
  • Transaction count and order value: Watch both separately. A stable count with a falling value points to a value problem in the pixel, for example after a platform change like the one to subtotalPrice.
  • Webhook errors: Log responses other than 200 and validation errors from the Measurement Protocol.
  • Changelogs: Read the Shopify developer changelog and what’s new in Google Analytics once a month. What changes there shows up in your gap weeks later.

A dashboard, for example in Looker Studio, can put the two data sources side by side. The value lies in the order reconciliation behind it, not in the chart.


When DIY Isn’t Enough

If you still see a gap you cannot explain after following this guide, you likely have:

  • Complex checkout flows with upsells, subscriptions or partial payments
  • Multiple payment providers with different redirect behavior
  • Multiple presentment currencies or markets with their own tax logic
  • International customers with regionally different consent rules
  • Inherited apps and pixels nobody has documented

Professional Implementation

At FW Delta we build Shopify measurement setups with exactly one purchase path in the browser, a server path for purchases and refunds and a weekly order reconciliation. Which measurement losses can be prevented, corrected or only modeled at all is what we examined systematically in the Web Measurement Reliability & Recoverability Report 2026. What you get:

  • a gap explained at order level instead of a percentage
  • a documented mapping of Shopify metrics to GA4 values
  • a privacy-oriented infrastructure that respects consent instead of bypassing it
  • alerts when a path fails

→ Book a free consultation


Frequently Asked Questions

Why is GA4 showing lower revenue than Shopify?

Because GA4 only sees purchases where a tag was allowed to run: consent given, no blocker, thank-you page loaded. Shopify counts every paid order. The server path from step 3 closes part of the gap, but not the part that rests on declined consent.

Can I use Google Tag Manager instead of the app or a custom pixel?

Yes, but a web container also runs in the browser and is subject to the same limitations. In the checkout it has to be loaded inside a custom pixel, with all the sandbox limits. A server container can replace the webhook from step 3 as the endpoint but changes nothing about the logic.

How do I get the GA4 API Secret?

Admin, Data streams, choose your web stream, Measurement Protocol API secrets, Create. Google describes the path in the Measurement Protocol reference. The secret stays on the server.

Will this affect my historical data?

No. Everything here only improves future data. According to Google the Measurement Protocol allows backdating by at most 72 hours, so older orders cannot be replayed afterwards.

Do I need Shopify Plus?

No. Custom pixels, webhooks and the Google & YouTube app are available on all plans. The earlier differences around checkout.liquid and additional scripts have been moot since 26 August 2026 because those routes ended for all stores.

What about refunds and cancellations?

The refunds/create webhook sends a refund event with the same transaction_id. For cancellations without payment you use orders/cancelled. When reconciling, remember that Shopify books the reversal on the processing day, not on the order date.


Summary: Action Checklist

  • Measure the gap for a completed period with the same metric and time zone
  • Reconcile order numbers from Shopify against transaction IDs in GA4
  • List all purchase paths and reduce them to exactly one in the browser
  • Check the custom pixel on checkout_completed with totalPrice, currencyCode and order ID
  • Store the client_id as a cart attribute
  • Receive the orders/paid and refunds/create webhooks with HMAC verification
  • Check payloads against the validation endpoint
  • Check reporting identity, data retention, hostname filters and consent controls in GA4
  • Run test orders in all scenarios and exclude them from the reconciliation
  • Set up a weekly order reconciliation with a baseline
  • Document configuration for future team members

Time investment: 2 to 4 hours, depending on whether you run the server path yourself
Business impact: A revenue gap you can explain and refunds that arrive in GA4


Need Professional Help?

This pattern appears in most Shopify setups that have grown over time.

Our Analytics Engineering service includes:

  • Complete tracking review (Shopify, GA4, GTM, pixel inventory)
  • Server path through webhook and Measurement Protocol or GTM server container
  • Order reconciliation as a repeatable routine
  • Validation with real test orders in all scenarios

Not sure where your tracking breaks? Book a free 30-minute diagnostic call: we’ll screen-share through your setup and identify the exact issue.

Already have server-side tracking? Check out our other guides, for example the Shopify tracking checklist with 42 checks before sign-off.

Newsletter

Research for technical decisions

New reports, benchmarks and technical analyses on SaaS economics, AI engineering and owned infrastructure.

Original research Public sources No sales mail

By subscribing you receive new analyses and updates from FW Delta by email. You can withdraw your consent at any time. Further information is available in the privacy policy.