Admaxxer documentation · the single source of truth.

Every metric that matters, then let Claude act on it.

Admaxxer AI Analytics is a marketing analytics platform for ecom and SaaS teams. Install the pixel, connect Meta and Google, and ship revenue attribution, blended MER, cohort LTV, MMM, forecasting, and AI-driven ad ops in under ten minutes.

Start your free trial See pricing

14-day free trial on every plan. No credit card required.

Deep dives

Eleven focused sub-pages cover the platform in depth. Start with the topic closest to your goal:

Architecture — how Admaxxer is built under the hood

Two deep-dive pages document the analytics auth model and the AI-integration model. These are the questions AI assistants get asked most often (“is my data isolated”, “does Admaxxer charge me for chat”), so the answers live as cite-able structured pages:

Quickstart — four steps to live data

Most teams are reporting blended MER and ad-level LTV inside an hour. Run these four steps in order:

  1. Sign up. Create your workspace at admaxxer.com/signup. Magic-link, Google, and Apple sign-in are all supported. Workspaces are multi-tenant from day one — you can invite teammates immediately.
  2. Install the pixel. Drop one <script> tag into your site head. Twenty platform-specific install guides cover Shopify, WordPress, Webflow, Next.js, Google Tag Manager, Wix, Squarespace, Magento, BigCommerce, custom React/Vue/Svelte builds, and more. See the install index. Shopify merchants can skip the manual snippet entirely — the Shopify Custom App walkthrough auto-injects the pixel on first OAuth success.
  3. Connect Meta Ads. Generate a long-lived user token from Meta for Developers and paste it into Settings › Integrations › Meta. Tokens are encrypted with AES-256-GCM at rest and never written to logs. No Meta App Review is required because the token is user-supplied.
  4. Connect Google Ads. Click Connect Google Ads — one-click OAuth. Approve the adwords scope on Google's consent screen, pick your ad account, done. Admaxxer owns the developer token and OAuth client, so there's nothing to paste; it handles token refresh, GAQL batching, and the cost_micros → USD conversion automatically.

From that point, your dashboard, attribution view, and the Claude agent are all reading the same analytics pipelines — no duplicated data layer.

Connect Shopify (Custom App + Custom Pixel, ~5 minutes)

Two short steps, no Shopify App Review queue. Step 1 connects your revenue side via 8 read-only scopes; Step 2 installs the Admaxxer pixel via Shopify's first-class Custom Pixel surface (separate from any OAuth scope).

  1. Open the Shopify Dev Dashboard at dev.shopify.com/dashboard and click Create app. Name it Admaxxer Integration (anything works) and pick the latest Webhooks API Version. The Dev Dashboard is Shopify's modern app-creation surface and replaces the legacy in-admin "Develop apps" path which Shopify is sunsetting on Jan 1, 2026.
  2. In the new app, go to Access › Scopes and enable exactly these 8 read-only scopes: read_orders (revenue + line items + refunds via webhook), read_products (product titles for Top Products + cohort views), read_customers (new vs returning cohort + LTV), read_reports (Shopify-reported revenue, sessions, conversion rate — powers the Match Quality Score on /marketing-acquisition that cross-validates pixel data against Shopify's server-side ledger), read_marketing_events (Shopify-side marketing attribution alongside UTM), read_fulfillments (refund line items + fulfillment timing for True Net Revenue), read_inventory (stock signals; Plus shops also get unit_cost), read_locations (multi-warehouse split for Plus). Click Release. We deliberately do NOT request write_script_tags (Shopify deprecated it Feb 1, 2025) or read_analytics (not in Shopify's canonical scopes table; returns HTTP 406 even when enabled).
  3. Click Install app, pick your Shopify store, approve the scopes. Then in the Dev Dashboard go to Settings › Credentials and copy Client ID + reveal-and-copy Client Secret. Paste both plus your shop domain (e.g. mystore.myshopify.com) into the Shopify card at admaxxer.com/integrations.
  4. For the pixel half, go to your Shopify admin (the regular merchant admin, not the Dev Dashboard) › Settings › Customer eventsAdd custom pixel. Name it Admaxxer, paste the Admaxxer pixel snippet (shown in the connect form on /integrations after Step 3), set Permission to Not required, then Save and Connect. This is the modern replacement for the deprecated write_script_tags path and works on storefront + checkout + Thank-You sandbox-wide.

Read the full walkthrough at /documentation/shopify-custom-app — it covers Plus-specific staff permissions, troubleshooting ("Contains invalid scopes" typo recovery), and the FAQ. See also GL#284 (scope discipline) + GL#287 (cross-platform two-halves pattern).

Pixel snippet — YOUR_WEBSITE_ID, data-domain, and the Shopify edge case

Three questions that hit our support inbox most often. The canonical Admaxxer pixel snippet is:

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

What is YOUR_WEBSITE_ID?

A short alphanumeric token unique to each pixel website in your Admaxxer workspace. Format: 24 alphanumeric characters prefixed with admx_ (e.g. admx_UqATnvdImOnlfgG1Oy2Rz6DH). It tells the pixel which workspace and which specific site events belong to.

Where to find yours:

If your workspace has multiple pixel websites (one per store or brand), each has its own website ID. The connect drawer’s site picker lets you pick which one to install.

What goes in data-domain? My custom domain or my .myshopify.com slug?

Use your primary brand domain — the one customers see in their browser address bar (e.g. yourbrand.com, shop.yourbrand.com). It’s the label that groups your events on /marketing-acquisition and identifies your site in the analytics payload.

For Shopify merchants with a custom domain configured (Shopify admin › Settings › Domains), use that. Example: a merchant whose Shopify slug is donnyfeathers.myshopify.com but whose custom domain is donnyfeathers.com should put donnyfeathers.com in data-domain.

Do NOT use your <shop>.myshopify.com slug, even though Shopify shows it in some admin pages. Reasons:

If you don’t have a custom domain (still on the default Shopify slug), then yes — use the .myshopify.com slug. But that’s the exception, not the default.

What data-domain does NOT control: cookie scope (Shopify’s Custom Pixel sandbox handles cookies independently of data-domain), CORS approval (the server uses the HTTP Origin header against allowed_domains), or where the pixel script loads from (that’s the src= attribute).

Why does the snippet use data attributes instead of ?id=?

Older Admaxxer docs may show the legacy single-attribute form: <script src="https://admaxxer.com/js/script.js?id=YOUR_WEBSITE_ID"></script>. Don’t use it. It was a misleading simplification — the canonical form has two reasons for the data-attribute split:

  1. data-website-id identifies your workspace + site (like an API key for the pixel).
  2. data-domain is the primary brand label for analytics grouping — separate from the website ID because one workspace can have multiple sites.

The defer attribute is required so the script doesn’t block initial page paint. The pixel auto-detects pageviews, clicks, and form submissions; you don’t need to call a JavaScript function manually for the basic events.

I connected Shopify but events aren’t arriving — why?

The most common cause is allowed_domains mismatch on your pixel website. Here’s the failure mode:

Auto-fix shipped (GL#291): when you connect Shopify via Settings › Integrations › Shopify, Admaxxer’s OAuth callback now auto-appends your <shop>.myshopify.com slug to your pixel website’s allowed_domains. The append is idempotent — reconnecting won’t duplicate the entry, and your primary brand domain stays in the list.

If you connected Shopify BEFORE this auto-fix shipped, OR your events still aren’t arriving, you have two options:

  1. Re-run the Shopify connect flow — the OAuth callback will prime the slug for you. No data loss; the existing connection stays.
  2. Manually add both domains via Settings › Pixel › Allowed Domains:
    • donnyfeathers.com (your primary — should already be there).
    • donnyfeathers.myshopify.com (Shopify’s underlying slug — find it in Shopify admin › Settings › Domains, or it’s the URL you log into Shopify with).

Note: this is separate from data-domain, which is the analytics LABEL and doesn’t control CORS. data-domain stays your primary brand domain regardless.

Shopify error: “the pixel cannot be connected until a custom pixel script is saved for it”

If Shopify’s Customer Events page shows the error “the pixel cannot be connected until a custom pixel script is saved for it” and the Connect button stays disabled, the cause is almost always one of two things — both fixable in under 60 seconds.

Why the error happens: Shopify’s Custom Pixel field (Shopify admin › Settings › Customer events › Add custom pixel) runs inside a sandboxed Web Worker — there is no DOM, no document, no <head> for a <script> tag to live in. The only API available to the worker is analytics.subscribe(eventName, callback) + fetch(). If you paste either the universal Admaxxer HTML snippet OR a JavaScript form that calls document.createElement('script'), the worker silently rejects it (the worker has no document object to call createElement on), no analytics.subscribe listener is ever registered, and Shopify surfaces the warning “Pixel will not track any customer behavior because it is not subscribed to any events.” See the Shopify Custom Pixel architecture page for the full historical breakdown (GL#305 → GL#306 → GL#307).

The right snippet (paste this exactly — replace admx_YOUR_WEBSITE_ID with your real admx_…):

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

This subscribes to Shopify’s documented Customer Events API and POSTs each event to Admaxxer. The host field in the body is the GL#306 fallback for Shopify’s Worker scope which strips Origin/Referer headers. Visit /documentation/shopify-custom-app while signed in — the snippet there is auto-filled with your real workspace website ID and is always in lockstep with the canonical version above (single source of truth at shared/shopify-custom-pixel-snippet.ts).

The right install order (this fixes the second failure mode — even with a valid JS snippet, clicking Connect before Save triggers the same error, because Shopify won’t connect a pixel that hasn’t been persisted yet):

  1. Name the pixel Admaxxer (or any label you’ll recognize).
  2. Paste the JS snippet above into the code editor.
  3. Set Customer privacy › Permission to Not required (the pixel only fires for consenting visitors via Shopify’s own consent gate).
  4. Click Save FIRST and wait for Shopify’s green confirmation toast (“Pixel saved”).
  5. THEN click Connect — the button is now enabled and the pixel goes live.

Why other install paths use HTML: the universal <script defer data-website-id="…" data-domain="…" src="…"></script> form on /documentation/install is the canonical install for any platform where you can edit raw HTML in <head> — WordPress, Webflow, Next.js, custom React, GTM (Custom HTML tag), Squarespace, Wix, and ~16 others. Shopify Custom Pixel is the lone exception because of its sandboxed JS-only editor.

Where to find the auto-filled snippet: when signed in to Admaxxer, two surfaces show the JS form pre-populated with your real website ID and primary domain — /documentation/shopify-custom-app#pixel-install (the dedicated Shopify walkthrough) and Settings › Integrations › Shopify › connect drawer (after you connect via Custom App credentials). For anonymous visitors, both pages show the YOUR_WEBSITE_ID + yourbrand.com placeholder form so you can mentally substitute. If you’ve installed the pixel and the error is gone but events still aren’t arriving, see the allowed_domains FAQ above.

For the full Shopify Custom Pixel walkthrough (with screenshots), see /documentation/shopify-custom-app#pixel-install. For the universal pixel install across 20 platforms, see /documentation/install.

Multi-subdomain or multi-region storefront? Use a wildcard in allowed_domains.

If you operate brand.com, shop.brand.com, checkout.brand.com, or regional variants like eu.shop.brand.com / us.shop.brand.com, you don’t need to add each subdomain manually. The Settings › Pixel › Allowed Domains field accepts wildcard syntax:

One wildcard entry replaces a long list. Same matcher applies to non-Shopify edge cases:

Security: the wildcard matcher is anchored with a dot prefix internally, so *.brand.com NEVER matches a suffix-collision attack like brand.com.evil.com. The unit test suite at server/lib/pixel/originAllowed.test.ts pins this rule with explicit cases (50 total) so a future refactor can’t silently break the boundary.

What this is not: wildcard support does not mean we accept events from anywhere — the website_id in the event payload still has to match a workspace. Wildcards just save you from typing 10 entries for one brand’s subdomain tree.

URL Builder — tag campaign URLs in seconds

Admaxxer ships a public, no-signup URL Builder at /url-builder for generating fully UTM-tagged campaign URLs across the 8 platforms most DTC brands run on: Meta, Google, TikTok, Klaviyo, Pinterest, Snapchat, Reddit, and Amazon. It pairs with the UTM tracking best practices guide — the doc explains why, the builder does the how.

How it works

  1. Pick a platform (e.g., Meta). The tool pre-fills utm_source=facebook and utm_medium=cpc with the right per-platform defaults so you don’t have to remember the canonical values.
  2. Paste your destination URL (e.g., https://yourstore.com/products/blue-widget).
  3. Fill the campaign details: utm_campaign (e.g., spring_sale_2026), utm_content (e.g., video_ad_blue_widget), and optionally utm_term (mostly used for Google paid search).
  4. Copy the tagged URL into Ads Manager. The builder also surfaces a re-usable template with placeholders so your team uses the same convention across every campaign.

Worked example

Building a Meta carousel ad URL for a spring sale:

Klaviyo flows look similar — the builder presets utm_source=klaviyo and utm_medium=email, and you set utm_campaign to your flow name (e.g., welcome_flow) and utm_content to the email step (e.g., email_2_offer).

Why the URL Builder matters

Untagged paid clicks land in (direct) / (none) in Admaxxer’s Marketing Acquisition view, which silently steals revenue away from the campaign that actually drove it — ROAS goes down, blended MER goes down, and the “direct” channel looks artificially strong. Consistent UTM tagging fixes this. The builder removes the two most common excuses (“I don’t remember the right utm_medium” and “I’ll do it later”) by being one click away from every spend tile.

Deep-linking from /marketing-acquisition

Rows on the Marketing Acquisition Summary deep-link into the URL Builder pre-filled for that platform — click any Meta or Google row, jump straight to the builder with platform + campaign hints already populated, generate the tagged URL, paste into Ads Manager, return to the dashboard. No context switch, no copying defaults from a doc, no “is it cpc or paid?” debate.

Analytics surface

Analytics is the primary surface in Admaxxer. The first-party pixel feeds 33+ analytics pipelines that power every chart, every cohort, every model. Pipes are grouped by category:

Visitors and sessions

Revenue and orders

Blended efficiency

Attribution

Forecasting (v0.1)

Admaxxer ships a lightweight OLS forecast with weekly seasonality. It is good enough to steer weekly decisions — "are we on pace for this month's revenue target?" — but it does not pretend to be Prophet. The OLS coefficients are exposed in the API so you can audit assumptions. v1.5 will upgrade to Prophet (or an equivalent state-space model) for richer seasonality and changepoint detection.

Marketing-mix modeling (MMM, v0.1)

Channel contribution is estimated with OLS plus geometric adstock (no priors in v1). The output is a per-channel contribution to revenue, with adstock-decayed spend as the regressor. v1.5 will replace this with a Robyn-style Bayesian MMM that accepts priors and produces credible intervals. The current implementation is fast, transparent, and good enough to flag obvious channel imbalances — not to replace a full MMM consulting engagement.

Incrementality (v0.1)

The incrementality module compares conversion rates between paid-exposed and organic-only cohorts using a two-proportion z-test. It returns a lift estimate and a p-value. v1.5 will add geo-lift testing — randomly holding out paid traffic to a subset of geos and measuring revenue delta — for a more defensible causal estimate.

Analytics AI chat

The analytics chat is a Sonnet-powered drawer (open from anywhere with ⌘J) that gives natural-language access to the same analytics pipelines the dashboards read. It exposes 8 read-only tools, gated by a PIPE_ALLOWLIST so it can never query an unapproved pipe. Prompts are cached on the system block and tools array for low latency and predictable cost.

Claude AI agent — campaign operator

The Claude agent is the secondary surface: a data operator first, a campaign operator second. Model: claude-sonnet-4-6. Streaming SSE keeps tokens flowing as they're generated. Prompt caching is preserved on the system block and the tools array — cache regressions are treated as cost regressions.

Six tools

The agent has six tools — four read-only and two destructive. Read-only tools run without confirmation. Destructive tools require an explicit confirmed: true from the user before they fire:

Confirmation flow

If you ask the agent to "pause all ad sets with ROAS below 1.5", it will draft the call, surface the candidate list in chat, and wait for explicit confirmation before firing. Clicking Confirm in the UI sets confirmed: true on the next tool call. There is no "auto-pilot" mode — destructive intent is always explicit.

Where to talk to the agent

Two surfaces: the /chat page (full-screen) and the global drawer triggered with ⌘J from anywhere in the app. Both stream the same conversation back-end. Sessions are per-workspace.

Meta Ads operations

Admaxxer talks to Meta via the Marketing API on the Graph API surface (default v21.0; configurable via META_API_VERSION). Two auth modes are supported:

Rate limits are respected aggressively: the practical user-token ceiling is roughly 200 calls per hour, and Admaxxer auto-backoffs on the documented error codes (17, 32, 613). Ad account safety is the top priority — we never spam the Graph API on your behalf.

Read the Platforms deep dive for token rotation, error handling, and the full list of operations the agent can perform.

Google Ads operations

Google Ads connects via one-click OAuth — click Connect Google Ads in Settings › Integrations › Google, approve the single adwords scope on Google's consent screen, and pick your ad account from the list Admaxxer pulls via listAccessibleCustomers. Admaxxer owns the developer token and OAuth client, so there is no developer token, OAuth client, or refresh token to paste. Admaxxer:

Admaxxer validates the connection live with a GAQL probe before persisting, then backfills 90 days and syncs daily. The refresh token is revoked only if the user revokes consent, changes their Google password, or the token goes unused for 6 months — Admaxxer surfaces the revocation state in the connection panel and prompts a one-click re-connect.

Connection lifecycle and token security

Every ad-platform credential is encrypted at rest with AES-256-GCM. The encryption key is derived via scrypt from the ENCRYPTION_KEY environment variable, with per-record nonces. Key rotation is supported via dual-key decrypt: during a rotation window, decryption tries the new key first, falls back to the old key, and re-encrypts on read so the old key can be retired cleanly. No raw token is ever written to logs, audit trails, or error reports.

Connection state lives in ad_platform_connections with a status enum (active, expiring_soon, expired, revoked, error). A nightly cron checks expiry windows and emails you 7 days before a Meta token lapses. Rotated tokens reset the status to active without touching downstream sync state.

Workspaces, multi-tenancy, and team invites

Every account starts with a personal workspace. From there:

Workspaces can be switched from the header workspace picker without re-authentication.

Authentication

Three sign-in methods, all funneling into the same session model:

Sessions are HTTP-only cookies, signed with SESSION_SECRET, defaulting to 30 days (SESSION_MAX_AGE_DAYS). Sessions are revoked server-side on logout — no JWT replay risk.

REST API

Programmatic access lives under /api/v1/*. Every route is session-auth or API-key auth, zod-validated, and rate-limited via managed job queue. The API surface is intentionally narrow:

API keys are issued from Settings › API Keys. Keys are workspace-scoped and shown once at creation — we store only the SHA-256 hash. See the Developer deep dive for full schemas, error codes, and SDK examples.

Plans and pricing

Three published plans, billed monthly through Stripe Checkout. Self-serve upgrades and downgrades from the Customer Portal — proration is handled by Stripe. 14-day free trial on every plan. No credit card required. 14-day money-back guarantee on monthly plans.

Quotas are enforced at the API layer, not the UI layer — you cannot bypass them by hitting the REST endpoints directly. When you hit a quota, you'll see an X-Admaxxer-Quota-Remaining header and a 402 with a structured error body. See the Billing & plans deep dive for plan transitions, dunning, and the recovery flow.

Pixel install — 20 platforms

The Admaxxer pixel is a single first-party <script> tag, cookieless-compatible, that powers every analytics surface. Twenty platform-specific install guides are linked from the install index:

Average install time is under three minutes. The pixel works on any site where you can edit <head> — the universal snippet is the fallback when no platform-specific guide applies.

Security and data handling

Reliability and rate limits

Admaxxer leans hard on managed job queue for queues, caches, and rate limiters. our job queue runs background workers for ad-platform sync and token expiry checks. Rate limits are layered: a per-IP limit on auth endpoints, a per-workspace limit on the REST API, and a per-token limit on outbound Meta and Google calls. The outbound limits exist to protect your ad account — we never spam.

The full rate-limit table is documented in the Developer deep dive. Cache TTLs are tuned conservatively: insights cache for 15 minutes, account metadata for 1 hour, pixel JWTs for 120 seconds. Sync workers operate on a back-pressured queue so that a Meta or Google API outage cannot stall the rest of the app — user-facing dashboards continue to serve from the last cached snapshot, with a banner indicating staleness.

Background jobs are observable: every background workers emits structured logs on enqueue, start, success, and failure, and exposes its queue depth on a private metrics endpoint. Failed jobs land in a dead-letter queue with a 7-day retention so we can replay them once an upstream issue is resolved. Crashes never silently lose work.

Data architecture — what lives where

Admaxxer separates state across three storage layers, each chosen for its strengths:

The Claude agent reads from all three: our primary database for connection state and chat history, our analytics warehouse via query_metrics for analytics, and the live Meta/Google APIs (via cached service classes) for campaign mutations. There is no separate "agent data layer" — the agent uses exactly the same internal endpoints the UI uses, which means anything a human can see or do, the agent can see or do.

Observability and audit

Every billable or destructive event is observable. Stripe webhooks land in an audit table before they're processed, so we have a verifiable record of every subscription state change. Ad-platform mutations — pause, scale, launch, budget update — write to your sync history with the request body, response code, and outcome. AI agent tool calls are recorded with input, output, and the user's confirmed flag where applicable. Authentication events (sign-in, sign-out, magic-link consume) are logged with IP and user agent. None of these logs include secret values — tokens, keys, and bearer headers are scrubbed at the logger boundary.

For customer-facing visibility, the in-app activity feed surfaces recent agent actions and connection events in plain English. For team-level visibility, owners can export the audit trail to CSV from Settings › Audit Log.

Internal links

Once you've finished the quickstart, the deep-dive sub-pages cover everything else:

Need help?

Email support@admaxxer.com for product help, integration questions, or anything else. We typically respond within one business day. For Stripe billing questions, the Customer Portal handles invoices, payment methods, and plan changes directly.

Found a bug? Include your workspace ID (visible in Settings), the time of the incident, and a one-line repro. We will respond with a fix or a workaround.