Create a Shopify custom app and connect it to Admaxxer

What this is: a 3-minute walkthrough for creating a Shopify Custom App in the Dev Dashboard with the canonical 12 scopes (alphabetised: read_customers, read_customer_events, read_discounts, read_fulfillments, read_inventory, read_locations, read_marketing_events, read_orders, read_pixels, read_products, read_reports, write_pixels), pasting the credentials into Admaxxer, and confirming the pixel registered. 11 are read-only; read_pixels + write_pixels are required so Admaxxer's Web Pixel App Extension can auto-install at OAuth time. No Shopify App Review required — Custom Apps ship directly to a single merchant.

What you'll get once both steps are connected

Step 1 — Create a Custom App in the Shopify Dev Dashboard (~1 min)

  1. Open the Shopify Dev Dashboard and sign in. If you don't have a dev account yet, create one — it's free and takes under a minute.
  2. Click Create app in the top right.
  3. Name it Admaxxer Integration (anything works) and click Create.
  4. Confirm Webhooks API Version is set to the latest stable release (e.g. 2025-10).
  5. Open Configuration in the left sidebar and fill in the URL fields exactly — both must point to admaxxer.com (Shopify requires both URLs share the same host):

    Don't use a placeholder like example.com — Shopify validates both fields, and if the hosts don't match (App URL host vs Allowed redirection URL host), the OAuth install fails with: The redirect_uri and application url must have matching hosts.

Step 2 — Enable the 12 required scopes (~45 sec)

In the left sidebar go to Access › Scopes and enable exactly these 12 permissions (11 read-only, plus the 1 pixel write scope):

That's it — 11 read scopes plus the 1 pixel write scope. Scroll to the bottom and click Release. When the modal appears, leave every field blank and click Release again.

Critical: paste the scope list exactly as read_customers,read_customer_events,read_discounts,read_fulfillments,read_inventory,read_locations,read_marketing_events,read_orders,read_pixels,read_products,read_reports,write_pixels. If you previously had write_script_tags on this app, remove it — that scope was deprecated by Shopify on Feb 1, 2025 and Admaxxer uses the modern Web Pixel App Extension via write_pixels instead. Do NOT add read_analytics — that scope is NOT in Shopify's canonical access-scopes table and returns HTTP 406 even when enabled (community evidence). read_reports is the correct scope name.

Note for docs maintainers: existing screenshots may show only 8 scope checkboxes — they need to be re-shot to include read_discounts, read_pixels, and write_pixels. Until then, the alphabetised string above is the source of truth.

What each scope powers

Admaxxer is deliberately scope-minimal: every Custom App permission maps to a specific dashboard tile or pipeline. The 11 read scopes are read-only; the pixel scopes (read_pixels + write_pixels) are required so Admaxxer's Web Pixel App Extension can auto-register at install time and capture checkout_completed events from inside Shopify's pixel sandbox. We never request any other write scope.

Mapping of Shopify Custom App scope to Admaxxer surface
Scope What it powers in Admaxxer
read_orders Revenue, refunds, AOV, returns, and new-customer revenue tiles. Powers the Marketing Acquisition Store section and the analytics dashboard's revenue / blended-MER columns.
read_products Product titles for line items so you can see which products each ad campaign drives. Powers the Top Products card and per-product ROAS lookups.
read_customers New vs returning cohort split. Powers the cohort LTV and lifetime-value cards and feeds the new-customer-revenue column on Marketing Acquisition.
read_discounts Promo-code attribution + discount-driven revenue breakdown on /marketing-acquisition. Powers the discount_code group_by in revenue analytics and lets the AI agent answer “which discount codes drove the most revenue last month?”.
read_reports Shopify's pre-built sales / customer / inventory reports. Powers the Shopify-vs-Pixel cross-validation card on /marketing-acquisition — pixel revenue vs Shopify-reported revenue side-by-side with delta % and Match Quality Score.
read_marketing_events Shopify-tagged marketing campaigns (e.g., a Klaviyo flow ID Shopify recorded against an order). We cross-check these against the UTMs the pixel saw to flag attribution gaps.
read_fulfillments Fulfillment timestamps + refund line items. Powers the lead-time tile, shipped-vs-paid revenue split, and the Returns tile (refund line items are richer than refund totals).
read_inventory InventoryItem.unitCost per SKU. Powers the COGS tile, margin-aware MMM, and the Net Profit tile (Shopify Plus only — unitCost is a Plus-tier field).
read_locations Multi-warehouse split. Powers the per-location stock card and EU-vs-US ad-spend attribution against fulfillment locations (Shopify Plus only).
read_pixels Reads which pixels are installed on your store so Admaxxer can confirm our Web Pixel App Extension registered correctly. Powers the “pixel installed” status badge in the connect drawer and the GL#326 install audit (pre-2026-05-01 installs without this scope ran blind on whether the auto-install actually succeeded).
write_pixels Registers Admaxxer's Web Pixel App Extension on your store at OAuth time via Shopify's webPixelCreate mutation. The extension runs inside Shopify's sandboxed pixel runtime and subscribes to checkout_completed — that's how revenue lands in your Admaxxer dashboard within seconds of an order. Without this scope the mutation returns ACCESS_DENIED, no extension is installed, and no checkout events reach the collector → revenue gap.
Manual Custom Pixel fallback (no scope) UTMs, sessions, pageviews, funnel events (page_viewed, product_viewed, product_added_to_cart, checkout_started, checkout_completed), device, geo. If you skip write_pixels or the auto-install fails, paste the snippet manually via Shopify admin › Settings › Customer events. See /documentation/install/shopify-web-pixel.

Step 3 — Install the app on your store (~30 sec)

  1. Back in the Dev Dashboard, click your app name in the left sidebar (the Dev Dashboard lists every app you've created).
  2. Click Install app at the top right.
  3. Pick the Shopify store you want to connect to Admaxxer.
  4. You'll be bounced to your Shopify admin — click Install to grant all 12 scopes (the 11 read scopes plus write_pixels).

Step 4 — Copy your Client ID and Client Secret (~20 sec)

  1. In the Dev Dashboard, open your app and go to Settings in the left sidebar.
  2. Find the Credentials section.
  3. Copy the Client ID and Client Secret — keep them somewhere private, you'll paste them into Admaxxer next.

Treat the Client Secret like a password. Admaxxer encrypts it with AES-256-GCM at rest and never logs it, but anyone with the raw value can act on behalf of your app.

Step 5 — Paste credentials into Admaxxer (~15 sec)

  1. Back in Admaxxer, open Integrations › Shopify.
  2. Paste your Client ID, Client Secret, and shop domain (e.g. mystore.myshopify.com).
  3. Click Configure & authorize. You'll be redirected to Shopify's consent screen — approve, and we'll drop you back on the integrations page with a green "Connected" badge for the Custom App half of the integration.

At this point Admaxxer can read orders, products, customers, reports, marketing events, fulfillments, inventory, and locations from your shop, and our Web Pixel App Extension auto-registers via the write_pixels scope — capturing checkout_completed events the moment the install completes. The daily Shopify reports sync also starts within minutes, powering the cross-validation card on /marketing-acquisition. If the auto-install fails (e.g. you skipped write_pixels), continue to Step 6 for the manual paste fallback — or read the canonical recovery guide at /documentation/install/shopify-web-pixel.

Step 6 — Manual Custom Pixel fallback (~1 min, only if auto-install failed)

If you granted write_pixels in Step 2, Admaxxer's Web Pixel App Extension already auto-installed at OAuth time and you can skip this section. This step is only needed if the auto-install failed (e.g. you have a pre-2026-05-01 install without write_pixels, or you don't want to grant the pixel scopes). For the canonical recovery walkthrough — including the GL#326 audit, manual pixel paste path, and scope-recovery instructions — see /documentation/install/shopify-web-pixel. The Custom Pixel runs in Shopify's sandboxed Customer Events runtime — no theme edits, no script tags, no OAuth scope. Shopify's consent banner is honored automatically.

  1. In your Shopify admin, go to Settings › Customer events.
  2. Click Add custom pixel in the top right.
  3. Name it Admaxxer (anything works, but this name makes it easy to find later).
  4. Paste the Admaxxer pixel snippet into the code editor. The connect form on Integrations › Shopify shows your live snippet (auto-filled with your website ID when you’re signed in).

    Important (GL#305): Shopify’s Custom Pixel runtime is a sandboxed Web Worker — there is no document object, no DOM, and no <script>-tag loading available. The only API you can call is analytics.subscribe(eventName, callback) for Shopify’s standard customer events plus fetch() to POST the events back to Admaxxer at admaxxer.com/api/event. Earlier versions of this snippet (pre-2026-04-29) wrapped a document.createElement('script') + document.head.appendChild(s) shape — that silently failed in the worker, surfacing as Shopify’s warning “Pixel will not track any customer behavior because it is not subscribed to any events.” The canonical shape is:

    // 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));
    });
    WEBSITE_ID
    A short alphanumeric token unique to each pixel website in your Admaxxer workspace (e.g. admx_abc123). It tells the pixel which workspace and site to send events to. Find it by signing in to Admaxxer and opening the install card on your Dashboard, or by opening the Shopify connect drawer on /integrations — the snippet is pre-filled with your real ID under the form.
    ENDPOINT
    Always https://admaxxer.com/api/event. The CORS allowlist on /api/pixel/ingest already permits *.myshopify.com and shop.app origins via originAllowed.ts — no allowed-domains configuration needed for the Custom Pixel sandbox.
  5. Set Customer privacy › Permission to Not required. (Strict CSP — the pixel doesn't read PII without consent, so it's safe to fire pre-consent for analytics-only data.)
  6. Click Save BEFORE clicking Connect. Click Save in the top right of the Shopify Custom Pixel editor and wait for the “saved” confirmation, THEN click Connect (or Publish, depending on your Shopify version). Clicking Connect first returns the error “the pixel cannot be connected until a custom pixel script is saved for it” — Shopify treats Save and Connect as a strict order, not interchangeable buttons.

The pixel starts firing on your next storefront page load. Open your storefront in incognito and check DevTools › Network for a POST to collect.admaxxer.com — that confirms the Custom Pixel is live.

Cross-validation: the payoff for the wider scope set

Pixel data has known gaps — ad-blockers, Safari ITP, CSP errors, and JavaScript disabled all silently drop events. Shopify's server-side data via read_reports is ground truth. Admaxxer pulls both into our analytics warehouse daily and shows them side-by-side on /marketing-acquisition with a delta % and a Match Quality Score (0-100) that quantifies attribution leakage. A 95+ score means your pixel is healthy; a sub-80 score means you're losing measurable revenue to ad-blockers or CSP — and the card tells you exactly which sources / pages are leaking. It's our differentiator on top of the same 8 scopes.

Frequently asked questions

I get "the pixel cannot be connected until a custom pixel script is saved for it" — what's wrong?
Two common causes. (1) Wrong snippet pasted. Shopify's Custom Pixel runs in a sandboxed Web Worker — no document object, no DOM, no <script>-tag loading. Pasting raw HTML like <script defer ...></script> is invalid JavaScript and Shopify refuses to save it; pasting a document.createElement('script') wrapper looks valid to the editor but throws at runtime in the worker. Use the canonical snippet from Step 6 above — it calls analytics.subscribe(eventName, callback) for Shopify's standard customer events and fetch() to POST events to admaxxer.com/api/event. (2) Save button wasn't clicked before Connect. The Save and Connect buttons live in the same toolbar but Shopify treats them as a strict order: Save first, Connect second. Connect alone won't persist your script, and the pixel never has a saved version to connect. Resolution: paste the canonical snippet from Step 6, click Save (top right) and wait for the “saved” confirmation, then click Connect.
Why does the Shopify pixel snippet look different from the WordPress / Webflow one?
Shopify's Custom Pixel runtime is a sandboxed Web Worker (no DOM, no document) and only exposes analytics.subscribe() and fetch(). WordPress, Webflow, and most other platforms instead ask for raw HTML you paste into <head>. The end goal is identical — capture pageviews, checkouts, and product views — but the implementations differ because the runtimes differ. The Shopify snippet on this page subscribes directly to Shopify's customer events and POSTs to admaxxer.com/api/event from inside the worker; the HTML snippet on /documentation/install loads script.js in the storefront DOM where it can do the same thing via window APIs. Always use the snippet on the page that matches your platform.
Why am I seeing "Contains invalid scopes" on dev.shopify.com?
Most common cause is a copy-paste typo in the scope list. Paste exactly: read_customers,read_discounts,read_fulfillments,read_inventory,read_locations,read_marketing_events,read_orders,read_pixels,read_products,read_reports,write_pixels — no spaces, no extra commas, no other scope names. The two scope names that historically trigger this error in Admaxxer's docs are: (1) write_script_tags — deprecated by Shopify on Feb 1, 2025 (script tags expire Aug 26, 2026 for non-Plus stores per Shopify's deprecation notice); and (2) read_analytics — NOT in Shopify's canonical access-scopes table and returns HTTP 406 even when enabled. If either appears in your scope list, remove it. The Custom App should use read_reports (not read_analytics) for analytics access, and write_pixels (not write_script_tags) for the Web Pixel App Extension.
What scopes does Admaxxer need from Shopify?
12 scopes on the Custom App, alphabetised: read_customers,read_customer_events,read_discounts,read_fulfillments,read_inventory,read_locations,read_marketing_events,read_orders,read_pixels,read_products,read_reports,write_pixels. 11 read-only: read_orders (revenue, refunds, AOV, returns — also gates Order.customerJourneySummary for the Sources & Attribution lens), read_products (product titles for line items), read_customers (new vs returning cohort), read_discounts (promo-code attribution + discount-driven revenue breakdown), read_reports (Shopify-reported revenue for cross-validation), read_marketing_events (Shopify-tagged campaigns vs pixel UTMs), read_fulfillments (lead time + refund line items), read_inventory (unit cost / COGS), read_locations (multi-warehouse split), and read_pixels (audit which pixels are installed on your store). Plus the write scope write_pixels (register Admaxxer's Web Pixel App Extension at OAuth time so checkout_completed events flow into your dashboard within seconds). read_pixels + write_pixels are non-negotiable for the auto-install path; if you skip them, you'll have to paste the snippet manually via Shopify admin › Settings › Customer events — see /documentation/install/shopify-web-pixel for the manual fallback.
What's the difference between read_reports and read_analytics?
read_reports is the canonical 2026 scope listed in Shopify's access-scopes documentation; description: "Analytics and reporting data through the shopifyqlQuery query." It's the scope you want for any analytics / cross-validation work. read_analytics is NOT in that canonical scopes table at all — it appears to be a legacy / Plus-only ShopifyQL gate that Shopify quietly retired. Even when "enabled," API calls return HTTP 406 (community evidence), and some dev dashboards reject it at config time with "Contains invalid scopes." We deliberately use read_reports and never request read_analytics.
Why two steps (Custom App + Custom Pixel)?
Shopify scopes give us revenue (server-side, via the Admin API). Custom Pixels give us traffic (browser-side, via Shopify's sandboxed Customer Events runtime). Both are needed for source-to-revenue attribution — the Custom App can't see UTMs or pageviews, and the Custom Pixel can't see refunds or full order economics. Doing them as two clearly-separated steps means each piece is auditable on its own and you can revoke either independently. The revenue side is server, the traffic side is browser — that's the architectural reason.
How does cross-validation work?
Pixel events tell us what visitors did (browser-side); Shopify's read_reports API tells us what actually happened in the merchant of record's ledger (server-side). Admaxxer pulls both into our analytics warehouse via the daily shopifyReportsSync worker and renders an automated attribution-quality check on /marketing-acquisition: pixel revenue vs Shopify-reported revenue side-by-side, the delta in dollars and percent, and a Match Quality Score (0-100). A score of 95+ means your pixel is healthy; below 80 means ad-blockers / Safari ITP / CSP errors are eating measurable revenue, and the card surfaces which sources or pages are leaking the most. This is our cross-validation differentiator — we don't just collect events, we show you when they're missing.
Do I need to give Admaxxer write access to anything?
One narrow write scope: write_pixels. It only lets Admaxxer register a Web Pixel App Extension on your store (via Shopify's webPixelCreate mutation) so we can capture checkout_completed events from inside Shopify's pixel sandbox. write_pixels does NOT grant write access to orders, customers, products, inventory, locations, or any other resource. The other 11 scopes are read-only. If you'd rather skip write_pixels entirely, you can install the pixel manually via Shopify admin › Settings › Customer events (no OAuth scope needed for the manual path) — see /documentation/install/shopify-web-pixel.
Does the Custom Pixel conflict with my existing analytics pixels?
No. Shopify's Customer Events architecture explicitly supports multiple Custom Pixels in parallel — each one sees the same standard customer events and forwards them to its own endpoint. The Admaxxer pixel runs in a sandboxed worker with no DOM access, so it can't conflict with GA4, Meta Pixel, TikTok Pixel, or any other tag manager.

Troubleshooting

Related

Web Pixel auto-install + scope-recovery (canonical) · Install the Admaxxer pixel on any platform · Open Shopify connect · Marketing Acquisition dashboard · Analytics dashboard · Documentation home