Getting started with Admaxxer

A 5-minute walkthrough from sign-up to live dashboard. Create your workspace, connect Meta and Google Ads, install the pixel, and ask the AI agent your first question — every step in plain English with a copyable example.

7-day free trial · no card · ~5 minute setup · unlimited ad connections.

From signup to live dashboard

Four steps — a typical setup takes about five minutes. Every step links to a deep-dive doc.

  1. 1 · Create your workspace

    Sign up with email or Google. Admaxxer creates your workspace, starts a 7-day trial, and lands you on the dashboard with a guided checklist. No credit card required during the trial.

    Start your 7-day free trial

  2. 2 · Connect your ad platforms

    Click Connect with Facebook for one-click read access to Meta Ads (or paste a long-lived Meta token if you want Admaxxer to make changes for you), click Connect Google Ads for one-click sign-in, and optionally add TikTok Ads and Klaviyo. Ad connections are unlimited on every plan.

    Meta connection guide

  3. 3 · Install the pixel + revenue connector

    Paste the Custom Pixel into Shopify's Customer events editor, install the WordPress plugin, or add the script tag to any site. Then add a revenue connector (Shopify, Stripe restricted key, and others) so the dashboard can check pixel-attributed revenue against your real orders.

    Pick your install guide

  4. 4 · Talk to the AI agent

    Open the chat panel (⌘J), add your own AI key, and ask for blended MER, ad-level LTV, or which campaigns to pause. Anything that changes your ads asks for your confirmation first.

    AI agent docs

Time to first value

Copy these, edit one value, you’re live

Shopify Custom Pixel (recommended)

Paste this into Shopify Admin → Settings → Customer events → Add custom pixel. Replace the website ID, click Save, then Connect.

// 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";
// GL#618 — snippet build version, baked in at render time. Ships on every
// event as snippet_version, reserved for charting install freshness (which
// stores still run a stale paste). NOTE (audit 2026-07-02): not yet persisted
// server-side — the ingest schema does not store snippet_version today, so it
// is captured now and lights up the day a column is added (same "capture now,
// store later" pattern as the ttp field above). Keep in lockstep with
// SHOPIFY_CUSTOM_PIXEL_SNIPPET_VERSION in this module's export.
const SNIPPET_VERSION = 10;
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 the GA4 /
// standard web-analytics 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#618 — Read Meta's REAL first-party _fbc / _fbp cookies via the Shopify
// sandbox cookie API. Mirrors client-pixel/src/pixel.ts readMetaCookie (GL#604)
// // snippet-allow: pixel.ts (file path inside comment, not exec)
// in the Worker context. When the merchant runs Meta's own Pixel JS (fbq)
// alongside this snippet, those cookies are the AUTHORITATIVE values Events
// Manager expects and they carry Meta's own click timestamp — reading + for-
// warding them verbatim beats anything we synthesize. _fbp in particular is
// NOT derivable from any URL param (Meta mints it client-side), so the cookie
// is the only source. The 300-char cap mirrors the server schema's fbc cap so
// a malformed/oversized cookie never bloats the payload (real values <200B).
async function readMetaCookie(name) {
  const raw = await readCookie(name);
  return raw ? String(raw).slice(0, 300) : "";
}

// 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
// and other web-analytics tools).
//
// 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));
}

// GL#618 — localStorage retry buffer for failed conversion posts. A
// checkout_completed POST can be cancelled when the browser tears the page down
// for the thank-you redirect; keepalive mitigates that but is best-effort. Any
// payload that fails to POST is parked here and re-sent on the NEXT event (the
// returning pageview after the redirect, or the visitor's next session). Capped
// + TTL'd so it can never grow unbounded in a merchant's storage. Kept tiny on
// purpose — this is a merchant-pasted snippet, not a sync engine.
const RETRY_LS_KEY = "__admx_retry";
const RETRY_MAX = 10;
const RETRY_TTL_MS = 24 * 60 * 60 * 1000;

async function bufferFailedSend(payload) {
  try {
    const raw = await readLocalStorage(RETRY_LS_KEY);
    let arr = [];
    if (raw) { try { const p = JSON.parse(raw); if (Array.isArray(p)) arr = p; } catch (e) {} }
    arr.push({ e: (new Date()).getTime() + RETRY_TTL_MS, p: payload });
    // Keep only the most recent RETRY_MAX so a long offline streak can't bloat.
    if (arr.length > RETRY_MAX) arr = arr.slice(arr.length - RETRY_MAX);
    await writeLocalStorage(RETRY_LS_KEY, JSON.stringify(arr));
  } catch (e) {}
}

// Attempt ONE POST. `keepalive` lets the request survive page-unload (the
// checkout->thank-you redirect). Returns a promise that resolves true on a
// network-level success (HTTP status ignored — a 2xx and a 4xx both mean the
// request reached us, so we don't retry either), false on a network reject.
function postOnce(payload, keepalive) {
  try {
    const opts = {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload),
    };
    // keepalive is a standard fetch option in the Shopify Worker sandbox; gate
    // behind the flag so non-critical events don't consume the 64KB keepalive
    // pool. Assigned dynamically so editors that lint unknown init keys don't
    // flag a literal.
    if (keepalive) opts.keepalive = true;
    return fetch(ENDPOINT, opts).then(function () { return true; }, function () { return false; });
  } catch (e) {
    return Promise.resolve(false);
  }
}

// Drain any buffered failed sends (best-effort, fire-and-forget). Re-sends each
// parked payload; survivors that fail again are re-parked with their original
// TTL. Expired entries are dropped.
async function flushRetryBuffer() {
  let raw = "";
  try { raw = await readLocalStorage(RETRY_LS_KEY); } catch (e) {}
  if (!raw) return;
  let arr = [];
  try { const p = JSON.parse(raw); if (Array.isArray(p)) arr = p; } catch (e) { return; }
  if (!arr.length) return;
  const now = (new Date()).getTime();
  const keep = [];
  for (let i = 0; i < arr.length; i++) {
    const entry = arr[i];
    if (!entry || typeof entry.e !== "number" || entry.e < now || !entry.p) continue;
    const ok = await postOnce(entry.p, false);
    if (!ok) keep.push(entry);
  }
  try { await writeLocalStorage(RETRY_LS_KEY, JSON.stringify(keep)); } catch (e) {}
}

// Fire-and-forget send for non-critical events (pageview, product_viewed). No
// keepalive (these don't precede a navigation), no retry buffer (a lost
// pageview is not revenue). Mirrors the prior behaviour.
function send(payload) {
  postOnce(payload, false);
}

// GL#618 — Reliable send for the conversion event. keepalive=true so the POST
// survives the checkout->thank-you redirect; on a network reject the payload is
// parked in the retry buffer and replayed on the next event.
async function sendReliable(payload) {
  const ok = await postOnce(payload, true);
  if (!ok) await bufferFailedSend(payload);
}

// 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"];
// GL#618 — ax_* vendor-override params, captured + shipped on EVERY event to
// match the storefront pixel's makeEvent (client-pixel/src/pixel.ts:1988-1992).
// // snippet-allow: pixel.ts (file path inside comment, not exec)
// ax_source is a soft override for utm_source the Admaxxer URL Builder injects;
// ax_campaign_id / ax_adset_id / ax_ad_id / ax_placement carry the platform's
// own object ids so server-side joins can match a campaign even when the
// merchant's utm_campaign is an ad name or a prefix-stripped variant. The
// server UTM validator under server/lib/utm sanitizes + persists these.
const AX_NAMES = ["ax_source","ax_campaign_id","ax_adset_id","ax_ad_id","ax_placement"];
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#618 (GL#383 family) — Shopify Liquid template-leak strip. When a merchant
// pastes a UTM template with curly-braces (e.g. utm_campaign={{campaign_name}})
// into an ad-platform URL builder, Shopify's storefront NEVER substitutes the
// {{...}} tokens — they only render inside .liquid theme files, not arbitrary
// URL params. The literal {{campaign_name}} arrives here as the value and, if
// shipped, renders as a fake campaign in Sources & Attribution. The pixel
// ingest server strips this same pattern (isLiquidTemplateLeak, in the UTM
// validator under server/lib/utm); we strip it client-side too so the worker
// never ships it. Matches a value that is EXCLUSIVELY {{...}} tokens (with optional
// separators) — a value where {{...}} is part of a longer real string is kept.
// Returns "" for a pure-template value, else the value unchanged.
function stripLiquidLeak(value) {
  if (!value || typeof value !== "string") return value || "";
  if (value.length > 256) return value;
  // Equivalent of /^[\s_\-+.\/|:;,]*(?:\{\{[^{}]*\}\}[\s_\-+.\/|:;,]*)+$/
  if (/^[\s_\-+.\/|:;,]*(?:\{\{[^{}]*\}\}[\s_\-+.\/|:;,]*)+$/.test(value)) return "";
  return value;
}

// 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#618 (back-port of GL#604/GL#611) — the timestamp is the FIRST-SEEN time of
// THIS fbclid, persisted in localStorage and replayed verbatim — NOT a freshly
// re-stamped wall-clock on every call.
//
// THE BUG this fixes: the prior synthesizeFbc re-stamped (new Date()).getTime()
// on EVERY event. A visitor who clicked a Meta ad three weeks ago (fbclid still
// inside our 90-day click-id localStorage) would, on a return visit +
// conversion, present an _fbc whose timestamp said "clicked just now". Meta uses
// that timestamp as the click recency; a stale click masquerading as fresh
// degrades match quality / attribution-window correctness. We stamp ONCE at
// first synthesis, persist {fbclid, firstSeenTs}, and replay it. Keyed on the
// fbclid value so a genuinely NEW click (different fbclid) correctly mints a
// fresh timestamp rather than inheriting the old click's first-seen time.
//
// 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.
const SYNTH_FBC_LS_KEY = "__admx_synth_fbc";

async function readSynthFbcRecord() {
  const raw = await readLocalStorage(SYNTH_FBC_LS_KEY);
  if (!raw) return null;
  try {
    const o = JSON.parse(raw);
    if (o && typeof o.fbclid === "string" && o.fbclid && typeof o.firstSeenTs === "number") {
      return { fbclid: o.fbclid, firstSeenTs: o.firstSeenTs };
    }
  } catch (e) {}
  return null;
}

async function writeSynthFbcRecord(fbclid, firstSeenTs) {
  // Hand-built JSON (no nested template) so the resulting snippet body stays
  // Shopify-editor-safe + autolinker-immune. Escape any embedded quote.
  const safeId = String(fbclid).replace(/"/g, "");
  await writeLocalStorage(SYNTH_FBC_LS_KEY, '{"fbclid":"' + safeId + '","firstSeenTs":' + firstSeenTs + '}');
}

async function synthesizeFbc(fbclid) {
  if (!fbclid) return "";
  const now = (new Date()).getTime();
  const rec = await readSynthFbcRecord();
  let firstSeenTs;
  if (rec && rec.fbclid === fbclid) {
    firstSeenTs = rec.firstSeenTs;
  } else {
    firstSeenTs = now;
    await writeSynthFbcRecord(fbclid, firstSeenTs);
  }
  return "fb.1." + firstSeenTs + "." + fbclid;
}

// GL#619 — Google first-party click cookies (Shopify Worker context). Google
// Ads' gtag writes the click id into _gcl_aw (search) and _gcl_dc (display) on
// the merchant's own domain in the format GCL.<unixSeconds>.<gclid>. We extract
// ONLY the trailing gclid token and feed it into the EXISTING gclid slot, so it
// joins the same 90-day click-id persistence + the same gclid server column with
// ZERO schema changes. Google analogue of the Meta cookie read above: a URL
// ?gclid= still wins (most-recent click); the cookie recovers attribution on a
// LATER session where the visitor returns without the param. The gclid itself
// can contain dots, so we split on the FIRST two separators only and keep the
// remainder. Returns "" when absent/malformed.
function extractGclidFromGclCookie(raw) {
  if (!raw || typeof raw !== "string") return "";
  const firstDot = raw.indexOf(".");
  if (firstDot < 0) return "";
  const secondDot = raw.indexOf(".", firstDot + 1);
  if (secondDot < 0) return "";
  const gclid = raw.substring(secondDot + 1);
  return gclid ? gclid.substring(0, 200) : "";
}

async function readGclidFromCookies() {
  const aw = extractGclidFromGclCookie(await readCookie("_gcl_aw"));
  if (aw) return aw;
  return extractGclidFromGclCookie(await readCookie("_gcl_dc"));
}

// GL#619 — TikTok _ttp first-party cookie (Shopify Worker context). TikTok's
// Pixel JS mints _ttp (a stable per-browser id, NOT derivable from any URL
// param — the TikTok analogue of Meta's _fbp). Forwarded verbatim as the ttp
// field alongside ttclid, mirroring how _fbp rides with fbclid. The 300-char cap
// mirrors the fbc/fbp caps. SERVER NOTE: there is no ttp ingest column yet (only
// ttclid), so the ingest zod schema strips ttp today; captured + shipped now so
// it lights up the day a ttp column is added — no merchant repaste needed.
async function readTikTokTtp() {
  const raw = await readCookie("_ttp");
  return raw ? String(raw).slice(0, 300) : "";
}

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];
    // GL#618 — strip {{...}} Liquid leaks before storing so an unsubstituted
    // utm_campaign={{campaign_name}} never ships as a fake campaign row.
    if (params[k]) out[k] = stripLiquidLeak(params[k]);
  }

  // GL#618 — ax_* vendor-override params (last-touch, current URL). Same set
  // the storefront pixel ships. Carried on every event so server-side joins
  // can match a campaign by platform object-id even when utm_campaign is an
  // ad name or a prefix-stripped variant.
  for (let i = 0; i < AX_NAMES.length; i++) {
    const ak = AX_NAMES[i];
    if (params[ak]) out[ak] = stripLiquidLeak(params[ak]);
  }

  // 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;
  }

  // GL#619 — Google first-party cookie gclid fallback. If neither the URL
  // (?gclid=) nor our own 90-day click-id store produced a gclid, recover it
  // from Google's _gcl_aw / _gcl_dc cookies. URL/store value already won above
  // (most-recent click); this only fills the gap on a later session where the
  // visitor returns without the param. Persist it so subsequent events ship it.
  if (!out.gclid) {
    const cookieGclid = await readGclidFromCookies();
    if (cookieGclid) {
      out.gclid = cookieGclid;
      await persistClickId("gclid", cookieGclid);
    }
  }

  // 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#618 (back-port of GL#604) — Meta _fbc / _fbp resolution, cookie-FIRST.
  // (1) _fbc: prefer the REAL first-party _fbc cookie Meta's own Pixel JS (fbq)
  //     sets — it is the authoritative value Events Manager expects and carries
  //     Meta's own click timestamp. Only when there's no cookie do we synthesize
  //     from fbclid, and that synthesis reuses a PERSISTED first-seen timestamp
  //     (see synthesizeFbc) so a weeks-old click is not re-stamped as brand-new.
  // (2) _fbp: NOT derivable from any URL param — Meta mints it client-side. The
  //     first-party _fbp cookie is the ONLY source, forwarded verbatim.
  if (!out.fbc) {
    const realFbc = await readMetaCookie("_fbc");
    if (realFbc) {
      out.fbc = realFbc;
    } else if (out.fbclid) {
      out.fbc = await synthesizeFbc(out.fbclid);
    }
  }
  if (!out.fbp) {
    const realFbp = await readMetaCookie("_fbp");
    if (realFbp) out.fbp = realFbp;
  }

  // GL#619 — TikTok _ttp first-party cookie, read verbatim. Mirrors the _fbp
  // read above: a stable per-browser id with no URL-param source, so the cookie
  // is the only place to get it. Ships alongside ttclid as _fbp ships with
  // fbclid. Dropped server-side until a ttp column exists (see readTikTokTtp).
  if (!out.ttp) {
    const realTtp = await readTikTokTtp();
    if (realTtp) out.ttp = realTtp;
  }

  // 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. GL#618 —
  // also persist ax_source so the first-touch row carries the vendor override.
  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,
      ax_source: out.ax_source || "",
      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);
  // GL#618 — drain any conversion POST that failed before a prior redirect.
  // The post-checkout thank-you pageview is the most common drain point.
  await flushRetryBuffer();
  const referrer = event.context.document.referrer;
  const attr = await captureAttribution(loc, referrer);
  send(Object.assign({
    website_id: WEBSITE_ID,
    host: loc.hostname,
    snippet_version: SNIPPET_VERSION,
    visitor_id: cachedVisitorId,
    session_id: cachedSessionId,
    event_type: "pageview",
    path: loc.pathname,
    referrer: referrer,
  }, attr));
});

analytics.subscribe("checkout_completed", async function (event) {
  // Fail-soft wrapper (audit 2026-07-02). A malformed or partial checkout
  // payload (for instance a missing totalPrice) must never throw out of this
  // async subscriber: an uncaught throw here can break the pixel for the rest
  // of the session, silently killing subsequent page_viewed / product_viewed
  // tracking. Wrapping the whole body isolates one bad payload — that single
  // conversion may be lost, but tracking stays alive. No rethrow. Sister rule
  // to the GL#307 destructure mandate: robustness the merchant never has to
  // think about.
  try {
    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;
    // Guard the one field accessed unconditionally below: parseFloat of
    // totalPrice.amount throws if totalPrice is absent. A payment with no
    // total is not a usable conversion, so bail cleanly rather than throw.
    if (!totalPrice) return;
    const totalAmount = totalPrice.amount;
    if (typeof totalAmount !== "string" && typeof totalAmount !== "number") return;
    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]);
    }

    // GL#618 — sendReliable (keepalive + retry buffer). The conversion POST fires
    // moments before the thank-you redirect; without keepalive the browser
    // cancels it. A network reject parks the payload for replay on the next event.
    await sendReliable(Object.assign({
      website_id: WEBSITE_ID,
      host: loc.hostname,
      snippet_version: SNIPPET_VERSION,
      visitor_id: cachedVisitorId,
      session_id: cachedSessionId,
      event_type: "payment",
      amount_cents: Math.round(parseFloat(totalAmount) * 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));
  } catch (e) {}
});

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,
    snippet_version: SNIPPET_VERSION,
    visitor_id: cachedVisitorId,
    session_id: cachedSessionId,
    event_type: "custom",
    goal_name: "product_viewed",
    product_id: productId,
  }, attr));
});

Meta token (optional — write access only)

# Optional — only if you want Admaxxer to change campaigns for you.
# (Read-only? Use "Connect with Facebook" instead — one click, no token.)
# 1. Visit https://developers.facebook.com/tools/explorer
# 2. Pick your app, request ads_read + ads_management + read_insights
# 3. Generate a User Access Token, then exchange it for a long-lived token
# 4. In Admaxxer: Integrations → Meta → "Paste a token" tab → paste it
#
# The token is encrypted with AES-256-GCM before it is stored.
ACCESS_TOKEN="EAAB...your-long-lived-meta-user-token..."

Everything in one workspace

Frequently asked questions

Q: How long is the free trial and what's included?

7 days, no credit card required, on the Starter plan: 15,000 tracked events a month, unlimited ad connections, unlimited AI chat with your own key, and 1 team member. Upgrade or cancel any time.

Q: What does BYOK mean for the AI agent?

Bring Your Own Key. Add a key from any of 11 supported providers — including OpenRouter, Anthropic, OpenAI and Google — and the agent uses that model. We never resell tokens: you pay your provider directly and keep control of your limits.

Q: Which platforms can the dashboard read from?

Meta Ads, Google Ads and TikTok Ads; Klaviyo for email revenue; Shopify, Stripe, Paddle, LemonSqueezy, Polar, Dodo Payments and WooCommerce for revenue; and our first-party pixel for pageviews and sessions. Amazon Ads and Pinterest Ads are coming soon. Cross-platform attribution is built in.

Q: Is it safe to connect my Meta account?

Yes. Connect with Facebook grants read-only access in one click; a pasted token is only needed if you want Admaxxer to change campaigns. Tokens are encrypted at rest with AES-256-GCM, every request draws from a per-connection budget capped at Meta's documented limit, and we remind you before a token expires so you can renew it in one click. You can revoke access any time from your Meta Business Settings.

Q: Where do I find my website ID for the pixel install?

Open Dashboard → Pixel (/dashboard/pixel). The ID is at the top of the snippet card, on the line starting data-website-id="admx_...". Copy the full value — it always starts with admx_ — and paste it wherever the install snippet shows admx_YOUR_WEBSITE_ID.

Quick resources

Start your 7-day free trial · All documentation