If you've only ever added tracking to Shopify by pasting a script into theme.liquid, building a Web Pixel extension feels strange the first time. Your code doesn't run on the page. It can't touch the DOM. It receives events from Shopify, and what it does with them is up to you, within a sandbox.
That's a good thing. It's also why a lot of "just paste this snippet" tracking advice doesn't work anymore, especially at checkout. This post walks through building an app pixel (the kind you ship inside a Shopify app) that subscribes to events, respects consent, and forwards purchases to your own endpoint, plus the gotchas that cost me the most time.
Note: Shopify's Web Pixels API evolves. I've linked to the official docs where behavior matters, and you should check them before shipping. Treat the snippets here as a starting point, not a drop-in implementation.
App pixels vs custom pixels
Shopify has two kinds of pixels:
- Custom pixels are code a merchant pastes into Settings → Customer events. Good for one store.
- App pixels are web pixel extensions shipped as part of an app. Good when you're building something for many stores.
Both run in the same sandboxed environment and use the same event API. App pixels add a shopify.extension.toml configuration, merchant-editable settings, and declared privacy requirements.
Why the sandbox exists
Pixels run isolated from the storefront, in a sandbox (Shopify documents it as a Web Worker for app pixels). The practical consequences:
-
No DOM access. You can't query selectors, read
document.cookiedirectly or inject script tags. -
No access to the theme's
windowordataLayer. If your old tracking relied onwindow.dataLayer, it won't be there. - You subscribe to standard events and receive structured data instead of scraping the page.
-
Network calls are allowed (
fetch), so you can send data to your own server or a platform's API.
The upside is that one pixel works across the storefront and checkout, because you're not depending on the theme.
Scaffold the extension
From your app directory, using the Shopify CLI:
shopify app generate extension
# choose "Web pixel" when prompted
(Template names have changed in different CLI versions, so pick the web pixel option from the menu rather than relying on a name from a blog post.)
You'll get something like:
extensions/my-pixel/
├── shopify.extension.toml
└── src/
└── index.js
Configure it: settings and privacy
In shopify.extension.toml you declare the extension's settings (what the merchant can enter) and which kinds of consent your pixel needs.
name = "My tracking pixel"
type = "web_pixel_extension"
runtime_context = "strict"
[customer_privacy]
analytics = true
marketing = true
preferences = false
sale_of_data = "disabled"
[settings]
type = "object"
[settings.fields.endpoint]
name = "Endpoint URL"
description = "Where purchase events are sent"
type = "single_line_text_field"
validations = [{ name = "min", value = "1" }]
Things to check against the current docs, because the schema has changed over time:
-
The exact keys in
[customer_privacy]and the allowed values. Shopify states its pixel manager only loads your pixel when there is visitor permission for all the settings you declare as required, so declaringmarketing = truemeans the pixel won't load for visitors who declined marketing. That's powerful, but it also means you may lose events you wanted. Declare only what you actually need. -
The
runtime_contextvalue (strict vs lax). It changes what the pixel can access. - How settings are typed and validated.
Subscribe to events
The entry point is register. You get analytics, browser, settings and init:
// src/index.js
import { register } from '@shopify/web-pixels-extension';
register(({ analytics, browser, settings, init, customerPrivacy }) => {
// 1. Know what consent looks like right now
let consent = init.customerPrivacy;
// 2. Keep it updated when the visitor makes a choice
customerPrivacy.subscribe('visitorConsentCollected', (event) => {
consent = event.customerPrivacy;
});
// 3. Subscribe to the events you care about
analytics.subscribe('checkout_completed', async (event) => {
// we'll fill this in below
});
});
Two details from Shopify's privacy docs worth knowing:
-
init.customerPrivacygives you the initial consent state when the pixel loads. -
visitorConsentCollectedis the event to listen to for changes. Consent can change mid-session, so a value read once at startup can be stale.
Common standard events include page_viewed, product_viewed, product_added_to_cart, checkout_started and checkout_completed. Check the current reference for the full list and each event's data shape.
Build the purchase payload
Keep the payload small, explicit and the same shape every time. Don't forward the raw event.
function buildPurchase(event) {
const c = event.data.checkout;
const orderId = String(c.order?.id ?? c.token);
return {
event_name: 'purchase',
event_id: `purchase_${orderId}`, // stable ID for deduplication
transaction_id: orderId,
value: Number(c.totalPrice.amount),
currency: c.currencyCode,
items: c.lineItems.map((li) => ({
id: li.variant?.sku || String(li.variant?.id),
name: li.title,
price: Number(li.variant?.price?.amount),
quantity: li.quantity,
})),
timestamp: event.timestamp,
};
}
Why a stable event_id built from the order? If you later add a server-side path (a webhook, for example), both paths can use the same ID, so the destination can deduplicate. A random UUID generated separately on each side is the classic way to double-count sales.
Forward it, with consent checked
Now fill in the subscriber. The key rule: check consent at send time, using the current value.
analytics.subscribe('checkout_completed', async (event) => {
// Only send if the visitor allowed what this data is used for
if (!consent?.marketingAllowed && !consent?.analyticsProcessingAllowed) {
return;
}
const payload = buildPurchase(event);
try {
await fetch(settings.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
consent: {
analytics: !!consent.analyticsProcessingAllowed,
marketing: !!consent.marketingAllowed,
},
}),
keepalive: true, // lets the request finish if the page is closing
});
} catch (err) {
// Don't throw: a tracking failure should never affect the page
console.error('pixel send failed', err);
}
});
Notes:
-
keepalive: truematters on a thank-you page. Customers navigate away fast, and without it the request can be cancelled. - Decide your consent logic deliberately. The check above is deliberately simple. What you may send, and for what purpose, depends on your legal situation and the destination. Mapping Shopify's consent fields to a platform's own consent signals (for example Google's Consent Mode) is a separate step that deserves care.
- Pass the consent state along to your server so downstream systems can honor it too. Moving collection server-side doesn't remove the need to respect the visitor's choice.
- Never throw from a subscriber. Wrap network calls so a failure is contained.
Using cookies and storage
Because you can't read document.cookie, the browser object provides sandbox-safe access:
const fbp = await browser.cookie.get('_fbp');
const sessionKey = await browser.sessionStorage.getItem('my_key');
Check the docs for the exact methods and for what is available in your runtime context. This is also where consent matters: reading advertising cookie IDs and forwarding them is something you should gate behind the right consent.
The gotchas that cost the most time
1. Events fire, but your fetch goes nowhere.
Check the Network panel for your endpoint, CORS responses and mixed content. Your server has to accept requests from the sandbox's origin, and a missing CORS header fails silently from the pixel's point of view.
2. The pixel never loads for some visitors.
That's often your own [customer_privacy] declaration working as designed. If you require marketing consent and a visitor declined, your pixel doesn't run at all.
3. A stale consent value.
Reading init.customerPrivacy once and never updating it means you act on old consent. Subscribe to changes.
4. You test on production and pollute your data.
Use a development store, and add a test flag so test orders can be filtered at your endpoint.
5. Two tracking implementations are both live.
An app pixel plus a custom pixel plus a leftover theme snippet will send the same purchase three times. Audit Settings → Customer events and installed apps before you ship, and make sure your event_id scheme is consistent.
6. Local dev doesn't behave like checkout.
Test end to end on a dev store with a test payment gateway. Don't trust only the storefront events, because checkout is where several implementations differ.
7. Silent breakage after changes.
There's no error in the admin when a pixel stops sending. Log on your endpoint, watch for sudden drops in event counts, and consider an automated test that buys a product and checks the request.
Debug checklist
- Open DevTools → Network and filter for your endpoint
- Confirm the payload contains the right
value,currencyandtransaction_id - Confirm exactly one purchase request per order
- Toggle consent (accept, then decline) and confirm what's sent in each case
- Check your server logs for the same order, and for duplicates
- Compare against the real order in the Shopify admin
When you don't want to maintain this
An app pixel is a good fit if you're building a product, or you need a custom integration. For a single store sending to the usual ad platforms, the maintenance adds up: consent mapping, deduplication, retries, and updates when platforms change their requirements.
If you'd rather not own that, Webgarh GTM Assistant is a Shopify app that handles client-side and server-side tracking through Google Tag Manager, with event deduplication, Consent Mode v2 support and tag diagnostics. It's newer than some alternatives, so test it on a dev store and compare what it sends with your own pixel.
Wrapping up
A Web Pixel extension is a different mental model from pasting a script: events in, structured payloads out, consent respected by design. Keep payloads small, build a stable event ID from the order, check consent at send time, and never let a tracking failure break the page. Then test it like production code, because it is.
Have you built one of these? I'd like to hear which sandbox limitation surprised you most, so drop it in the comments.
Top comments (1)
gotcha #5 is the universal one. two implementations both live sending the same purchase three times is just the event-tracking version of a fan-out join. the stable event_id built from the order is the real fix, one id both paths agree on.