<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Web Diary]]></title><description><![CDATA[Explore Web Diaries for insightful tech and marketing blogs. Stay updated with modern web development trends, digital strategy, and industry insights.]]></description><link>https://web-daries.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Web Diary</title><link>https://web-daries.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 09 Oct 2026 15:40:18 GMT</lastBuildDate><atom:link href="https://web-daries.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Consent-Aware Shopify Web Pixel Extension (and the Sandbox Gotchas Nobody Mentions)]]></title><description><![CDATA[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 ]]></description><link>https://web-daries.hashnode.dev/building-a-consent-aware-shopify-web-pixel-extension-and-the-sandbox-gotchas-nobody-mentions</link><guid isPermaLink="true">https://web-daries.hashnode.dev/building-a-consent-aware-shopify-web-pixel-extension-and-the-sandbox-gotchas-nobody-mentions</guid><dc:creator><![CDATA[Anshul Sandal]]></dc:creator><pubDate>Fri, 09 Oct 2026 15:16:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abbd6054122ad942af8db04/a667f05d-9878-4e4e-8685-feaa44a9941e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you've only ever added tracking to Shopify by pasting a script into <code>theme.liquid</code>, building a <strong>Web Pixel extension</strong> 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.</p>
<p>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 <strong>app pixel</strong> (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.</p>
<blockquote>
<p><strong>Note:</strong> 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.</p>
</blockquote>
<h2>App pixels vs custom pixels</h2>
<p>Shopify has two kinds of pixels:</p>
<ul>
<li><p><strong>Custom pixels</strong> are code a merchant pastes into <em>Settings → Customer events</em>. Good for one store.</p>
</li>
<li><p><strong>App pixels</strong> are web pixel extensions shipped as part of an app. Good when you're building something for many stores.</p>
</li>
</ul>
<p>Both run in the same sandboxed environment and use the same event API. App pixels add a <code>shopify.extension.toml</code> configuration, merchant-editable settings, and declared privacy requirements.</p>
<h2>Why the sandbox exists</h2>
<p>Pixels run isolated from the storefront, in a sandbox (Shopify documents it as a Web Worker for app pixels). The practical consequences:</p>
<ul>
<li><p><strong>No DOM access.</strong> You can't query selectors, read <code>document.cookie</code> directly or inject script tags.</p>
</li>
<li><p><strong>No access to the theme's</strong> <code>window</code> <strong>or</strong> <code>dataLayer</code><strong>.</strong> If your old tracking relied on <code>window.dataLayer</code>, it won't be there.</p>
</li>
<li><p><strong>You subscribe to standard events</strong> and receive structured data instead of scraping the page.</p>
</li>
<li><p><strong>Network calls are allowed</strong> (<code>fetch</code>), so you can send data to your own server or a platform's API.</p>
</li>
</ul>
<p>The upside is that one pixel works across the storefront <em>and</em> checkout, because you're not depending on the theme.</p>
<h2>Scaffold the extension</h2>
<p>From your app directory, using the Shopify CLI:</p>
<pre><code class="language-bash">shopify app generate extension
# choose "Web pixel" when prompted
</code></pre>
<p>(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.)</p>
<p>You'll get something like:</p>
<pre><code class="language-plaintext">extensions/my-pixel/
├── shopify.extension.toml
└── src/
    └── index.js
</code></pre>
<h2>Configure it: settings and privacy</h2>
<p>In <code>shopify.extension.toml</code> you declare the extension's settings (what the merchant can enter) and which kinds of consent your pixel needs.</p>
<pre><code class="language-toml">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" }]
</code></pre>
<p>Things to check against the current docs, because the schema has changed over time:</p>
<ul>
<li><p><strong>The exact keys in</strong> <code>[customer_privacy]</code> 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 <code>marketing = true</code> 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.</p>
</li>
<li><p><strong>The</strong> <code>runtime_context</code> <strong>value</strong> (strict vs lax). It changes what the pixel can access.</p>
</li>
<li><p><strong>How settings are typed</strong> and validated.</p>
</li>
</ul>
<h2>Subscribe to events</h2>
<p>The entry point is <code>register</code>. You get <code>analytics</code>, <code>browser</code>, <code>settings</code> and <code>init</code>:</p>
<pre><code class="language-js">// src/index.js
import { register } from '@shopify/web-pixels-extension';

register(({ analytics, browser, settings, init, customerPrivacy }) =&gt; {
  // 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) =&gt; {
    consent = event.customerPrivacy;
  });

  // 3. Subscribe to the events you care about
  analytics.subscribe('checkout_completed', async (event) =&gt; {
    // we'll fill this in below
  });
});
</code></pre>
<p>Two details from Shopify's privacy docs worth knowing:</p>
<ul>
<li><p><code>init.customerPrivacy</code> gives you the <strong>initial</strong> consent state when the pixel loads.</p>
</li>
<li><p><code>visitorConsentCollected</code> is the event to listen to for <strong>changes</strong>. Consent can change mid-session, so a value read once at startup can be stale.</p>
</li>
</ul>
<p>Common standard events include <code>page_viewed</code>, <code>product_viewed</code>, <code>product_added_to_cart</code>, <code>checkout_started</code> and <code>checkout_completed</code>. Check the current reference for the full list and each event's data shape.</p>
<h2>Build the purchase payload</h2>
<p>Keep the payload small, explicit and the same shape every time. Don't forward the raw event.</p>
<pre><code class="language-js">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) =&gt; ({
      id: li.variant?.sku || String(li.variant?.id),
      name: li.title,
      price: Number(li.variant?.price?.amount),
      quantity: li.quantity,
    })),
    timestamp: event.timestamp,
  };
}
</code></pre>
<p>Why a stable <code>event_id</code> 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.</p>
<h2>Forward it, with consent checked</h2>
<p>Now fill in the subscriber. The key rule: <strong>check consent at send time, using the current value.</strong></p>
<pre><code class="language-js">analytics.subscribe('checkout_completed', async (event) =&gt; {
  // Only send if the visitor allowed what this data is used for
  if (!consent?.marketingAllowed &amp;&amp; !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);
  }
});
</code></pre>
<p>Notes:</p>
<ul>
<li><p><code>keepalive: true</code> matters on a thank-you page. Customers navigate away fast, and without it the request can be cancelled.</p>
</li>
<li><p><strong>Decide your consent logic deliberately.</strong> 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.</p>
</li>
<li><p><strong>Pass the consent state along</strong> 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.</p>
</li>
<li><p><strong>Never throw from a subscriber.</strong> Wrap network calls so a failure is contained.</p>
</li>
</ul>
<h2>Using cookies and storage</h2>
<p>Because you can't read <code>document.cookie</code>, the <code>browser</code> object provides sandbox-safe access:</p>
<pre><code class="language-js">const fbp = await browser.cookie.get('_fbp');
const sessionKey = await browser.sessionStorage.getItem('my_key');
</code></pre>
<p>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.</p>
<h2>The gotchas that cost the most time</h2>
<p><strong>1. Events fire, but your</strong> <code>fetch</code> <strong>goes nowhere.</strong> 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.</p>
<p><strong>2. The pixel never loads for some visitors.</strong> That's often your own <code>[customer_privacy]</code> declaration working as designed. If you require marketing consent and a visitor declined, your pixel doesn't run at all.</p>
<p><strong>3. A stale consent value.</strong> Reading <code>init.customerPrivacy</code> once and never updating it means you act on old consent. Subscribe to changes.</p>
<p><strong>4. You test on production and pollute your data.</strong> Use a development store, and add a test flag so test orders can be filtered at your endpoint.</p>
<p><strong>5. Two tracking implementations are both live.</strong> An app pixel plus a custom pixel plus a leftover theme snippet will send the same purchase three times. Audit <em>Settings → Customer events</em> and installed apps before you ship, and make sure your <code>event_id</code> scheme is consistent.</p>
<p><strong>6. Local dev doesn't behave like checkout.</strong> 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.</p>
<p><strong>7. Silent breakage after changes.</strong> 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.</p>
<h2>Debug checklist</h2>
<ul>
<li><p>Open DevTools → Network and filter for your endpoint</p>
</li>
<li><p>Confirm the payload contains the right <code>value</code>, <code>currency</code> and <code>transaction_id</code></p>
</li>
<li><p>Confirm exactly <strong>one</strong> purchase request per order</p>
</li>
<li><p>Toggle consent (accept, then decline) and confirm what's sent in each case</p>
</li>
<li><p>Check your server logs for the same order, and for duplicates</p>
</li>
<li><p>Compare against the real order in the Shopify admin</p>
</li>
</ul>
<h2>When you don't want to maintain this</h2>
<p>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.</p>
<p>If you'd rather not own that, <a href="https://apps.shopify.com/gtm-assistant">Webgarh GTM Assistant</a> 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.</p>
<p><em>Disclosure: [add your relationship to Webgarh here, e.g. "I work at Webgarh"].</em></p>
<h2>Wrapping up</h2>
<p>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.</p>
<hr />
<p><em>Have you built one of these? I'd like to hear which sandbox limitation surprised you most, so drop it in the comments.</em></p>
]]></content:encoded></item><item><title><![CDATA[Stop Finding Out Your Shopify Tracking Broke From a Dashboard: Test It With Playwright]]></title><description><![CDATA[Most teams learn their tracking is broken the same way: someone looks at Google Ads a week later and the conversions column is flat.
By then you've paid for days of ad spend with no learning signal. T]]></description><link>https://web-daries.hashnode.dev/stop-finding-out-your-shopify-tracking-broke-from-a-dashboard-test-it-with-playwright</link><guid isPermaLink="true">https://web-daries.hashnode.dev/stop-finding-out-your-shopify-tracking-broke-from-a-dashboard-test-it-with-playwright</guid><category><![CDATA[shopify]]></category><category><![CDATA[conversion tracking]]></category><category><![CDATA[Google Analytics]]></category><category><![CDATA[Meta]]></category><category><![CDATA[gtm]]></category><dc:creator><![CDATA[Anshul Sandal]]></dc:creator><pubDate>Wed, 07 Oct 2026 14:37:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abbd6054122ad942af8db04/314ef023-81b1-4365-ae65-999f86e1ebbd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most teams learn their tracking is broken the same way: someone looks at Google Ads a week later and the conversions column is flat.</p>
<p>By then you've paid for days of ad spend with no learning signal. Tracking is code, and it breaks like code. A theme update, a changed trigger, a new app or a consent banner tweak can all kill a purchase event without a single error in the Shopify admin.</p>
<p>We already test checkout flows and APIs. Tracking deserves the same. This post shows a simple setup: a Playwright test that runs a purchase on a dev store, listens to the network requests, and fails if the purchase event is missing, duplicated or carries wrong data. Then we schedule it.</p>
<h2>What we're actually testing</h2>
<p>A purchase tracking test should answer four questions:</p>
<ol>
<li><strong>Did the event fire?</strong> (at least once)</li>
<li><strong>Did it fire exactly once?</strong> (no duplicates)</li>
<li><strong>Is the payload right?</strong> (transaction ID, value, currency)</li>
<li><strong>Did it go to every destination we expect?</strong> (GA4, Meta, Google Ads)</li>
</ol>
<p>Notice that we test the <strong>network requests</strong>, not whether a tag appears in Tag Manager. A tag firing doesn't mean the request left the browser with the right data.</p>
<h2>Setup and a major caveat</h2>
<p>Use a <strong>development store</strong>, not production. Check Shopify's current documentation for dev-store behavior, because two things matter:</p>
<ul>
<li><strong>Password protection.</strong> Dev stores are often password-protected, so your test has to enter the storefront password.</li>
<li><strong>A test payment method.</strong> Use a test or "Bogus" gateway as Shopify documents for dev stores, so no real card is charged.</li>
</ul>
<p>One honest warning: Shopify's checkout is hardened against automation and changes over time, and bot protection can interfere. Expect to maintain your selectors, and consider testing the pieces separately (see "What you can't easily test" below).</p>
<pre><code class="language-bash">npm init -y
npm i -D @playwright/test
npx playwright install chromium
</code></pre>
<h2>The test: capture requests during a purchase</h2>
<p>The idea is to record every request to your analytics endpoints while the test buys a product, then assert on what was captured.</p>
<pre><code class="language-ts">// tests/purchase-tracking.spec.ts
import { test, expect, Request } from '@playwright/test';

const STORE = process.env.STORE_URL!;          // https://your-dev-store.myshopify.com
const STORE_PASSWORD = process.env.STORE_PASSWORD;

type Hit = { url: string; body: string };

test('purchase is tracked once, with correct data', async ({ page }) =&gt; {
  const ga4: Hit[] = [];
  const meta: Hit[] = [];

  page.on('request', (req: Request) =&gt; {
    const url = req.url();
    const body = req.postData() ?? '';

    // GA4 collect endpoint (also covers server-container domains if you add them)
    if (url.includes('/g/collect') || url.includes('google-analytics.com/g/collect')) {
      ga4.push({ url, body });
    }
    // Meta Pixel
    if (url.includes('facebook.com/tr')) {
      meta.push({ url, body });
    }
  });

  // 1. Unlock the storefront if password-protected
  await page.goto(STORE);
  if (STORE_PASSWORD &amp;&amp; (await page.locator('input[type="password"]').count())) {
    await page.fill('input[type="password"]', STORE_PASSWORD);
    await page.keyboard.press('Enter');
  }

  // 2. Add a known product and go to checkout
  await page.goto(`${STORE}/products/test-product`);
  await page.getByRole('button', { name: /add to cart/i }).click();
  await page.goto(`${STORE}/checkout`);

  // 3. Fill checkout (selectors will differ per store and checkout version)
  await fillCheckoutWithTestData(page);

  // 4. Wait for the confirmation / thank-you state
  await page.waitForURL(/thank[-_]you|orders\//, { timeout: 60_000 });

  // 5. Give async tags a moment to send
  await page.waitForTimeout(5_000);

  // ---- Assertions ----
  const ga4Purchases = ga4.filter(h =&gt; /(^|[?&amp;])en=purchase(&amp;|$)/.test(h.url + '&amp;' + h.body));
  expect(ga4Purchases, 'GA4 purchase should fire exactly once').toHaveLength(1);

  const metaPurchases = meta.filter(h =&gt; h.url.includes('ev=Purchase'));
  expect(metaPurchases, 'Meta Purchase should fire exactly once').toHaveLength(1);

  // Payload checks
  const ga4Params = new URLSearchParams(ga4Purchases[0].url.split('?')[1]);
  expect(ga4Params.get('cu')).toBe('USD');                 // currency
  expect(Number(ga4Params.get('epn.value') ?? ga4Params.get('ep.value'))).toBeGreaterThan(0);
  expect(ga4Params.get('ep.transaction_id')).toBeTruthy();

  const metaParams = new URL(metaPurchases[0].url).searchParams;
  expect(metaParams.get('cd[currency]')).toBe('USD');
  expect(metaParams.get('eid')).toBeTruthy();              // event ID for dedup
});
</code></pre>
<p>A few things worth noting:</p>
<ul>
<li><strong>Parameter names vary.</strong> GA4's <code>/g/collect</code> uses short names (<code>en</code>, <code>cu</code>, <code>ep.*</code>, <code>epn.*</code>) that you should confirm against what your own requests look like. Open DevTools → Network during a manual purchase and copy what you actually see before writing assertions.</li>
<li><strong>Meta's pixel can batch or use different endpoints</strong> depending on setup, so inspect your own requests first.</li>
<li><strong>If you use server-side GTM or a custom tracking domain,</strong> add that hostname to the request filter, or you'll test the wrong thing.</li>
<li><strong><code>waitForTimeout</code> is a blunt tool.</strong> Prefer <code>page.waitForRequest(...)</code> for the specific event once you know it, so the test is both faster and less flaky.</li>
</ul>
<h2>Compare against the real order</h2>
<p>Network assertions catch missing and duplicate events. To catch <strong>wrong values</strong>, compare the tracked value with the order Shopify actually created.</p>
<pre><code class="language-ts">// helpers/shopify.ts
export async function getLatestOrder() {
  const res = await fetch(
    `https://${process.env.SHOP}/admin/api/${process.env.API_VERSION}/orders.json?limit=1&amp;order=created_at+desc`,
    { headers: { 'X-Shopify-Access-Token': process.env.ADMIN_TOKEN! } }
  );
  const { orders } = await res.json();
  return orders[0];
}
</code></pre>
<pre><code class="language-ts">const order = await getLatestOrder();
expect(Number(ga4Params.get('ep.value') ?? ga4Params.get('epn.value')))
  .toBeCloseTo(Number(order.total_price), 2);
expect(ga4Params.get('ep.transaction_id')).toBe(String(order.id));
</code></pre>
<p>Now the test fails if your tracking ever sends <code>24.90</code> for a <code>249.00</code> order, or uses the order name where your server uses the order ID. Keep the Admin API token in CI secrets, scoped read-only to orders, and use <code>API_VERSION</code> from config because Shopify retires old versions.</p>
<h2>Testing the server path</h2>
<p>If a webhook sends purchases to a server, test that separately. It's much more stable than browser automation.</p>
<pre><code class="language-ts">// tests/webhook.spec.ts
import crypto from 'node:crypto';
import { test, expect } from '@playwright/test';

test('webhook rejects a bad signature', async ({ request }) =&gt; {
  const body = JSON.stringify({ id: 1, total_price: '10.00' });
  const res = await request.post(`${process.env.APP_URL}/webhooks/orders-paid`, {
    headers: { 'X-Shopify-Hmac-Sha256': 'invalid', 'Content-Type': 'application/json' },
    data: body,
  });
  expect(res.status()).toBe(401);
});

test('same webhook twice results in one send (idempotency)', async ({ request }) =&gt; {
  const body = JSON.stringify({ id: 999, total_price: '10.00', currency: 'USD', line_items: [] });
  const hmac = crypto.createHmac('sha256', process.env.SHOPIFY_API_SECRET!)
                     .update(body).digest('base64');

  const send = () =&gt; request.post(`${process.env.APP_URL}/webhooks/orders-paid`, {
    headers: { 'X-Shopify-Hmac-Sha256': hmac, 'Content-Type': 'application/json' },
    data: body,
  });

  expect((await send()).status()).toBe(200);
  expect((await send()).status()).toBe(200);
  // Then assert your downstream mock received exactly one event for id 999.
});
</code></pre>
<p>For the downstream assertion, point your handler at a mock endpoint in test mode (an <code>API_BASE_URL</code> env var is enough) and count what it receives. That tests deduplication without sending fake data to real ad platforms.</p>
<h2>Run it on a schedule</h2>
<p>A test that only runs when you remember to run it won't catch the Tuesday-night theme change. Run it daily.</p>
<pre><code class="language-yaml"># .github/workflows/tracking-check.yml
name: tracking-check
on:
  schedule:
    - cron: '0 6 * * *'     # daily
  workflow_dispatch:
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          STORE_URL: ${{ secrets.STORE_URL }}
          STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
          SHOP: ${{ secrets.SHOP }}
          ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
          API_VERSION: ${{ vars.API_VERSION }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
</code></pre>
<p>Add a Slack or email notification on failure, and trigger the workflow after theme publishes if you deploy through CI.</p>
<h2>What you can't easily test</h2>
<p>Being honest about the limits will save you frustration:</p>
<ul>
<li><strong>Real attribution.</strong> You can test that the events were sent, not that Google Ads or Meta credited a campaign.</li>
<li><strong>Production consent behavior.</strong> Different regions and banners need separate test cases, and consent-driven differences are hard to simulate fully.</li>
<li><strong>Modeled conversions.</strong> These happen on the platform side.</li>
<li><strong>Checkout bot protection.</strong> If automation gets blocked, test the storefront events and the webhook path separately, and verify checkout manually after changes.</li>
</ul>
<h2>A practical rollout</h2>
<ol>
<li><strong>Start with one assertion:</strong> purchase fires exactly once.</li>
<li><strong>Add the payload checks:</strong> transaction ID, value, currency.</li>
<li><strong>Add the order comparison</strong> to catch wrong values.</li>
<li><strong>Test the webhook path</strong> with signature and idempotency cases.</li>
<li><strong>Schedule it,</strong> and make failures noisy.</li>
<li><strong>Update it after each checkout or theme change,</strong> because selectors and request shapes change.</li>
</ol>
<h2>If you'd rather not build this yourself</h2>
<p>A test suite tells you <em>that</em> something broke. Finding out <em>why</em> still takes digging. Tools with built-in diagnostics can shorten that. For example, <a href="https://apps.shopify.com/gtm-assistant">Webgarh GTM Assistant</a> includes tag diagnostics and event deduplication alongside its client-side and server-side GTM tracking. It's newer than some alternatives, so test it on a dev store against your own checks and compare what it reports with what your Playwright test sees.</p>
<p><em>Disclosure: [add your relationship to Webgarh here, e.g. "I work at Webgarh"].</em></p>
<h2>Wrapping up</h2>
<p>Treat tracking like production code. Test the requests, not the tag. Compare against the real order. Run it daily. Then the next time a theme update silently breaks your purchase event, your CI tells you that morning instead of your ad report telling you next week.</p>
]]></content:encoded></item><item><title><![CDATA[Conversion Tracking Not Working on Shopify? Find Your Symptom, Then Fix It]]></title><description><![CDATA[You've opened Google Ads and the conversions column says zero. Or the status next to your purchase action says "No recent conversions." Or it worked fine for months and then, one Tuesday, it flatlined]]></description><link>https://web-daries.hashnode.dev/shopify-conversion-tracking-not-working</link><guid isPermaLink="true">https://web-daries.hashnode.dev/shopify-conversion-tracking-not-working</guid><category><![CDATA[shopify]]></category><category><![CDATA[google ads]]></category><category><![CDATA[tracking software]]></category><category><![CDATA[ads]]></category><category><![CDATA[ConversionTracking]]></category><dc:creator><![CDATA[Anshul Sandal]]></dc:creator><pubDate>Mon, 05 Oct 2026 14:42:19 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abbd6054122ad942af8db04/292bcdf0-9492-4880-8e45-8df5595d5113.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You've opened Google Ads and the conversions column says zero. Or the status next to your purchase action says "No recent conversions." Or it worked fine for months and then, one Tuesday, it flatlined.</p>
<p>Meanwhile, Shopify shows orders coming in. So the sales are real, and the tracking has quietly stopped.</p>
<p>When tracking breaks, people tend to start by reinstalling things, which often makes it worse. A better way is to match what you're seeing to a symptom, because each symptom points to a different, smaller set of causes. Find yours below.</p>
<h2>First, a two-minute triage</h2>
<p>Answer these before you touch anything:</p>
<ol>
<li><strong>When did it stop?</strong> Pin down a date, even roughly.</li>
<li><strong>What changed around that date?</strong> A new theme, an app installed or removed, a checkout update, a new payment method, a consent banner, a domain change, someone "cleaning up" Google Tag Manager.</li>
<li><strong>Is it everything or just one platform?</strong> If Google Ads, GA4 and Meta all dropped on the same day, suspect something on your store. If only Google Ads dropped, suspect the Google connection.</li>
</ol>
<p>Most tracking problems start with a change somebody made, so the date gives you the biggest clue you'll get.</p>
<h2>Symptom 1: Zero conversions, ever</h2>
<p>If Google Ads has never recorded a purchase for this store, tracking was probably never connected properly. The usual causes:</p>
<ul>
<li><strong>The wrong Google Ads account is connected.</strong> Shopify happily sends data to an account you aren't looking at. This happens a lot when an agency or a former employee did the setup. Check which account the Google &amp; YouTube channel is linked to, and compare it to the one running your campaigns.</li>
<li><strong>The Google account doesn't have enough access.</strong> The account used to connect needs administrator access to the Ads account. If it doesn't, the connection can look complete and still not work.</li>
<li><strong>The conversion action exists but nothing is feeding it.</strong> Someone created a "Purchase" conversion in Google Ads by hand, but no tag or integration sends to it. The action sits there at "Unverified" or "No recent conversions" forever.</li>
<li><strong>A conversion ID or label was pasted wrong.</strong> One missing character in a GTM tag means the conversion goes nowhere, and nothing warns you.</li>
</ul>
<p><strong>What to do:</strong> In Google Ads, open the conversion action and read its status and source. Then follow one real test order from Shopify to see where it stops. Don't change anything until you know which of these you have.</p>
<h2>Symptom 2: It worked, then suddenly dropped to zero</h2>
<p>A sudden drop is almost always caused by one specific change. Check these, roughly in order of how often they cause it:</p>
<ul>
<li><strong>A change to your checkout.</strong> Shopify has been moving stores onto its newer checkout system, and older methods, such as scripts pasted into the order status or "thank you" page, have been phased out. If your tracking relied on one of those, it can stop working when your store moves over. Check Shopify's current guidance for your plan and dates, because the timeline has differed for Shopify Plus and other stores.</li>
<li><strong>A theme change or update.</strong> If your tracking code was pasted into the theme, a new theme or an update can wipe it out.</li>
<li><strong>An app was removed or updated.</strong> Uninstalling an app doesn't always clean up after itself, and sometimes it takes tracking with it.</li>
<li><strong>Someone edited or published a Google Tag Manager container.</strong> A published change to a trigger or tag can break purchase tracking without any visible error.</li>
<li><strong>A new consent banner.</strong> If the banner now blocks tags until someone clicks "accept," and most visitors ignore it, a large chunk of conversions disappears.</li>
<li><strong>A new payment method.</strong> Some payment methods send the customer off to another site to pay and return them afterward. If some customers don't make it back to your confirmation page, they never trigger the purchase event, even though the order is real.</li>
</ul>
<p><strong>What to do:</strong> Match the drop date against your changes. If you have a theme version history, a GTM version history or an app install log, use them. The change next to the drop is your suspect.</p>
<h2>Symptom 3: The tag fires in Tag Manager, but Google Ads shows nothing</h2>
<p>This one confuses people most, because the preview says everything is fine.</p>
<p>A tag firing only proves the tag ran. It doesn't prove Google accepted what it sent. Common reasons for the gap:</p>
<ul>
<li><strong>GTM isn't running where you think it is.</strong> On newer Shopify checkouts, the theme's GTM container doesn't run on checkout pages the way it used to, because checkout is more locked down. A trigger that waits for the "thank you" page URL may never fire in real life, even if it seemed to in a test.</li>
<li><strong>Consent is blocking the request.</strong> The tag fires, but the consent state tells it not to send, or to send in a limited form.</li>
<li><strong>The tag fires, but for the wrong conversion.</strong> It's sending to a different conversion ID or label than the one you're checking.</li>
<li><strong>A browser extension is blocking the request</strong> on your own device. Test in a clean browser profile.</li>
</ul>
<p><strong>What to do:</strong> Open your browser's Network tab during a test order and look for the request to Google. Check that it was sent and that it carried the right values. If you don't see it, the problem is before Google. If you do, it's on Google's side, such as the wrong action or attribution.</p>
<h2>Symptom 4: Conversions show up, but the numbers or values are wrong</h2>
<p>Here, tracking is working but reporting something false.</p>
<ul>
<li><strong>Revenue is off.</strong> Wrong value, wrong currency, or a value that includes or excludes tax and shipping differently from how you report revenue elsewhere. If a $249 order shows up as $24.90, that's usually a formatting or parsing problem in the tag.</li>
<li><strong>Orders are counted twice.</strong> Two things are sending the same purchase, such as Shopify's Google channel plus a manual GTM tag. Look at your conversion actions list and check whether more than one counts purchases.</li>
<li><strong>Add to cart or checkout counts as a "conversion."</strong> If those are set as primary, your conversion numbers will look great while your sales stay flat.</li>
<li><strong>Test orders are being counted.</strong> Real events from your own test orders end up in your data. Use a clear test process, and exclude those orders in reporting where you can.</li>
</ul>
<h2>Symptom 5: Conversions are recorded, but ads don't get credit</h2>
<p>If Shopify and GA4 show sales but Google Ads attributes few or none to your campaigns, the purchase is being recorded but the connection back to the ad click is lost.</p>
<ul>
<li><strong>Auto-tagging is off,</strong> so Google can't tie the visit to a click.</li>
<li><strong>A redirect strips the click identifier</strong> before the visitor lands, so the page never sees it.</li>
<li><strong>The visitor crosses to a different domain</strong> during checkout, and the identity doesn't carry across.</li>
<li><strong>Consent or browser restrictions</strong> limit what can be matched.</li>
<li><strong>The attribution window</strong> is shorter than your customers' buying time.</li>
</ul>
<p><strong>What to do:</strong> Check that auto-tagging is on, click your own ad (or a test link with the click ID) and watch whether the identifier survives all the way to the order. If your buyers usually take several days to decide, look at the conversion window setting too.</p>
<h2>A simple way to find where it breaks</h2>
<p>When you can't tell which symptom you have, trace one real order forward. Place a test order, then ask:</p>
<p><strong>Did the order exist? → Was a purchase event created? → Was it sent? → Did it contain the right data? → Did Google accept it? → Was it credited to a campaign?</strong></p>
<p>The first "no" is where your problem is. Write that step down, and you've turned "tracking is broken" into a specific, fixable question.</p>
<h2>Fixes people try that make it worse</h2>
<ul>
<li><strong>Installing another tracking app on top.</strong> If something partly works, this usually creates duplicate purchases.</li>
<li><strong>Deleting and recreating conversion actions.</strong> You lose history, and the new action also needs time to learn.</li>
<li><strong>Assuming a green status means it works.</strong> "Recording conversions" doesn't tell you whether the values are right.</li>
<li><strong>Changing several things at once.</strong> You'll never know which one fixed it, or which one broke something new.</li>
</ul>
<h2>When the basic setup keeps breaking</h2>
<p>If you keep fixing tracking only to have it break again after each theme update or checkout change, the setup is too fragile. Pasted scripts and several overlapping apps are the usual reason. At that point, a single managed tracking layer is easier than patching.</p>
<p>The <strong><a href="https://apps.shopify.com/gtm-assistant">Webgarh GTM Assistant</a></strong> is built for this situation. It handles browser-side and server-side tracking through Google Tag Manager, supports Google Ads, GA4, Meta, TikTok, Pinterest and others, and includes event deduplication, Consent Mode v2 and tracking diagnostics. The diagnostics matter most here, because they show what's being sent from your store instead of leaving you to guess.</p>
<p>If your conversions have stopped and you can't see why, <strong><a href="https://apps.shopify.com/gtm-assistant">install the Webgarh GTM Assistant from the Shopify App Store</a></strong>, then place one test order and look at where the event stops. That one step often points straight at the cause.</p>
<h2>Quick checklist</h2>
<ul>
<li> I know the date tracking stopped and what changed around it</li>
<li> The correct Google Ads account is connected, with administrator access</li>
<li> The conversion action has a real source, not just a name</li>
<li> I've traced one real test order from Shopify to Google</li>
<li> Purchase is the main conversion, with correct value and currency</li>
<li> Only one implementation sends purchases</li>
<li> Consent settings aren't blocking tags for most visitors</li>
<li> Auto-tagging is on</li>
<li> I re-tested after fixing, instead of trusting a status label</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to Build a Reliable Shopify Purchase Event with Custom Pixels, a Data Layer and GTM]]></title><description><![CDATA[Most tracking guides begin with the tool. This one begins with the event.
If you define a single purchase event properly, with a predictable shape and a stable ID, every destination (GA4, Google Ads, ]]></description><link>https://web-daries.hashnode.dev/shopify-purchase-event-data-layer-gtm</link><guid isPermaLink="true">https://web-daries.hashnode.dev/shopify-purchase-event-data-layer-gtm</guid><dc:creator><![CDATA[Anshul Sandal]]></dc:creator><pubDate>Thu, 01 Oct 2026 13:38:30 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abbd6054122ad942af8db04/193d6aca-35a1-47ca-ab69-f6cabdd008c0.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most tracking guides begin with the tool. This one begins with the event.</p>
<p>If you define a single purchase event properly, with a predictable shape and a stable ID, every destination (GA4, Google Ads, Meta, or anything else) becomes a mapping job rather than a separate integration.</p>
<p>This guide walks through building that event on Shopify using customer events, a custom pixel, the data layer, and Google Tag Manager. It assumes you are comfortable with JavaScript and GTM basics.</p>
<h2>The Goal: One Event, Defined Once</h2>
<p>Before writing code, decide what a purchase event should look like in your system.</p>
<p>A minimal, GA4-friendly shape looks like this:</p>
<pre><code class="language-json">{
  "event": "purchase",
  "event_id": "purchase_5678901234",
  "ecommerce": {
    "transaction_id": "5678901234",
    "value": 249.00,
    "currency": "USD",
    "items": [
      {
        "item_id": "SKU-123",
        "item_name": "Example Product",
        "price": 249.00,
        "quantity": 1
      }
    ]
  }
}
</code></pre>
<p>Three decisions matter here:</p>
<ul>
<li><p><strong>Stable event ID:</strong> Derive it from the order, not from a random value generated per page load. If the same order ever produces the event twice, the ID stays the same.</p>
</li>
<li><p><strong>Numeric values as numbers:</strong> Send <code>249.00</code>, not <code>"$249.00"</code>. Formatting errors at this layer are a common reason revenue looks wrong in reports.</p>
</li>
<li><p><strong>One shape for every destination:</strong> GTM tags read from this structure instead of each platform getting its own custom logic.</p>
</li>
</ul>
<h2>Step 1: Understand Where Your Code Can Run</h2>
<p>Shopify's checkout is locked down, so you can't drop arbitrary scripts into it the way you could on older setups.</p>
<p>Tracking in checkout is now handled through <strong>customer events</strong>, where a pixel subscribes to events emitted by the store and runs in a sandbox.</p>
<p>Shopify has been moving stores from older checkout customization methods to this model, so check the current Shopify documentation for what applies to your plan and checkout version before building.</p>
<p>Two practical consequences:</p>
<ul>
<li><p>Your code runs in a sandboxed environment and can't read the storefront's <code>window</code> directly. It receives data through the event object.</p>
</li>
<li><p>You can load GTM inside the pixel's own sandbox. GTM then works with a <code>dataLayer</code> that lives in that environment.</p>
</li>
</ul>
<h2>Step 2: Create a Custom Pixel</h2>
<p>In the Shopify admin, go to <strong>Settings → Customer events</strong> and add a custom pixel.</p>
<p>The pixel subscribes to the <code>checkout_completed</code> event and pushes your standard shape to the data layer.</p>
<pre><code class="language-javascript">// Custom pixel: runs in Shopify's sandbox
window.dataLayer = window.dataLayer || [];

analytics.subscribe('checkout_completed', (event) =&gt; {
  const checkout = event.data.checkout;
  const orderId = checkout.order &amp;&amp; checkout.order.id;

  if (!orderId) return; // Don't send an event without an order

  const items = checkout.lineItems.map((line) =&gt; ({
    item_id: line.variant &amp;&amp; line.variant.sku
      ? line.variant.sku
      : String(line.variant &amp;&amp; line.variant.id),
    item_name: line.title,
    price: Number(
      line.variant &amp;&amp;
      line.variant.price &amp;&amp;
      line.variant.price.amount
    ),
    quantity: line.quantity,
  }));

  window.dataLayer.push({ ecommerce: null });

  window.dataLayer.push({
    event: 'purchase',
    event_id: 'purchase_' + orderId,
    ecommerce: {
      transaction_id: String(orderId),
      value: Number(checkout.totalPrice.amount),
      currency: checkout.currencyCode,
      items: items,
    },
  });
});
</code></pre>
<h3>A Few Important Notes</h3>
<p>Field names such as <code>checkout.order.id</code>, <code>totalPrice.amount</code>, and <code>lineItems</code> should be verified against Shopify's current customer events reference, as the structure can change between versions.</p>
<p>Clearing <code>ecommerce</code> with <code>null</code> before each push prevents old ecommerce values from leaking into the new event.</p>
<p>The early <code>return</code> also prevents the pixel from sending a purchase event when there is no real order behind it.</p>
<p>Load your GTM container snippet at the top of the same pixel so it initializes before the data layer push.</p>
<h2>Step 3: Map the Event in GTM</h2>
<p>Inside Google Tag Manager, build the mapping once.</p>
<h3>1. Create the Trigger</h3>
<p>Create a <strong>Custom Event</strong> trigger with:</p>
<pre><code class="language-text">purchase
</code></pre>
<h3>2. Create Data Layer Variables</h3>
<p>Create variables for:</p>
<pre><code class="language-text">ecommerce.transaction_id
ecommerce.value
ecommerce.currency
ecommerce.items
event_id
</code></pre>
<h3>3. Configure the GA4 Tag</h3>
<p>Create a GA4 Event tag with:</p>
<pre><code class="language-text">Event Name: purchase
</code></pre>
<p>Use the ecommerce data from the data layer for the relevant parameters.</p>
<h3>4. Map Other Destinations</h3>
<p>Google Ads, Meta, and other destinations can read the same variables.</p>
<p>Each platform becomes a mapping exercise rather than another custom tracking implementation.</p>
<p>That is the main advantage of defining the event properly at the source.</p>
<p>If the value format needs to change or a bug needs to be fixed, you fix it in the pixel. The downstream tags don't need to be rebuilt.</p>
<h2>Step 4: Use the Event ID for Deduplication</h2>
<p>If you later add server-side tracking, the browser event and server event need to share an identifier so the destination can treat them as the same action.</p>
<p>Because the event ID in this example comes from the order:</p>
<pre><code class="language-text">purchase_&lt;orderId&gt;
</code></pre>
<p>a server process — for example, one triggered by an order webhook — can generate the exact same ID from the same order.</p>
<p>You can then pass that value in the event ID field supported by the destination, such as <code>event_id</code> for Meta's Conversions API.</p>
<p>The rule is simple:</p>
<blockquote>
<p><strong>Derive the ID from something both sides already know.</strong></p>
</blockquote>
<p>That is much more reliable than generating a random ID independently in the browser and server.</p>
<h2>Step 5: Test the Whole Path</h2>
<p>Don't stop at confirming that the tag fired.</p>
<p>Test the entire tracking path.</p>
<h3>1. Place a Test Order</h3>
<p>Place a test order and note the actual order ID and order total.</p>
<h3>2. Inspect the Data Layer</h3>
<p>Confirm that:</p>
<ul>
<li><p><code>transaction_id</code> matches the order</p>
</li>
<li><p><code>value</code> matches the order total</p>
</li>
<li><p><code>currency</code> is correct</p>
</li>
<li><p><code>value</code> is a number rather than a formatted string</p>
</li>
<li><p><code>items</code> contains the expected products</p>
</li>
</ul>
<h3>3. Check GTM Preview</h3>
<p>Open GTM Preview and confirm that the <code>purchase</code> trigger fires once.</p>
<p>If it fires twice, investigate before moving forward.</p>
<h3>4. Check the Network Request</h3>
<p>Open the browser's Network tab and confirm that the request to the destination contains the expected parameters.</p>
<h3>5. Check the Destination</h3>
<p>Use the relevant debugging tools, such as:</p>
<ul>
<li><p><strong>GA4 DebugView</strong></p>
</li>
<li><p><strong>Meta Events Manager → Test Events</strong></p>
</li>
<li><p>Google Ads conversion diagnostics where applicable</p>
</li>
</ul>
<p>Confirm that the event is actually being received and processed.</p>
<h3>6. Reload the Confirmation Page</h3>
<p>Refresh the confirmation page and verify that it does not create another purchase event.</p>
<p>This last test catches a surprising number of duplicate-event problems.</p>
<h2>Common Mistakes to Avoid</h2>
<h3>Generating a Random Event ID on Every Load</h3>
<p>A new ID on every page load defeats the purpose of deduplication.</p>
<p>If the same order is sent twice with two different IDs, the destination may treat them as two separate purchases.</p>
<h3>Sending Formatted Strings as Values</h3>
<p>Avoid:</p>
<pre><code class="language-javascript">value: "$249.00"
</code></pre>
<p>Use:</p>
<pre><code class="language-javascript">value: 249.00
</code></pre>
<p>Currency symbols and thousands separators should not be part of numeric tracking values.</p>
<h3>Rebuilding the Event for Every Platform</h3>
<p>Creating a completely different purchase payload for GA4, Google Ads, Meta, and other platforms is how tracking implementations gradually drift apart.</p>
<p>Define the event once and map it downstream.</p>
<h3>Skipping the <code>ecommerce: null</code> Reset</h3>
<p>Without clearing the previous ecommerce object, stale values can remain in the data layer and potentially contaminate subsequent events.</p>
<h3>Assuming the Sandbox Behaves Like the Storefront</h3>
<p>It doesn't.</p>
<p>Shopify's customer events environment has its own restrictions and execution model. Test the implementation inside that environment instead of assuming storefront JavaScript behavior will carry over.</p>
<h2>Where a Ready-Made Setup Fits</h2>
<p>If you would rather not maintain this tracking layer yourself, it helps to know what a good implementation should do.</p>
<p>Look for:</p>
<ul>
<li><p>One consistent event definition</p>
</li>
<li><p>Stable event IDs</p>
</li>
<li><p>Validated payloads</p>
</li>
<li><p>Consistent ecommerce parameters</p>
</li>
<li><p>Browser/server deduplication</p>
</li>
<li><p>Support for multiple advertising and analytics destinations</p>
</li>
</ul>
<p><a href="https://gtmassistant.app/">Webgarh GTM Assistant</a> handles Shopify tracking across GTM, GA4, Google Ads, and Meta, with the goal of removing much of this manual setup.</p>
<p>You can also find it on the <a href="https://apps.shopify.com/gtm-assistant">Shopify App Store</a>.</p>
<p>Even if you decide to build your own implementation, these are useful criteria for evaluating any Shopify tracking solution.</p>
<h2>Wrapping Up</h2>
<p>A reliable purchase event comes down to a few decisions made early:</p>
<p><strong>One shape. Numeric values. An ID tied to the order. A test routine that checks the actual data, not just whether a tag fired.</strong></p>
<p>Get those right once in the pixel, and every platform downstream becomes much easier to manage.</p>
<p>Once the event is stable, it also pays to monitor it over time.</p>
]]></content:encoded></item></channel></rss>