invisit
All posts

By Miraç Berk13 min read

Shopify analytics without cookies: theme, custom pixel or webhook

Where analytics code runs on Shopify in 2026, what a custom pixel can see, how GA4, Plausible, Fathom, Umami and PostHog set up, and how to count every order.

A soft 3D shopping bag beside a storefront window and a small sealed sandbox box, lit by violet dithered light with soft shadows.

To add cookieless analytics to a Shopify store, load the tracker from your theme’s theme.liquid for storefront pages, use a custom pixel subscribed to Shopify’s customer events for checkout steps, and count purchases from Shopify’s order webhook when you want one record per order. Theme code never runs in checkout, and since Shopify’s August 26, 2026 deadline for upgrading Thank you and Order status pages, after which it upgraded the remaining stores itself, pasted additional scripts run nowhere.

We build invisit, a cookieless analytics tool, and tested what we could on November 3, 2026 without a Shopify store of our own: we inspected the custom pixel sandbox on a public Shopify storefront in Chrome, ran the invisit tracker in a copy of that sandbox against our development collector, and sent signed test orders through the webhook handler below. What Shopify’s admin, checkout and webhooks do comes from Shopify’s documentation. For Webflow, Framer, Squarespace or Wix, see website builder analytics.

Where can analytics code run on a Shopify store?

Analytics code runs in four places on Shopify: the theme, a custom pixel, an app pixel and your own server. Only pixels run on every page a shopper sees, because checkout does not load theme code.

Where it runs How you add it Storefront pages Checkout steps Purchases
Theme: theme.liquid or an app embed block Edit code, or switch on an app’s embed Yes No No
Custom pixel Settings → Customer events → Add custom pixel Yes Yes Yes, from the browser
App pixel Installed by an app Yes Yes Yes, from the browser
Your server An order webhook No No Every order, POS included

Shopify loads pixels on the storefront, checkout, Thank you and Order status pages, and on customer account pages only once you connect a custom subdomain, where they get page_viewed alone (pixels overview). Shopify’s docs place app pixels in a web worker and custom pixels in an iframe (about web pixels).

Theme code sees the whole page, clicks and scrolling included. An app embed block is how an app adds a script without touching your code: Shopify injects it before </head> or </body>, and it stays off until you switch it on under Theme settings → App embeds (theme app extensions). Editing theme.liquid yourself has a cost Shopify states plainly: it “takes the theme off its automatic upgrade path” (storefront script tags).

What happened to checkout.liquid and additional scripts?

Both are gone. As of November 2026, a pixel is the only analytics code that runs in checkout and on the Thank you page, from Shopify’s changelog, checkout.liquid, Order status script tags and upgrade pages:

Date What changed
August 13, 2024 checkout.liquid stops working on the Information, Shipping and Payment steps
February 1, 2025 Apps can no longer create script tags for the Order status page
August 28, 2025 checkout.liquid and additional scripts sunset on Thank you and Order status pages; script tags stop there on Plus
August 26, 2026 Script tags stop on Order status pages of all other plans; stores not yet upgraded were upgraded automatically
October 1, 2026 Apps can no longer create or update storefront script tags (storefront script tags)
March 1, 2027 Shopify stops injecting script tags into storefronts

A purchase snippet you once pasted into the additional scripts box no longer runs, and Shopify’s replacement for tracking code there is a web pixel.

How do Shopify custom pixels work?

A custom pixel is JavaScript you paste under Settings → Customer events, which Shopify runs in a hidden iframe on storefront and checkout pages and feeds with customer events through analytics.subscribe(). The iframe carries sandbox="allow-scripts allow-forms", so the code cannot reach the page around it (about web pixels). HTML is not allowed, so a vendor’s <script src> becomes a document.createElement('script') (custom pixel code).

Shopify publishes 15 standard events. Seven describe the storefront: page_viewed, product_viewed, collection_viewed, search_submitted, cart_viewed, product_added_to_cart and product_removed_from_cart. Six describe checkout: checkout_started, checkout_contact_info_submitted, checkout_address_info_submitted, checkout_shipping_info_submitted, payment_info_submitted and checkout_completed. The other two report alerts and checkout extension errors. checkout_completed fires once per checkout, usually on the Thank you page, on the first upsell page if you run post-purchase upsells, and not at all if that page fails to load (checkout_completed).

page_viewed carries no data of its own. Shopify’s docs point you to event.context.document.location.href for the page, and every event’s context holds a snapshot of the top page’s document, navigator and window, referrer and title included (page_viewed). The sandbox cannot scrape the page, so it does not detect outbound link clicks, scrolling or heatmap clicks on its own, and it cannot show a banner or button (pixels overview). It can read and write the top page’s cookies, localStorage and sessionStorage through the asynchronous browser API (Web Pixels API), so a pixel is not cookieless by nature; that depends on the code you put in it.

What a custom pixel sees in a real store

We loaded a product page of a large public Shopify storefront in Chrome, with a Google referrer and UTM tags, and read the values from inside one of its custom pixel iframes:

What code in the pixel reads Value we saw
location.href https://<store>/web-pixels@<runtime>/custom/web-pixel-<id>@<n>/sandbox/modern/products/<handle>?utm_source=…
document.referrer, document.title The shopper’s page: https://www.google.com/ and the product’s title
The Origin header on its requests null

The frame reports the top page’s referrer and title and keeps the query string, but the address is the sandbox’s own, and every request leaves with Origin: null. Any script that reads window.location records the sandbox URL, which is why Shopify says to take the URL from the event.

Yes, by default. In markets set to require consent, such as the EEA and the UK, a pixel runs only once the visitor grants the permissions in its configuration, and a new custom pixel requires Marketing and Analytics. New custom pixels also count as data sale by default, so they skip visitors who opted out of data sale or sharing (custom pixel code). In regions set up for that opt-out, Shopify honours Global Privacy Control automatically (Customer Privacy API).

Both are settings on each pixel, under Customer privacy. Set to Not required, the pixel loads for every visitor; whether that is lawful for a cookieless tool where you sell is what our cookie banner guide covers. App pixels follow the same consent signals, and Shopify replays the events that happened before consent once it is given (about web pixels).

Theme scripts are not gated by Shopify at all. The banner reaches them only if your code asks the Customer Privacy API, which the theme snippet further down does.

What does Shopify’s own analytics count?

Shopify Analytics counts sessions and visitors with two cookies, _shopify_s, with a 30-minute rolling expiry, and _shopify_y, kept for a year, and counts orders and sales from the orders themselves (session cookies). A session ends after 30 minutes without activity.

A measurement update rolled out from September 21 to 23, 2026: sessions no longer end at midnight UTC, some sessions without a page view count, such as a cart link straight to checkout, and identified bot sessions are filtered out by default (session measurement update). Where your cookie banner is active, Shopify collects only after consent, which lowers session counts and the metrics built on them, conversion rate included (discrepancies). Orders are records, so every purchase is still there. Shopify’s order count is the number to check every other tool against; its session count is not.

How GA4, Plausible, Fathom, Umami, PostHog and invisit set up on Shopify

Every tool but GA4 puts a script in theme.liquid; they differ in what they do about checkout. As of November 2026, from each vendor’s own Shopify instructions:

Tool Where it runs Storefront pages Checkout steps Purchases Cookies by default
GA4 App pixel (Google & YouTube app) Yes begin_checkout, add_shipping_info, add_payment_info purchase, without shipping and tax _ga, two years
Plausible Theme and custom pixel Yes Page views per step, Begin Checkout Purchase revenue goal None
Fathom Theme and custom pixel Yes Checkout Started event Order Placed with a value None
Umami Theme Yes Not documented Not documented None
PostHog Theme and custom pixel Yes Not documented An event on checkout_completed ph_<token>_posthog, 365 days
invisit Theme and your server Yes No Order webhook None

GA4 is set up by installing the Google & YouTube app (Shopify). It maps Shopify’s standard events to 11 GA4 events, from page_view and view_item to begin_checkout and purchase, plus session_start, user_engagement and first_visit, and since April 24, 2025 its purchase value is the subtotal minus discounts, without shipping and taxes, so it will not match Shopify’s total sales (Google’s reference).

Plausible’s guide is the fullest for a cookieless tool (Plausible). Its pixel turns off automatic page views, sends each event with the URL from event.context.document.location, and replaces the checkout token in the path so steps group together. Revenue goals and custom properties need Plausible Business, from $19 a month at 10K page views (our comparison). Plausible lists the limits itself: no purchase when the Thank you page never loads or the pixel’s privacy settings keep it off, and no refunds, cancellations or POS orders.

Fathom loads a second copy of its script in the pixel with data-auto="false", so the pixel sends events only, with values in cents (Fathom). Umami’s documentation has no Shopify page; its instruction is the tracking code in the <head> of every page (Umami), which covers the storefront alone. PostHog’s guide uses a pixel for conversions only, and its example identifies the buyer by email and recommends disable_session_recording: true in the pixel (PostHog).

invisit has no Shopify app and no pixel. Checkout steps stay unmeasured, which Plausible and GA4 cover and we do not.

Can you run the invisit tracker in a Shopify custom pixel?

No. The invisit browser tracker does not work correctly inside a custom pixel, and we do not support it there. It records the URL of the frame it runs in, and its requests from the sandbox carry Origin: null.

We tested it on our development stack with a copy of Shopify’s sandbox: a page on the test site’s allowed domain with a hidden iframe built like the real one, the same sandbox attribute and URL shape, whose code inserted invisit.js the way Shopify says to load a script in a pixel.

Site setting What the collector does What would be recorded
Allowed domains lists your store 403: “This origin is not allowed to send events for this site.” Nothing; the tracker drops the batch
Allowed domains empty Accepted Every page as /web-pixels@…/custom/web-pixel-…/sandbox/modern/products/…

Allowed domains refuses a missing or null origin, and leaving it empty lets any site send events with your site id. We checked the empty case on the collector’s origin check and URL handling, not on a live site. The same storefront page with the theme script was accepted and stored as /products/test-shirt, with the google.com referrer and the newsletter campaign.

How to add invisit to a Shopify theme

  1. In Shopify admin, open Online Store → Themes, then … → Edit code on your live theme.
  2. Open layout/theme.liquid and paste the tag right before </head>, below {{ content_for_header }}.
  3. Save, then add your store’s domain under Settings → Collection → Allowed domains in invisit.
layout/theme.liquid
{% unless request.design_mode %}
  <script
    async
    src="https://api.invisit.dev/invisit.js"
    data-site="<site id>"
    data-collector="https://api.invisit.dev"
  ></script>
{% endunless %}

The site id is under Settings → Installation. request.design_mode is true inside the theme editor, and Shopify’s Liquid reference suggests it for keeping tracking scripts from counting your editing sessions. The tracker sets no cookies, records page views, outbound links and downloads on its own, and is about 8.5 KB.

If your policy is that analytics waits for consent, load the tracker only once Shopify’s Customer Privacy API allows analytics, in place of the tag above:

layout/theme.liquid
{% unless request.design_mode %}
<script>
  (function () {
    var loaded = false
    function apply(allowed) {
      if (!allowed) return window.invisit && window.invisit.stop()
      if (loaded) return
      loaded = true
      var script = document.createElement('script')
      script.async = true
      script.src = 'https://api.invisit.dev/invisit.js'
      script.setAttribute('data-site', '<site id>')
      script.setAttribute('data-collector', 'https://api.invisit.dev')
      document.head.appendChild(script)
    }
    document.addEventListener('visitorConsentCollected', function (event) {
      apply(event.detail.analyticsAllowed)
    })
    window.Shopify.loadFeatures([{ name: 'consent-tracking-api', version: '0.2' }], function (error) {
      if (!error) apply(window.Shopify.customerPrivacy.analyticsProcessingAllowed())
    })
  })()
</script>
{% endunless %}

analyticsProcessingAllowed() is true by default outside your consent regions, so those visitors are counted from the first page. Loading late keeps the script off the page until then. invisit’s requireConsent mode would work too: it sends nothing before consent('granted') and then counts the page the visitor is on. A withdrawal calls stop(), which ends collection without storing anything. With a stand-in for Shopify’s API, Chrome requested nothing before consent, sent the page view right after it, and nothing after a withdrawal.

How to count Shopify orders from the order webhook

Create an Order creation webhook in Shopify admin and send each online store order to invisit with @invisitdev/server. Unlike a Stripe webhook, Shopify’s order already carries the shopper’s browser_ip and client_details.user_agent (Order), so the event can join the visit that led to it with nothing kept at checkout.

  1. In Settings → Notifications → Webhooks, create a webhook: event Order creation, format JSON, your handler’s URL, API version 2026-10 (creating webhooks). Copy the signing key on that page.
  2. In invisit, create an API key with only the ingest scope under Settings → API keys.
  3. Deploy the handler, here as a Cloudflare Worker, with both as secrets.
src/index.ts
import { createInvisit } from '@invisitdev/server'

interface Env {
  SHOPIFY_WEBHOOK_SECRET: string
  INVISIT_API_KEY: string
}

// The orders/create fields this handler reads.
interface ShopifyOrder {
  admin_graphql_api_id: string
  created_at: string
  source_name: string
  test: boolean
  browser_ip: string | null
  client_details: { user_agent: string | null; accept_language: string | null } | null
  line_items: { quantity: number }[]
  discount_codes: unknown[]
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const raw = await request.arrayBuffer()
    const signature = request.headers.get('x-shopify-hmac-sha256')
    if (!(await signedByShopify(raw, signature, env.SHOPIFY_WEBHOOK_SECRET))) {
      return new Response('Invalid signature', { status: 401 })
    }

    const order = JSON.parse(new TextDecoder().decode(raw)) as ShopifyOrder
    const ip = order.browser_ip
    const userAgent = order.client_details?.user_agent
    // Point of sale, draft and app orders had no storefront visit to join.
    if (order.source_name !== 'web' || !ip || !userAgent) return new Response(null, { status: 200 })

    try {
      await createInvisit({ key: env.INVISIT_API_KEY }).track('order_placed', {
        visitor: { ip, userAgent, language: order.client_details?.accept_language ?? undefined },
        eventId: await eventIdFor(order),
        properties: {
          items: order.line_items.reduce((sum, line) => sum + line.quantity, 0),
          discount: order.discount_codes.length > 0,
          test: order.test,
        },
      })
    } catch (error) {
      console.error(error)
      return new Response('Not recorded yet', { status: 503 }) // Shopify retries the delivery
    }
    return new Response(null, { status: 200 })
  },
}

async function signedByShopify(body: ArrayBuffer, signature: string | null, secret: string) {
  if (!signature) return false
  try {
    const expected = Uint8Array.from(atob(signature), (char) => char.charCodeAt(0))
    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(secret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['verify'],
    )
    return await crypto.subtle.verify('HMAC', key, expected, body)
  } catch {
    return false
  }
}

// One UUID v7 per order, so a delivery Shopify retries is counted once.
async function eventIdFor(order: ShopifyOrder) {
  const seed = new TextEncoder().encode(order.admin_graphql_api_id)
  const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', seed)).slice(0, 16)
  const ms = Date.parse(order.created_at)
  for (let i = 0; i < 6; i++) bytes[i] = Math.floor(ms / 2 ** (8 * (5 - i))) & 0xff
  bytes[6] = 0x70 | (bytes[6]! & 0x0f)
  bytes[8] = 0x80 | (bytes[8]! & 0x3f)
  const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}

Shopify signs each delivery with an HMAC of the raw body in X-Shopify-Hmac-SHA256, may deliver one more than once, waits five seconds for an answer, and retries a failed delivery 8 times over 4 hours (verify deliveries). The handler derives its event id from the order, and invisit counts a repeated event id once for two days, so a retry or a slow answer never adds a second order. It keys on admin_graphql_api_id because Shopify’s numeric ids can exceed what JavaScript numbers hold exactly. An order paid through Shopify’s Bogus Gateway or a provider in test mode arrives with test: true (Order), so test orders can be filtered out.

We ran this handler under Bun with the published @invisitdev/server 0.3.0, against our development collector, after a storefront page view from the same address and browser:

Delivery Handler answered invisit’s collector
First delivery 200 202, accepted 1
The same delivery again 200 202, duplicate 1
Wrong signature 401 Not called
Point of sale order 200 Not called

The stored order_placed landed in the same visit as the page view that came from google.com with the newsletter campaign. Our development collector reads the visitor’s address from the forwarding header the production edge sets, so the test browser sent that header.

Two more limits. The order carries no Global Privacy Control signal and no consent answer, so the handler cannot honour either the way the browser tracker can; decide whether order events fit your policy before you deploy. And Order creation counts orders, not payments: an order paid later by bank deposit counts when it is placed.

Why invisit’s revenue reports do not cover Shopify

invisit reads revenue from Stripe, Paddle, Polar, Lemon Squeezy, Creem and Dodo Payments, and Shopify is not among them, so your Shopify sales do not appear in invisit’s revenue charts (revenue). The order_placed event counts orders and puts them in visits and funnels. It sums no money, and invisit attributes no revenue to sources or campaigns for any provider.

Your sales totals stay in Shopify’s reports. For revenue by source, GA4’s purchase value and Plausible’s revenue goals do what invisit does not, and DataFast connects Shopify directly (our comparison). invisit’s part is the visit: where the buyer came from, what they read, and a funnel such as page /cart then event order_placed within one visit.

Checklist for a Shopify store