How to track Shopify checkout inside the pixel sandbox

Every checkout step and the purchase from a custom pixel: page URLs, cookies, consent, custom fields and debugging.

Shopify’s checkout is where your most important events happen, and it is also where you have the least control. Since the end of checkout.liquid and Additional scripts, no tracking code runs on the checkout page itself. Everything goes through pixels running in a sandbox. This guide shows how to track every checkout step and the purchase from a custom pixel, the sandbox problems you will hit, and how to debug them.

What changed, and why it matters

Until recently, you could paste scripts in the checkout (Plus stores) or on the order status page (all stores). That’s over: every store now uses checkout extensibility, with the new thank-you and order status pages. Scripts in Additional scripts no longer run, and theme scripts never reach the checkout.

What remains:

  • Customer events, published by Shopify on every checkout step.
  • Pixels (app or custom) that subscribe to those events from a sandbox.
  • Checkout UI extensions, which can add blocks to the checkout and thank-you pages and publish their own events.
  • Server-side data (webhooks, the Admin API) for what happens after the order.

For the general model, read how Shopify Customer Events work first.

How the sandbox works on the checkout

A custom pixel runs in a hidden iframe (the “lax” sandbox). On the checkout, this has concrete consequences:

You want toOn the old checkoutIn the sandbox
Read the page URLwindow.locationevent.context.document.location
Read the orderLiquid variables or Shopify.checkoutevent.data.checkout on checkout events
Read a cookiedocument.cookieawait browser.cookie.get(name)
Track a button clickClick listenerNot possible on specific checkout elements
Read a custom fieldDOMPublish it from a checkout UI extension
Debug with GTM previewTag AssistantLimited; use DebugView and the console

The checkout events

Shopify eventGA4 eventFires when
checkout_startedbegin_checkoutThe checkout loads
checkout_contact_info_submitted(custom, optional)Email or phone is submitted
checkout_address_info_submitted(custom, optional)The address is submitted
checkout_shipping_info_submittedadd_shipping_infoA shipping method is chosen
payment_info_submittedadd_payment_infoPayment details are submitted
checkout_completedpurchaseThe order is placed
page_viewedpage_viewEach checkout page or step loads
alert_displayed(custom)An error or warning is shown to the buyer

On one-page checkout, several steps happen on the same page, so page_viewed doesn’t tell you the step. Rely on the step events instead.

Step 1: send the real page, not the sandbox

Inside the pixel, window.location is the iframe’s address. If GTM’s GA4 tag reads it, every checkout page view in GA4 shows a strange /wpm@... path. Pass the real page explicitly, and clean the checkout token while you’re at it, so GA4 doesn’t get thousands of unique checkout URLs:

const cleanUrl = (href) =>
  href.replace(/\/checkouts\/(cn\/)?[^/?#]+/, "/checkouts/$1(token)");

const pageData = (event) => {
  const doc = event.context.document;
  return {
    page_location: cleanUrl(doc.location.href),
    page_referrer: doc.referrer,
    page_title: doc.title
  };
};

analytics.subscribe("page_viewed", (event) => {
  window.dataLayer.push({ event: "page_view", ...pageData(event) });
});

In GTM, set page_location, page_referrer and page_title on the Google tag and on every GA4 event tag from Data Layer Variables.

Step 2: track each checkout step

Every checkout event carries the full checkout object: line items, currency, totals, discounts and shipping. One helper builds the GA4 payload for all steps:

const num = (m) => (m && m.amount != null ? Number(m.amount) : undefined);

const checkoutData = (c) => ({
  currency: c.currencyCode,
  value: num(c.totalPrice),
  items: c.lineItems.map((li) => ({
    item_id: li.variant?.sku || li.variant?.product?.id,
    item_name: li.variant?.product?.title,
    item_variant: li.variant?.title,
    price: num(li.variant?.price),
    quantity: li.quantity
  }))
});

const step = (shopifyEvent, ga4Event) =>
  analytics.subscribe(shopifyEvent, (event) => {
    window.dataLayer.push({ ecommerce: null });
    window.dataLayer.push({
      event: ga4Event,
      event_id: event.id,
      ...pageData(event),
      ecommerce: checkoutData(event.data.checkout)
    });
  });

step("checkout_started", "begin_checkout");
step("checkout_contact_info_submitted", "checkout_contact");
step("checkout_address_info_submitted", "checkout_address");
step("checkout_shipping_info_submitted", "add_shipping_info");
step("payment_info_submitted", "add_payment_info");

The two custom steps are optional, but they make the checkout funnel in GA4 explorations much more useful: you see exactly where buyers drop.

Step 3: the purchase

analytics.subscribe("checkout_completed", (event) => {
  const c = event.data.checkout;
  window.dataLayer.push({ ecommerce: null });
  window.dataLayer.push({
    event: "purchase",
    event_id: event.id,
    ...pageData(event),
    customer_type: c.order?.customer?.isFirstOrder ? "new" : "returning",
    payment_type: (c.transactions || []).map((t) => t.gateway).join(",") || undefined,
    ecommerce: {
      ...checkoutData(c),
      transaction_id: c.order?.id,
      tax: num(c.totalTax),
      shipping: num(c.shippingLine?.price)
    }
  });
});

Three things to know about checkout_completed:

  • It fires once per order, on the thank-you page. Reloading the page or coming back from the order status email doesn’t fire it again. If GA4 still shows duplicates, another source is sending purchases. See duplicate purchase events.
  • With a post-purchase upsell, it fires on the first upsell page instead. The accepted upsell is a change to the order made afterwards, and it is not in this event’s value.
  • It needs the pixel to be allowed. If the pixel requires consent and the buyer refused, there is no purchase event, which is expected.

Step 4: keep the same visitor from store to checkout

GA4 recognizes a visitor through the _ga cookie. If the theme and the checkout don’t use the same client ID, every purchase looks like it came from a new visitor with no source, and your channel reports fill up with “direct” and “unassigned”.

Check it: in the console on a product page, read _ga; then on the thank-you page, compare with the client ID in the GA4 request (cid parameter in the Network tab). They should match. If you need the value inside the pixel, read it with the cookie API:

analytics.subscribe("checkout_started", async (event) => {
  const ga = await browser.cookie.get("_ga");
  window.dataLayer.push({ event: "ga_cookie", ga_cookie: ga });
});

The simplest way to avoid the problem is to load GA4 from one place only: the custom pixel, on every page, theme included.

Step 5: consent on the checkout

The visitor’s choice from your cookie banner is stored by Shopify and applies on the checkout too, as long as your banner uses the Customer Privacy API. In the pixel, read it on load and listen for changes:

const consentState = (p) => ({
  analytics_storage: p.analyticsProcessingAllowed ? "granted" : "denied",
  ad_storage: p.marketingAllowed ? "granted" : "denied",
  ad_user_data: p.marketingAllowed ? "granted" : "denied",
  ad_personalization: p.marketingAllowed ? "granted" : "denied"
});

gtag("consent", "default", consentState(init.customerPrivacy));
customerPrivacy.subscribe("visitorConsentCollected", (e) =>
  gtag("consent", "update", consentState(e.customerPrivacy))
);

This code must run before GTM loads. The full setup is in Consent Mode v2 on Shopify.

Step 6: data the sandbox can’t see

Custom checkout fields and blocks

A gift message, a delivery date picker or a survey on the thank-you page lives in a checkout UI extension. The pixel can’t read it, but the extension can publish a custom event with the data:

// inside a checkout UI extension
const { analytics } = useApi();
analytics.publish("delivery_date_selected", { date: selectedDate });

// inside your custom pixel
analytics.subscribe("delivery_date_selected", (event) => {
  window.dataLayer.push({ event: "delivery_date_selected", date: event.customData.date });
});

Extensions on the information, shipping and payment steps are a Shopify Plus feature. Thank-you and order status page extensions are available on all plans.

Errors shown to buyers

alert_displayed tells you when the checkout shows an error, such as an invalid address or a declined discount code. Sending it to GA4 as a custom event is one of the cheapest ways to find checkout friction.

What happens after the order

Refunds, cancellations, edited orders and subscription renewals never reach the pixel. Use Shopify webhooks or a server-side integration, and for GA4, send refunds with the Measurement Protocol if you need net revenue there.

Debugging the checkout

  1. Check the pixel status. In Settings → Customer events, the pixel must be “Connected”.
  2. Use Shopify’s Pixel Helper, available from the Customer events page, to see which pixels loaded on each checkout step and which events they received.
  3. Use the console. In Chrome DevTools, open the Console, and in the context dropdown at the top choose the frame whose name starts with web-pixel-sandbox-CUSTOM. Type dataLayer to see every push.
  4. Add temporary logs. A console.log(event.name, event.data) in each subscription shows exactly what Shopify sends.
  5. Use GA4 DebugView. Set debug_mode: true on the Google tag while testing. GTM’s preview mode often can’t attach to the sandbox, so DebugView is the reliable view.
  6. Watch the Network tab. Filter on collect to see GA4 requests, and check en=purchase, ep.transaction_id, cid and gcs (consent state).
  7. If you use server-side GTM, its preview mode works normally, because the requests reach your server container.
  8. Test real payment paths: card, PayPal, Shop Pay, a discount code, and an upsell if you have one. Each can behave differently.

Frequently asked questions

Can I still use Additional scripts or checkout.liquid?

No. Both have been retired, and the thank-you and order status pages now run on checkout extensibility for every store. Tracking goes through Customer events and pixels.

Why does GTM not work on my Shopify checkout?

Usually because GTM is still in the theme, the custom pixel isn’t connected, its consent permissions block it, or the GA4 tags read the sandbox’s address instead of the event context. Work through the steps above in that order.

Why do checkout purchases show as Direct in GA4?

Because the checkout doesn’t recognize the visitor from the store: different client IDs, page data from the sandbox iframe, or consent not shared with the sandbox. See Shopify purchases attributed to Direct or Unassigned.

Can I track clicks on the Shopify checkout?

Not on specific checkout elements. Use the standard checkout events, alert_displayed for errors, and checkout UI extensions that publish custom events for your own blocks.

Checklist before you go live

  • One purchase source per platform; the Google & YouTube app’s GA4 connection disabled if GTM sends purchases.
  • Real page URLs in GA4, checkout tokens cleaned.
  • All checkout steps visible in DebugView with items and value.
  • transaction_id equal to the Shopify order ID.
  • Same _ga client ID on the store and the thank-you page.
  • Consent default before GTM, update on banner choice, checked with gcs.
  • PayPal and other payment domains in GA4’s unwanted referrals. See fixing paypal.com / referral.
  • A week later, GA4 purchases compared with Shopify orders by order ID.

For the complete data layer, including product and cart events, see building a full ecommerce dataLayer from a Shopify custom pixel. If you’d rather have the checkout tracked and verified for you, my Shopify tracking packages start at 1,300€, or book a free 30-minute call.

Elliot Latil

Full Stack Web Analytics expert: GA4, GTM, server-side tracking and Shopify. About me