DEV Community

Cover image for Building a Consent-Aware Shopify Web Pixel Extension
Anshul Sandal
Anshul Sandal

Posted on

Building a Consent-Aware Shopify Web Pixel Extension

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.cookie directly or inject script tags.
  • No access to the theme's window or dataLayer. If your old tracking relied on window.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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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" }]
Enter fullscreen mode Exit fullscreen mode

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 declaring marketing = true means 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_context value (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
  });
});
Enter fullscreen mode Exit fullscreen mode

Two details from Shopify's privacy docs worth knowing:

  • init.customerPrivacy gives you the initial consent state when the pixel loads.
  • visitorConsentCollected is 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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

Notes:

  • keepalive: true matters 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');
Enter fullscreen mode Exit fullscreen mode

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, currency and transaction_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)

Collapse
 
omyvnss profile image
Om Yaduvanshi •

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.