Install guide · ecommerce · ~5 min

Install Admaxxer on Shopify

Custom Pixel via Customer events — 5-min install.

The 5-minute install for Online Store 2.0 themes. Admaxxer’s pixel uses Shopify’s modern Customer events Custom Pixel APIanalytics.subscribe() running inside Shopify’s sandboxed Web Worker. No theme.liquid edits. No <script> tags injected. Works on every theme since 2021.

Two install paths exist — pick exactly one:

Critical: the two paths use different code formats. Path A is a JavaScript snippet pasted into Shopify’s Customer events JS editor. Path B is an HTML <script> tag pasted into theme.liquid. Pasting Path B’s HTML into Path A’s editor is the most common Shopify install error — Shopify’s editor rejects it on parse and shows “the pixel cannot be connected until a custom pixel script is saved for it.” Use only the JS snippet from step 2 below.

Steps

  1. 1 Open Customer events in Shopify Admin

    Go to Settings → Customer events → Add custom pixel. Name it 'Admaxxer'.

    Where to find Settings: bottom-left corner of your Shopify admin. Customer events sits inside Settings under the Apps and sales channels section. The Add custom pixel button is at the top-right of the Customer events page.

  2. 2 Paste the snippet

    Copy the JavaScript code below and paste it into the code editor. Replace anything that's already in there. Replace admx_YOUR_WEBSITE_ID with your real website ID from your Admaxxer dashboard (it starts with admx_). Set Customer privacy → Permission to Not required — the pixel only fires for consented visitors via Shopify’s built-in consent gate.

    shopify-custom-pixel.js js
    // Admaxxer Custom Pixel — paste into Shopify Admin → Settings → Customer events → Add custom pixel
    // IMPORTANT: Click "Save" (top right) first, then click "Connect" (separate
    // button). If you click Connect before Save you get the
    // "pixel cannot be connected until a custom pixel script is saved for it" error.
    //
    // GL#366 — Cross-domain attribution handoff (INBOUND only in this Worker).
    // When a visitor lands here from one of the merchant's other tracked hosts
    // with ?_admx_v=&_admx_s=&_admx_t= in the URL, the snippet inherits the
    // visitor + session ids instead of minting fresh ones — preserving
    // attribution across storefront -> ReCharge / app.brand.com / B2B portal
    // hops. Outbound rewriting is a no-op here: the Shopify Worker can't
    // intercept arbitrary link clicks (no document.click, no DOM). The regular
    // pixel on the merchant's server-rendered storefront is the surface that
    // signs outbound handoff tokens. The Consent API
    // (window.admaxxer.optIn/Out/hasOptedIn) is exposed by the regular pixel
    // only; the Shopify Custom Pixel honours the admaxxer_optout cookie via
    // the server-side allow_all_domains check.
    const WEBSITE_ID = "admx_YOUR_WEBSITE_ID";
    const ENDPOINT = "https://admaxxer.com/api/event";
    const CROSS_DOMAIN_HOSTS = [];
    const CROSS_DOMAIN_SECRET = "";
    const HANDOFF_TTL_MS = 5 * 60 * 1000;
    // CROSS_DOMAIN_HOSTS is informational in the Worker (no link-click intercept);
    // referenced here so Shopify's editor lint doesn't flag it as unused. The
    // regular pixel on the storefront uses the same allow-list to decide which
    // outbound clicks to sign. CROSS_DOMAIN_SECRET is consumed by signHandoff +
    // verifyInboundHandoff below.
    void CROSS_DOMAIN_HOSTS; void signHandoff;
    
    // GL#306: Shopify Worker fetches strip Origin + Referer headers under the
    // default referrer policy. Every event includes a host field in the body
    // from event.context.window.location.hostname; the server uses this as a
    // CORS-fallback host for the allowed_domains check.
    //
    // GL#307: this snippet must satisfy TWO conflicting constraints:
    //   (1) Chat-autolinker immunity. Slack / Discord / Markdown renderers
    //       auto-link any expression ending in .TLD (.data .id .email .product
    //       are all real ICANN TLDs). A merchant copying from an autolinked
    //       surface ends up with "[event.data](http://event.data).checkout"
    //       in Shopify's editor — JS parse error, pixel fails to load.
    //   (2) Shopify Custom Pixel ESLint config (which the editor enforces)
    //       has the dot-notation rule turned on — every literal property
    //       access via brackets (event["data"], c["email"], etc.) is rejected.
    // The fix is destructuring: pull .data / .id / .email / .product values
    // into bare-name locals (data, orderId, customerEmail, productId) and
    // access them by the bare name afterwards. Dot notation preserved for
    // non-TLD props (no eslint warning); no expression ends in
    // .data / .id / .email / .product anywhere in the snippet (no autolinker
    // match). Don't "simplify" this back to inline access without restoring
    // both invariants.
    //
    // GL#323: visitor_id and session_id are minted + persisted in this Worker
    // via Shopify's browser.localStorage (durable visitor) + browser.cookie
    // (30-min idle session). Both are sent on every event so the server never
    // falls back to randomUUID(). The 30-min idle threshold matches GA4 /
    // TripleWhale / Datafast convention. localStorage is the primary store
    // for visitor_id (1-year practical retention); cookie is the fallback in
    // case localStorage is unavailable (Shopify's browser API may be denied
    // in certain consent states). Session_id uses cookie because cookies
    // auto-expire after the idle window even if the Worker stays warm
    // (sliding session-cookie semantics, no Date math required).
    const VID_LS_KEY = "__admx_vid";
    const SID_COOKIE_KEY = "__admx_sid";
    const VID_COOKIE_KEY = "__admx_vid";
    const SAT_LS_KEY = "__admx_sat"; // last activity timestamp (ms)
    const SESSION_IDLE_MS = 30 * 60 * 1000;
    
    // In-memory state, hydrated once from browser.* on first event and kept
    // warm for the Worker's lifetime. Shopify Workers may be recycled between
    // pageviews, so the persistence layer is the source of truth.
    let cachedVisitorId = "";
    let cachedSessionId = "";
    let cachedSessionLastActivity = 0;
    let hydrated = false;
    
    function uuidv4() {
      // Worker-safe v4. crypto.getRandomValues is the only RNG we can rely on
      // in Shopify's sandbox (no Math.random seeding guarantees, no Node).
      const b = new Uint8Array(16);
      crypto.getRandomValues(b);
      b[6] = (b[6] & 0x0f) | 0x40;
      b[8] = (b[8] & 0x3f) | 0x80;
      const h = [];
      for (let i = 0; i < 16; i++) {
        let s = b[i].toString(16);
        if (s.length < 2) s = "0" + s;
        h.push(s);
      }
      return h[0] + h[1] + h[2] + h[3] + "-" + h[4] + h[5] + "-" + h[6] + h[7] + "-" + h[8] + h[9] + "-" + h[10] + h[11] + h[12] + h[13] + h[14] + h[15];
    }
    
    async function readLocalStorage(key) {
      try {
        const v = await browser.localStorage.getItem(key);
        return typeof v === "string" ? v : "";
      } catch (e) { return ""; }
    }
    
    async function writeLocalStorage(key, value) {
      try { await browser.localStorage.setItem(key, value); } catch (e) {}
    }
    
    async function readCookie(key) {
      try {
        const v = await browser.cookie.get(key);
        return typeof v === "string" ? v : "";
      } catch (e) { return ""; }
    }
    
    async function writeCookie(key, value) {
      try { await browser.cookie.set(key + "=" + value + "; path=/; max-age=" + (SESSION_IDLE_MS / 1000) + "; SameSite=Lax"); } catch (e) {}
    }
    
    // GL#366 — Cross-domain handoff signer.
    // snippet-allow: pixelIngest.ts (file path inside comment, not exec)
    // MUST match client-pixel/src/pixel.ts signHandoffSync AND server-side
    // verifyHandoffToken in pixelIngest — all three layers use the same wire
    // format:
    //   token = SHA-256(`${vid}|${sid}|${websiteId}|${minute}|${secret}`).hex().slice(0,8)
    // where minute = Math.floor((new Date()).getTime() / 60000)
    // Plain unkeyed SHA-256 with secret APPENDED to the message — not true HMAC.
    // Reason: client-pixel must sign synchronously inside the click-capture
    // handler (the click navigates before any awaitable HMAC resolves). For an
    // 8-char truncation + 5-min TTL + opt-in allow-list threat model,
    // length-extension is not a realistic concern. If we ever need true HMAC,
    // route handoff through a server-issued short-lived signed token instead
    // (separate ship — see GL#366 deferred follow-ups).
    async function signHandoff(vid, sid, websiteIdArg) {
      const minute = Math.floor((new Date()).getTime() / 60000);
      const msg = vid + "|" + sid + "|" + websiteIdArg + "|" + minute + "|" + (CROSS_DOMAIN_SECRET || "");
      try {
        if (typeof crypto !== "undefined" && crypto.subtle) {
          const enc = new TextEncoder();
          const buf = await crypto.subtle.digest("SHA-256", enc.encode(msg));
          const bytes = new Uint8Array(buf);
          let hex = "";
          for (let i = 0; i < bytes.length; i++) {
            let s = bytes[i].toString(16);
            if (s.length < 2) s = "0" + s;
            hex += s;
          }
          return hex.slice(0, 8);
        }
      } catch (e) {}
      // djb2-style fallback. Deterministic + TLD-immune (no dot tokens). Output
      // 8 hex chars to match the canonical wire format.
      let h = 5381;
      for (let i = 0; i < msg.length; i++) {
        h = ((h << 5) + h + msg.charCodeAt(i)) >>> 0;
      }
      let hex = h.toString(16);
      while (hex.length < 8) hex = "0" + hex;
      return hex.slice(0, 8);
    }
    
    // Verify an inbound `_admx_t` token. MUST match the server's verifyHandoffToken
    // formula. Accepts the current minute or any of the previous 5 minutes.
    async function verifyInboundHandoff(token, vid, sid, websiteIdArg) {
      if (!token || typeof token !== "string" || token.length !== 8) return false;
      const nowMin = Math.floor((new Date()).getTime() / 60000);
      for (let i = 0; i <= 5; i++) {
        const minute = nowMin - i;
        const msg = vid + "|" + sid + "|" + websiteIdArg + "|" + minute + "|" + (CROSS_DOMAIN_SECRET || "");
        let computed = "";
        try {
          if (typeof crypto !== "undefined" && crypto.subtle) {
            const enc = new TextEncoder();
            const buf = await crypto.subtle.digest("SHA-256", enc.encode(msg));
            const bytes = new Uint8Array(buf);
            let hex = "";
            for (let j = 0; j < bytes.length; j++) {
              let s = bytes[j].toString(16);
              if (s.length < 2) s = "0" + s;
              hex += s;
            }
            computed = hex.slice(0, 8);
          }
        } catch (e) {}
        if (!computed) {
          // djb2 fallback path — same shape as signHandoff for parity.
          let h = 5381;
          for (let j = 0; j < msg.length; j++) {
            h = ((h << 5) + h + msg.charCodeAt(j)) >>> 0;
          }
          let hex = h.toString(16);
          while (hex.length < 8) hex = "0" + hex;
          computed = hex.slice(0, 8);
        }
        if (computed === token) return true;
      }
      return false;
    }
    
    // Hydrate visitor + session ids from Shopify's browser API on first event.
    // localStorage is the authoritative visitor store; cookie is a tie-breaker
    // fallback if localStorage is gated by consent. Session uses cookie first
    // (because cookies expire on their own — no idle-window math needed) with
    // a localStorage SAT (last-activity timestamp) belt-and-suspenders for
    // browsers that drop the cookie sooner than expected.
    async function hydrateIds(inboundLoc) {
      // GL#366 — Inbound cross-domain handoff. When the landing URL carries
      // _admx_v / _admx_s / _admx_t and the token verifies (HMAC + 5-min TTL),
      // adopt the inherited visitor + session ids in place of any persisted or
      // freshly-minted ones. Effectively zero overhead when the URL has no
      // _admx_* params (the common case). Performed BEFORE storage reads so a
      // valid handoff cleanly overrides any cookie/LS values from a stale prior
      // visit on this same Shopify Worker. The hydrated ids are then persisted
      // back to LS + cookie below, so subsequent events on this domain inherit
      // the same identity.
      let inboundVid = "";
      let inboundSid = "";
      let inboundOk = false;
      try {
        const params = parseQuery((inboundLoc && inboundLoc.search) ? inboundLoc.search : "");
        const candVid = typeof params._admx_v === "string" ? params._admx_v : "";
        const candSid = typeof params._admx_s === "string" ? params._admx_s : "";
        const candTok = typeof params._admx_t === "string" ? params._admx_t : "";
        // Cheap shape gate — vid + sid are uuid-shaped (>=32 chars, hex+dashes).
        if (candVid && candSid && candTok && candVid.length >= 16 && candSid.length >= 16) {
          inboundOk = await verifyInboundHandoff(candTok, candVid, candSid, WEBSITE_ID);
          if (inboundOk) { inboundVid = candVid; inboundSid = candSid; }
        }
      } catch (e) {}
    
      let vid = inboundOk ? inboundVid : "";
      if (!vid) vid = await readLocalStorage(VID_LS_KEY);
      if (!vid) vid = await readCookie(VID_COOKIE_KEY);
      if (!vid) {
        vid = uuidv4();
      }
      // Persist (covers fresh-mint AND inbound-override paths).
      await writeLocalStorage(VID_LS_KEY, vid);
      await writeCookie(VID_COOKIE_KEY, vid);
      cachedVisitorId = vid;
    
      // GL#354: use (new Date()).getTime() instead of the millisecond-since-epoch
      // shorthand on the Date constructor. The shorthand has a "dot-now" token,
      // and "now" is Tonga's ccTLD — Slack/Discord/Markdown autolinkers mangle
      // the token into a broken markdown link the merchant pastes verbatim.
      // (new Date()).getTime() has no word-dot-TLD token anywhere and is the
      // canonical autolinker-immune replacement. Sister rule to GL#307's
      // destructure mandate. Verified by the snippet-drift canary under scripts/.
      const now = (new Date()).getTime();
      let sid = inboundOk ? inboundSid : "";
      if (!sid) sid = await readCookie(SID_COOKIE_KEY);
      let satRaw = await readLocalStorage(SAT_LS_KEY);
      let sat = parseInt(satRaw, 10);
      if (!Number.isFinite(sat)) sat = 0;
      if (!sid || (!inboundOk && sat > 0 && now - sat > SESSION_IDLE_MS)) {
        sid = uuidv4();
        sat = now;
      }
      if (inboundOk) sat = now;
      cachedSessionId = sid;
      cachedSessionLastActivity = sat;
      await writeCookie(SID_COOKIE_KEY, sid);
      await writeLocalStorage(SAT_LS_KEY, String(sat));
      hydrated = true;
    }
    
    // Bump the session-idle window on every event. If 30+ minutes elapsed since
    // the last event, mint a fresh session_id (industry standard, matches GA4 /
    // TripleWhale / Datafast).
    //
    // GL#354: (new Date()).getTime() instead of the dot-now shorthand — see
    // hydrateIds() for the autolinker rationale. The "now" suffix is Tonga's
    // ccTLD; chat-client autolinkers mangle the token into a broken link.
    async function tickSession(inboundLoc) {
      if (!hydrated) await hydrateIds(inboundLoc);
      const now = (new Date()).getTime();
      if (cachedSessionLastActivity > 0 && now - cachedSessionLastActivity > SESSION_IDLE_MS) {
        cachedSessionId = uuidv4();
      }
      cachedSessionLastActivity = now;
      await writeCookie(SID_COOKIE_KEY, cachedSessionId);
      await writeLocalStorage(SAT_LS_KEY, String(now));
    }
    
    function send(payload) {
      try {
        fetch(ENDPOINT, {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify(payload),
        }).then(function () {}, function () {});
      } catch (e) {}
    }
    
    // GL#359 — World-class attribution capture. Same model as the regular pixel
    // (client-pixel) // snippet-allow: pixel.ts (file path inside comment, not exec)
    // behaviour for the Shopify Worker sandbox: 13 click-IDs persist 90 days in
    // browser.localStorage; 5 UTMs persist as first-touch (365 days) AND ship as
    // last-touch (current event). Carried on EVERY event so server-side joins are
    // bulletproof regardless of which event fires first.
    const CLICKID_LS_PREFIX = "__admx_cid:";
    const CLICKID_TTL_MS = 90 * 24 * 60 * 60 * 1000;
    const FIRST_TOUCH_LS_KEY = "__admx_first";
    const FIRST_TOUCH_TTL_MS = 365 * 24 * 60 * 60 * 1000;
    const CLICKID_NAMES = [
      "gclid","gbraid","wbraid","fbclid","ttclid","msclkid",
      "scid","rdt_cid","epik","li_fat_id","kx","ko_click_id","twclid",
    ];
    const UTM_NAMES = ["utm_source","utm_medium","utm_campaign","utm_term","utm_content"];
    let cachedFirstTouch = null;
    let firstTouchHydrated = false;
    
    // Worker-safe query-string parser. URLSearchParams may not be available; this
    // handles "?a=1&b=2" deterministically. Empty input → empty object.
    function parseQuery(qs) {
      const out = {};
      if (!qs || qs.length === 0) return out;
      const stripped = qs.charAt(0) === "?" ? qs.slice(1) : qs;
      if (stripped.length === 0) return out;
      const parts = stripped.split("&");
      for (let i = 0; i < parts.length; i++) {
        const eq = parts[i].indexOf("=");
        if (eq < 0) continue;
        try {
          const k = decodeURIComponent(parts[i].slice(0, eq));
          const v = decodeURIComponent(parts[i].slice(eq + 1).replace(/\+/g, " "));
          if (k && !out[k]) out[k] = v;
        } catch (e) {}
      }
      return out;
    }
    
    // GL#361 (back-ported via GL#362) — 30-platform smart referrer classifier.
    // Returns { source, medium } so the strip-detection probe below can decide
    // whether the referrer is paid-capable. Order matters: most-specific hosts
    // first; AI-chat tier first (rising attribution surface that no other vendor
    // classifies). Mirrors client-pixel/src/pixel.ts classifyReferrer for parity.
    //
    // snippet-allow: chatgpt.com (AI-chat referrer host — real referrer hostname)
    // snippet-allow: chat.openai.com (AI-chat referrer host)
    // snippet-allow: claude.ai (AI-chat referrer host)
    // snippet-allow: perplexity.ai (AI-chat referrer host)
    // snippet-allow: copilot.microsoft.com (AI-chat referrer host)
    // snippet-allow: gemini.google.com (AI-chat referrer host)
    // snippet-allow: bard.google.com (AI-chat referrer host)
    // snippet-allow: you.com (AI-chat referrer host)
    // snippet-allow: t.co (Twitter URL shortener — real referrer hostname)
    // snippet-allow: x.com (Twitter/X domain — real referrer hostname)
    // snippet-allow: l.facebook.com (Facebook link wrapper — real referrer hostname)
    // snippet-allow: lm.facebook.com (Facebook mobile wrapper — real referrer hostname)
    // snippet-allow: threads.net (Threads social — real referrer hostname)
    // snippet-allow: bsky.app (Bluesky — real referrer hostname)
    // snippet-allow: bsky.social (Bluesky — real referrer hostname)
    // snippet-allow: lnkd.in (LinkedIn shortener — real referrer hostname)
    // snippet-allow: youtu.be (YouTube shortener — real referrer hostname)
    // snippet-allow: out.reddit.com (Reddit outbound wrapper — real referrer hostname)
    // snippet-allow: brave.com (Brave search — real referrer hostname)
    // snippet-allow: search.brave.com (Brave search — real referrer hostname)
    // snippet-allow: medium.com (Content referrer — real referrer hostname)
    // snippet-allow: .social (Mastodon TLD regex — Mastodon instances live on *.social)
    function classifyReferrer(referrer) {
      if (!referrer || typeof referrer !== "string") return { source: "", medium: "" };
      let host = "";
      try {
        const m = referrer.match(/^https?:\/\/([^\/?#]+)/i);
        if (!m) return { source: "", medium: "" };
        host = m[1].toLowerCase();
      } catch (e) { return { source: "", medium: "" }; }
    
      // AI-chat tier (rising category — no incumbent classifies these)
      if (host.indexOf("chatgpt.com") >= 0 || host.indexOf("chat.openai.com") >= 0) return { source: "chatgpt", medium: "ai_chat" };
      if (host.indexOf("claude.ai") >= 0) return { source: "claude", medium: "ai_chat" };
      if (host.indexOf("perplexity.ai") >= 0) return { source: "perplexity", medium: "ai_chat" };
      if (host.indexOf("copilot.microsoft.com") >= 0) return { source: "copilot", medium: "ai_chat" };
      if (host.indexOf("gemini.google.com") >= 0) return { source: "gemini", medium: "ai_chat" };
      if (host.indexOf("bard.google.com") >= 0) return { source: "bard", medium: "ai_chat" };
      if (host.indexOf("you.com") >= 0) return { source: "you", medium: "ai_chat" };
    
      // Social
      if (host.indexOf("facebook") >= 0 || host === "l.facebook.com" || host === "lm.facebook.com") return { source: "facebook", medium: "social" };
      if (host.indexOf("instagram") >= 0) return { source: "instagram", medium: "social" };
      if (host === "t.co" || host.indexOf("twitter") >= 0 || host.indexOf("x.com") >= 0) return { source: "twitter", medium: "social" };
      if (host.indexOf("threads.net") >= 0) return { source: "threads", medium: "social" };
      if (host.indexOf("bsky.app") >= 0 || host.indexOf("bsky.social") >= 0) return { source: "bluesky", medium: "social" };
      if (host.indexOf("mastodon") >= 0 || host.match(/\.social$/)) return { source: "mastodon", medium: "social" };
      if (host.indexOf("tiktok") >= 0) return { source: "tiktok", medium: "social" };
      if (host.indexOf("linkedin") >= 0 || host === "lnkd.in") return { source: "linkedin", medium: "social" };
      if (host.indexOf("pinterest") >= 0) return { source: "pinterest", medium: "social" };
      if (host.indexOf("reddit") >= 0 || host === "out.reddit.com") return { source: "reddit", medium: "social" };
      if (host.indexOf("snapchat") >= 0) return { source: "snapchat", medium: "social" };
      if (host.indexOf("youtube") >= 0 || host === "youtu.be") return { source: "youtube", medium: "social" };
      if (host.indexOf("quora") >= 0) return { source: "quora", medium: "social" };
      if (host.indexOf("discord") >= 0) return { source: "discord", medium: "social" };
    
      // Search
      if (host.indexOf("google.") >= 0) return { source: "google", medium: "organic" };
      if (host.indexOf("bing.") >= 0) return { source: "bing", medium: "organic" };
      if (host.indexOf("duckduckgo") >= 0) return { source: "duckduckgo", medium: "organic" };
      if (host.indexOf("yahoo.") >= 0) return { source: "yahoo", medium: "organic" };
      if (host.indexOf("yandex.") >= 0) return { source: "yandex", medium: "organic" };
      if (host.indexOf("baidu.") >= 0) return { source: "baidu", medium: "organic" };
      if (host.indexOf("brave.com") >= 0 || host.indexOf("search.brave.com") >= 0) return { source: "brave", medium: "organic" };
    
      // Email
      if (host.indexOf("klaviyo") >= 0) return { source: "klaviyo", medium: "email" };
      if (host.indexOf("mailchimp") >= 0) return { source: "mailchimp", medium: "email" };
      if (host.indexOf("substack") >= 0) return { source: "substack", medium: "email" };
      if (host.indexOf("beehiiv") >= 0) return { source: "beehiiv", medium: "email" };
    
      // Content
      if (host.indexOf("medium.com") >= 0) return { source: "medium", medium: "referral" };
    
      return { source: "", medium: "" };
    }
    
    // GL#361 (back-ported via GL#362) — Build Meta CAPI-compliant `_fbc` cookie
    // value when fbclid arrives. Format: `fb.{subdomainIndex}.{ts_ms}.{fbclid}`.
    // Without this exact format Meta Events Manager rejects the value as
    // "expired fbc" — community-confirmed match-rate killer (raw fbclid alone
    // floors at 10–20% match rate vs 50–70% with the proper cookie format).
    //
    // GL#354: use (new Date()).getTime() — the millisecond-since-epoch shorthand
    // on the Date constructor has a "dot-now" token, and "now" is Tonga's ccTLD.
    // Slack/Discord/Markdown autolinkers mangle the token; the (new Date()) form
    // has no word-dot-TLD anywhere.
    function synthesizeFbc(fbclid) {
      if (!fbclid) return "";
      return "fb.1." + (new Date()).getTime() + "." + fbclid;
    }
    
    async function persistClickId(name, value) {
      if (!name || !value) return;
      const exp = (new Date()).getTime() + CLICKID_TTL_MS;
      await writeLocalStorage(CLICKID_LS_PREFIX + name, JSON.stringify({ e: exp, v: value }));
    }
    async function readClickId(name) {
      const raw = await readLocalStorage(CLICKID_LS_PREFIX + name);
      if (!raw) return "";
      try {
        const obj = JSON.parse(raw);
        if (!obj || typeof obj.e !== "number" || typeof obj.v !== "string") return "";
        if (obj.e < (new Date()).getTime()) return "";
        return obj.v;
      } catch (e) { return ""; }
    }
    
    // First-touch is write-once-per-365-days — a returning visitor inside the
    // window keeps their original first-touch even when current UTM differs.
    async function hydrateFirstTouch() {
      const raw = await readLocalStorage(FIRST_TOUCH_LS_KEY);
      if (!raw) { firstTouchHydrated = true; return; }
      try {
        const obj = JSON.parse(raw);
        if (obj && typeof obj.e === "number" && obj.e > (new Date()).getTime()) {
          cachedFirstTouch = obj;
        }
      } catch (e) {}
      firstTouchHydrated = true;
    }
    
    // Read URL params + LS, persist any new click-IDs, stamp first-touch if
    // absent, return merged attribution object that every send() merges into
    // its payload.
    async function captureAttribution(loc, referrer) {
      if (!firstTouchHydrated) await hydrateFirstTouch();
      const params = parseQuery((loc && loc.search) ? loc.search : "");
      const out = { utm_source:"", utm_medium:"", utm_campaign:"", utm_term:"", utm_content:"" };
    
      for (let i = 0; i < UTM_NAMES.length; i++) {
        const k = UTM_NAMES[i];
        if (params[k]) out[k] = params[k];
      }
    
      // Click-IDs: persist any new URL value, then read all back so every event
      // ships every live click-ID. Different click-IDs coexist (a gclid from
      // day 1 + fbclid from day 3 both ride along until their 90d TTL).
      for (let i = 0; i < CLICKID_NAMES.length; i++) {
        const name = CLICKID_NAMES[i];
        if (params[name]) await persistClickId(name, params[name]);
        const persisted = await readClickId(name);
        if (persisted) out[name] = persisted;
      }
      // Klaviyo's _kx URL alias → kx storage slot.
      if (params._kx) {
        await persistClickId("kx", params._kx);
        if (!out.kx) out.kx = params._kx;
      }
    
      // Smart referrer fallback covers organic/dark-social/AI-chat/email/search
      // when no UTM. Inferred medium ("ai_chat" / "social" / "organic" / "email"
      // / "referral") is preserved verbatim — no longer flattened to "referral".
      if (!out.utm_source && referrer) {
        const inferred = classifyReferrer(referrer);
        if (inferred.source) {
          out.utm_source = inferred.source;
          out.utm_medium = out.utm_medium || inferred.medium;
        }
      }
    
      // GL#361 (back-ported via GL#362) — Synthesize Meta CAPI-compliant _fbc
      // from fbclid when present. Hard match-rate fix; see synthesizeFbc above.
      if (out.fbclid && !out.fbc) {
        out.fbc = synthesizeFbc(out.fbclid);
      }
    
      // GL#361 (back-ported via GL#362) — Strip-detection probe. When the URL
      // had zero UTMs AND zero click-IDs AND the referrer matches a known
      // paid-capable host (social/search/AI-chat), the visitor's params were
      // likely stripped by Brave / Firefox ETP / Safari ITP. Stamp the flag so
      // /marketing-acquisition can quantify per-merchant attribution loss.
      // Run AFTER the smart-referrer-classifier fallback above: if that just
      // recovered a source via referrer, hasAnyAttribution flips true and the
      // strip flag stays off — we attribute the recovered source instead.
      let hasAnyAttribution = false;
      if (out.utm_source || out.utm_medium || out.utm_campaign) hasAnyAttribution = true;
      if (!hasAnyAttribution) {
        for (let i = 0; i < CLICKID_NAMES.length; i++) {
          if (out[CLICKID_NAMES[i]]) { hasAnyAttribution = true; break; }
        }
      }
      if (!hasAnyAttribution && referrer) {
        const inferredForStrip = classifyReferrer(referrer);
        if (inferredForStrip.medium === "social" || inferredForStrip.medium === "organic" || inferredForStrip.medium === "ai_chat") {
          out.referrer_strip_suspected = "1";
        }
      }
    
      // First-touch: write once per 365d; never overwrite within window.
      if (!cachedFirstTouch) {
        const ft = {
          e: (new Date()).getTime() + FIRST_TOUCH_TTL_MS,
          utm_source: out.utm_source,
          utm_medium: out.utm_medium,
          utm_campaign: out.utm_campaign,
          utm_term: out.utm_term,
          utm_content: out.utm_content,
          referrer: referrer || "",
          landing_path: (loc && loc.pathname) ? loc.pathname : "",
        };
        cachedFirstTouch = ft;
        await writeLocalStorage(FIRST_TOUCH_LS_KEY, JSON.stringify(ft));
      }
      out.first_utm_source = cachedFirstTouch ? (cachedFirstTouch.utm_source || "") : "";
      out.first_utm_medium = cachedFirstTouch ? (cachedFirstTouch.utm_medium || "") : "";
      out.first_utm_campaign = cachedFirstTouch ? (cachedFirstTouch.utm_campaign || "") : "";
      out.first_referrer = cachedFirstTouch ? (cachedFirstTouch.referrer || "") : "";
      out.first_landing_path = cachedFirstTouch ? (cachedFirstTouch.landing_path || "") : "";
      return out;
    }
    
    // All three subscriptions await tickSession() so visitor_id + session_id are
    // guaranteed to be populated and the 30-min idle window is up-to-date before
    // the event leaves the Worker. tickSession is fast — a single event-loop
    // turn after the first hydrate. If hydrate fails (consent denied, etc.) the
    // IDs fall back to in-memory uuids generated on first call, which still
    // dedupe within the Worker's lifetime even if persistence is blocked.
    analytics.subscribe("page_viewed", async function (event) {
      const loc = event.context.window.location;
      await tickSession(loc);
      const referrer = event.context.document.referrer;
      const attr = await captureAttribution(loc, referrer);
      send(Object.assign({
        website_id: WEBSITE_ID,
        host: loc.hostname,
        visitor_id: cachedVisitorId,
        session_id: cachedSessionId,
        event_type: "pageview",
        path: loc.pathname,
        referrer: referrer,
      }, attr));
    });
    
    analytics.subscribe("checkout_completed", async function (event) {
      const locEarly = event.context.window.location;
      await tickSession(locEarly);
      const { data } = event;
      if (!data) return;
      const { checkout } = data;
      if (!checkout) return;
      const { order, email: customerEmail, totalPrice, currencyCode,
              subtotalPrice, totalTax, totalShippingPrice,
              discountApplications, lineItems, financialStatus,
              paymentGateways } = checkout;
      const { id: orderId } = order || {};
      const loc = locEarly;
      const referrer = event.context.document.referrer;
      const attr = await captureAttribution(loc, referrer);
    
      // GL#359 — extract aggregates Shopify already gives us. All optional;
      // omitted fields fall through to schema DEFAULTs (GL#357).
      const lines = Array.isArray(lineItems) ? lineItems : [];
      let unitsSold = 0;
      for (let i = 0; i < lines.length; i++) {
        const q = lines[i] && lines[i].quantity;
        if (typeof q === "number") unitsSold += q;
      }
      const dApps = Array.isArray(discountApplications) ? discountApplications : [];
      let discountTotal = 0;
      for (let i = 0; i < dApps.length; i++) {
        const v = dApps[i] && dApps[i].value && dApps[i].value.amount;
        if (typeof v === "string" || typeof v === "number") {
          const n = parseFloat(v);
          if (!isNaN(n)) discountTotal += n;
        }
      }
      // GL#363: Shopify Custom Pixel ESLint flags multi-line ternaries with the
      // "?" on the next line ("Misleading line break before '?'; readers may
      // interpret this as an expression boundary"). Use an if/else block instead
      // of a multi-line ternary so the editor accepts the paste. Sister rule to
      // GL#307 destructure mandate: the snippet body must satisfy Shopify's
      // editor lint, not just be valid JavaScript.
      let gw = "";
      if (Array.isArray(paymentGateways) && paymentGateways.length > 0) {
        gw = String(paymentGateways[0]);
      }
    
      send(Object.assign({
        website_id: WEBSITE_ID,
        host: loc.hostname,
        visitor_id: cachedVisitorId,
        session_id: cachedSessionId,
        event_type: "payment",
        amount_cents: Math.round(parseFloat(totalPrice.amount) * 100),
        currency: currencyCode,
        provider: "shopify",
        external_payment_id: orderId,
        email: customerEmail || undefined,
        subtotal: subtotalPrice ? parseFloat(subtotalPrice.amount) : undefined,
        tax: totalTax ? parseFloat(totalTax.amount) : undefined,
        shipping: totalShippingPrice ? parseFloat(totalShippingPrice.amount) : undefined,
        discount: discountTotal > 0 ? discountTotal : undefined,
        units_sold: unitsSold > 0 ? unitsSold : undefined,
        line_item_count: lines.length > 0 ? lines.length : undefined,
        gateway: gw || undefined,
        financial_status: financialStatus || undefined,
        landing_site: loc.pathname,
        referring_site: referrer || undefined,
      }, attr));
    });
    
    analytics.subscribe("product_viewed", async function (event) {
      const loc = event.context.window.location;
      await tickSession(loc);
      const { data } = event;
      if (!data) return;
      const { productVariant } = data;
      if (!productVariant) return;
      const { product } = productVariant;
      const { id: productId } = product || {};
      const referrer = event.context.document.referrer;
      const attr = await captureAttribution(loc, referrer);
      send(Object.assign({
        website_id: WEBSITE_ID,
        host: loc.hostname,
        visitor_id: cachedVisitorId,
        session_id: cachedSessionId,
        event_type: "custom",
        goal_name: "product_viewed",
        product_id: productId,
      }, attr));
    });
  3. 3 Click Save (top right) — do this FIRST

    This is the FIRST button to click — top-right of the editor. Without saving first, the next step will error.

    Why this matters: the Save button is at the top-right of the editor (blue background). Clicking Connect before Save triggers Shopify’s most common Custom Pixel error: “the pixel cannot be connected until a custom pixel script is saved for it.” Save first, wait for the green confirmation toast, then proceed to step 4.

  4. 4 Click Connect (separate button) — do this SECOND

    Now click Connect. The status flips from 'Disconnected' to 'Connected' and events start flowing.

    Where to find Connect: next to Save at the top-right of the editor. The button is grayed out until step 3 (Save) succeeds. After clicking, the pixel status flips from DisconnectedConnected and Shopify starts firing events into the sandbox immediately.

  5. 5 Verify

    Open your storefront in an incognito tab. The Admaxxer dashboard's Sources tile should show a new pageview within 5–10 seconds. Or call GET /api/pixel/healthcheck?website_id=YOUR_WEBSITE_IDlastEventAt should be non-null and within the last 60 seconds.

  6. 6 Path B (vintage themes only) — legacy theme.liquid <script> tag

    Skip this section unless your theme pre-dates Online Store 2.0 (last updated before 2021). Most stores are on Dawn, Refresh, or another OS 2.0 theme — if your theme has JSON sections (sections/*.json) or supports Theme editor → Sections everywhere, it’s OS 2.0 and you must use Path A above.

    If your theme is a true pre-OS 2.0 vintage theme, you can install via the legacy <script> tag in theme.liquid. This path is NOT pasted into Customer events — it goes into your theme code only. In the Shopify admin go to Online Store → Themes → (your live theme) → Edit code → layout/theme.liquid. Paste the HTML tag below into <head>, replace YOUR_WEBSITE_ID and yourdomain.com, and click Save:

    <script defer
      data-website-id="YOUR_WEBSITE_ID"
      data-domain="yourdomain.com"
      src="https://admaxxer.com/js/script.js"></script>

    Why this is legacy: Shopify deprecated write_script_tags on Feb 1 2025 (script tags expire Aug 26 2026 for non-Plus stores), and Online Store 2.0 themes silently ignore script_tags injection — the API returns 201 but the script never loads in the storefront. Path A’s Custom Pixel is the canonical install for every modern theme; Path B exists only as a fallback for vintage themes that don’t support OS 2.0.

Verify installation

Troubleshooting

&ldquo;Pixel will not track any customer behavior because it is not subscribed to any events&rdquo;
Cause: you pasted Path B’s HTML <script> tag (or a document.createElement('script') snippet) into Path A’s Customer events JS editor. Shopify’s Custom Pixel runs in a sandboxed Web Worker with no DOM — there’s no document.head to append a script to, and the worker never sees an analytics.subscribe() call so it warns that the pixel listens to nothing. Fix: delete everything in the editor, paste Path A’s JavaScript snippet from step 2 above (the one that starts with const WEBSITE_ID = "admx_..."), Save, then Connect. The snippet must call analytics.subscribe() directly — that’s the only API the worker exposes.
&ldquo;The pixel cannot be connected until a custom pixel script is saved for it&rdquo;
Cause: you clicked Connect before Save. Shopify won’t connect a pixel that hasn’t been persisted yet. Fix: click Save first (the blue button at the top-right of the editor), wait for the green confirmation toast, then click Connect. They are two separate buttons and they must be clicked in order. If the error persists after Save, the editor may have rejected your snippet on parse — double-check you pasted the JS form from step 2, not an HTML <script> tag.
Status flips back to &ldquo;Disconnected&rdquo; right after I click Save
Cause: Save persists the snippet but does NOT activate the pixel. Connect is a separate, second click. Fix: after Save, look for the Connect button (next to Save, top-right). Click it. Status flips from DisconnectedConnected and events start flowing on the next storefront page load.
No events arriving in Admaxxer 30 seconds after Connect
Check three things in order. (a) The WEBSITE_ID at the top of your snippet matches your Admaxxer dashboard exactly — starts with admx_, no trailing whitespace, no extra quotes. (b) Your storefront origin (e.g. yourbrand.com AND yourbrand.myshopify.com) is in your pixel website’s allowed-domains list under Settings → Pixel → Allowed Domains — Shopify’s pixel sandbox sometimes proxies the request from the .myshopify.com origin even when the customer is on your custom domain (GL#291). (c) Open your storefront in a fresh incognito tab with DevTools → Network filtered to admaxxer.com. Each pageview should fire a POST /api/event returning 202 Accepted. If the request never appears, the Custom Pixel didn’t Connect — re-Save and re-Connect from Shopify admin.
I&rsquo;m on a vintage theme &mdash; should I use Path B (script tag)?
Only if your theme actually pre-dates Online Store 2.0 (i.e. was last updated before 2021). Most stores are on Dawn, Refresh, or another OS 2.0 theme — if your theme has JSON sections (sections/*.json) or supports Theme editor → Sections everywhere, it’s OS 2.0 and you must use Path A. Path B’s <script> tag silently fails on OS 2.0 themes — Shopify accepts the inject call (returns 201) but never loads the script in the storefront. See the Path B section at the end of the steps above for the rare cases where it’s applicable.
Custom Pixel works on storefront but not at checkout
Shopify’s Customer events sandbox covers storefront and checkout including the Thank-You page — the canonical snippet from step 2 fires checkout_completed on every paid order. If you’re seeing pageviews but no revenue events, the most likely cause is that the analytics.subscribe("checkout_completed", ...) block was edited or removed. Re-paste the canonical snippet from step 2 verbatim, Save, Connect, then complete a $1 test order to confirm.
Will the Custom Pixel slow down my storefront?
No. Shopify’s pixel sandbox runs in a separate Web Worker thread, completely off the main UI thread — it cannot block storefront rendering. The snippet’s fetch() calls use keepalive: true so they survive page unloads (no lost events) without delaying navigation. Total payload per pageview is ~200 bytes.
Can I disable specific events?
Yes — remove the corresponding analytics.subscribe() block from the snippet, then re-Save and re-Connect. The pixel only sends events for the subscriptions you keep. The default snippet ships page_viewed, checkout_completed, and product_viewed — the minimum set for revenue attribution + product-level ROAS. Removing checkout_completed will break revenue attribution; removing product_viewed only disables the Top Products report.
No events are showing up. What now?
Open DevTools Console and Network. Filter for script.js and /api/event. If they are blocked:csp, your Content Security Policy is blocking admaxxer.com — see /documentation/troubleshoot/csp. Also double-check that data-website-id matches the ID shown in your dashboard.
Events show up in staging but not production.
Confirm data-domain matches the production hostname exactly (no protocol, no trailing slash). Also confirm the website's Allowed Domains list in settings includes the prod domain.
My site is a single-page app — am I missing pageviews?
Use script.hash.js if you rely on location.hash routing. Otherwise the default script.js already hooks history.pushState/replaceState and tracks SPA navigations.