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.
Does the Shopify cookie banner block custom pixels?
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
- In Shopify admin, open Online Store → Themes, then … → Edit code on your live theme.
- Open
layout/theme.liquidand paste the tag right before</head>, below{{ content_for_header }}. - Save, then add your store’s domain under Settings → Collection → Allowed domains in invisit.
{% 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.
Wait for Shopify’s consent banner
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:
{% 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.
- 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.
- In invisit, create an API key with only the ingest scope under Settings → API keys.
- Deploy the handler, here as a Cloudflare Worker, with both as secrets.
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
- The tracker is in
theme.liquidonce, inside{% unless request.design_mode %}, and not also in a custom pixel. - Your store’s domain is under Allowed domains.
- Purchase tracking that lived in additional scripts has been rebuilt as a pixel or the webhook.
- The Order creation webhook’s handler verifies the signature and answers within five seconds.
- A test order produced exactly one
order_placed, withtest: true. - After a week,
order_placedis compared with Shopify’s online store order count, the number that is not consent-gated. - Each browser you use has opened the store with
#invisit-ignore.
