Why the script matters
The tracking script is the foundation of your unified data platform. Everything the dashboard shows — visitors, conversion rate, revenue, leads, attribution — and everything an agent reads through get_analytics starts as an event from this script. That is why it is the *single required step* in onboarding: connect zero tools and you still get a real analytics platform; skip the script and there is no spine for the rest to hang on.
Install the snippet
Add one line to the <head> of every page you want measured:
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>Your real data-tracker id is shown in onboarding, on the Connections page, and on the Overview empty state — copy it from there. The script auto-tracks pageviews, including client-side route changes in single-page apps, and classifies each visit's channel, device and referrer for you. Origin is verified against your registered domain, so events only count from the site you own.
Install on your platform
The tag is the same everywhere; only where you paste it changes. Single-page apps need nothing extra — pushState, replaceState and popstate are hooked, so client-side route changes count as pageviews.
Google Tag Manager — Tags → New → Custom HTML, trigger "All Pages", publish:
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>WordPress — no plugin needed. In your child theme's functions.php, enqueue the script and let the tag filter add defer and the tracker id:
add_action('wp_enqueue_scripts', function () {
wp_enqueue_script('businessmcp', 'https://businessmcp.com/track.js', [], null, false);
});
add_filter('script_loader_tag', function ($tag, $handle, $src) {
if ($handle !== 'businessmcp') return $tag;
return '<script defer src="' . esc_url($src) . '" data-tracker="YOUR_TRACKER_ID"></script>';
}, 10, 3);Shopify — Online Store → Themes → Edit code → layout/theme.liquid, paste before </head>. Shopify's checkout runs on its own domain, so record purchases from an order webhook with the server-side goal endpoint:
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>
</head>Webflow — Project settings → Custom code → "Head code", then publish:
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>Framer — Site settings → General → Custom code → "Start of head tag":
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>Next.js (App Router) — in app/layout.tsx. The tracker falls back to a script[data-tracker] lookup because next/script can null document.currentScript, so afterInteractive is safe:
import Script from 'next/script'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID" strategy="afterInteractive" />
</body>
</html>
)
}Nuxt — nuxt.config.ts:
export default defineNuxtConfig({
app: {
head: {
script: [{ src: 'https://businessmcp.com/track.js', defer: true, 'data-tracker': 'YOUR_TRACKER_ID' }],
},
},
})Astro, SvelteKit, plain HTML — the plain tag in the layout's <head> (src/layouts/Layout.astro as <script is:inline defer …>, src/app.html for SvelteKit):
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>Track goals & revenue
For conversions, call mcph.track when something meaningful happens. Pass a goal name and, for purchases, a revenue amount in cents:
<script>
// A signup completed
mcph.track('signup')
// A purchase worth $49.00
mcph.track('purchase', { revenue_cents: 4900 })
</script>Goals show up in your funnels and attribution so you can see which channels and campaigns actually drive revenue, not just traffic. Server-verified revenue from Stripe is attributed automatically through the checkout flow, so client-side revenue tracking is optional belt-and-suspenders.
Identify visitors
To stitch an anonymous visitor to a real person, call mcph.identify with an email once you know it (for example, right after signup):
<script>
mcph.identify('jordan@example.com', { plan: 'pro' })
</script>Identify events flow into the CRM rather than the raw analytics firehose, linking visitor_id → email → stripe_customer_id so a contact's full journey — first touch to lifetime value — lives in one place.
JavaScript API
Everything lives on window.mcph once the script has loaded (window.agentsuite is a back-compat alias).
mcph.track(name, meta?)— records a goal and returns its event id (nullfor an empty name). Names are lower-cased and reduced toa-z0-9_:-(64 chars max).metakeeps at most 10 keys with string values of 255 chars, 4KB in total; a numericrevenue_centsturns the goal into a revenue event.mcph.identify(email, traits?)— stitches the visitor to a person. A business email auto-derivescompany_domain, so the company is enriched with no extra config.mcph.getVisitorId()— the anonymous visitor id. Pass it into a custom checkout (Stripeclient_reference_idormcph_sidmetadata) or a hidden form field to stitch a later conversion back to this journey.mcph.variant(key, opts?)— this visitor's arm for a split test.opts.variantsis[{ id, weight }](default: an even control/b split) andopts.controlnames the fallback arm. Always returns a string, stampshtml[data-mcph-<key>]and records one exposure — see A/B test variants.mcph.getVariant(key)— the arm already assigned forkey, ornullifvarianthas not run; never records a second exposure.mcph.consent('granted' | 'denied')— fordata-require-consentinstalls.grantedpersists the choice, flushes what was queued and starts recordings without a reload;deniedpersists the choice and drops what was waiting.mcph.q.push([method, args])— call the API before the script has loaded. Queued calls replay once it loads, andq.pushkeeps working afterwards (it runs the call immediately).
<script>
// Before the tag has loaded: queue calls on a stub
window.mcph = window.mcph || { q: [] }
mcph.q.push(['track', ['newsletter_signup', { list: 'weekly' }]])
</script>// After load
const eventId = mcph.track('purchase', { revenue_cents: 4900, plan: 'pro' })
mcph.identify('jordan@acme.com', { name: 'Jordan Lee', plan: 'pro' })
mcph.getVisitorId() // 'v…'
mcph.variant('price-test', { variants: [{ id: 'control', weight: 90 }, { id: 'b', weight: 10 }] })
mcph.getVariant('price-test') // 'control' | 'b' | null
mcph.consent('granted')Every track call also dispatches a mcph:goal CustomEvent on document with { name, eventId, meta }. Pass the same id to your own Meta or TikTok pixel and the server-side conversion we forward dedupes against the browser event instead of counting twice:
document.addEventListener('mcph:goal', (e) => {
const { name, eventId, meta } = e.detail
if (name === 'purchase' && window.fbq) {
fbq('track', 'Purchase', { value: meta.revenue_cents / 100, currency: 'USD' }, { eventID: eventId })
}
})Script attributes
Everything is configured on the tag itself. Defaults are chosen so the one-line install measures a normal marketing site correctly.
data-tracker— required. Your tracker id.data-auto—"false"turns autocapture off (outbound links, downloads, form submits, mailto and tel clicks, CTA clicks, copy, JS errors, 90% scroll,data-goalclicks).data-rage—"true"turns rage-click capture on.data-internal—"1"flags every event from this install as internal (excluded from every metric).data-internal-paths— comma-separated path prefixes (/dashboard,/admin) flagged internal, checked on every beacon so a session that navigates into your app is caught.data-exclude— comma-separated path globs (/preview/*,/admin) that send nothing at all.data-widget—"false"never loads the support widget.data-autolead—"false"disables email auto-capture from native forms and the Stripe payment-link decoration.data-harvest—"false"disables the same-origin identity harvest (an email your other tools already stored on the page; consent-gated).data-require-consent—"true"holds every event untilmcph.consent('granted');"split"always sends base analytics under a session-scoped id and gates identify and recordings on consent.data-respect-dnt—"true"makes the tracker exit entirely for visitors sending Do Not Track or Global Privacy Control (off by default — see Privacy).data-hash—"true"counts a hash change as a pageview (hash-based routers).data-domains— comma list of your other registrable domains; links to them carry the visitor id across (see Cross-domain).data-allow-localhost—"true"sends from localhost and 127.0.0.1, which are dropped by default.data-iframe—"true"counts pageviews when the page is embedded cross-origin (skipped by default;mcph.trackstill works inside an embed).data-embed-origins— extra iframe form-provider hosts allowed to identify a visitor via postMessage. Heyflow, Tally, Jotform, Typeform, HubSpot forms, Paperform, Fillout, Formstack and Youform are built in.data-clarity/data-hotjar— legacy Microsoft Clarity or Hotjar project ids, loaded once alongside the tracker (only after granted consent under a consent mode).
Three markup hooks work anywhere in the page: data-mcph-goal="name" (or data-goal) on any clickable element records that goal on click; data-mcph-no-capture on a form or any ancestor suppresses identification from it (sign-in forms want this — a login is not a lead); and an inline data-mcph-form element becomes a hydrated lead form.
Attribution & engagement
The server reads utm_* and ad click ids from the beacon URL, so a goal fired after the query string was cleaned (a redirect, SPA navigation, a thank-you page) used to lose its campaign. The tracker keeps two touches client-side and sends both on every beacon as att:
- First touch (
ft) — written once on the visitor's first landing and kept for 90 days. - Last touch (
lt) — replaced whenever a visit arrives with a campaign or an external referrer; a plain direct return keeps the previous campaign, the way GA does.
Each touch carries utm_source, utm_medium, utm_campaign, utm_term, utm_content and utm_id, any ad click id (gclid, gbraid, wbraid, msclkid, dclid, fbclid, ttclid, twclid, li_fat_id, igshid, epik, srsltid), the external referrer, the landing path and a timestamp. Meta's _fbc and _fbp ride along only once identification is allowed. Safari caps script-written storage at 7 days of inactivity, so the client-side copy can expire early there — the durable first touch also lives server-side on the visitor profile, which is what the attribution report reads.
Engagement is measured on the way out. When a tab is hidden, the page is left or a single-page app navigates, an engagement beacon carries the visible time (ms) and the deepest scroll (%, in 5% steps) of the page being left. It never counts as a pageview. Nor does a URL change that only rewrites the query string; a page restored from the back-forward cache does count as a new view, and hash changes count only with data-hash="true".
Cross-domain & subdomains
The visitor id lives in the browser's storage for the origin that served the page, and the tracker sets no cookie. Two consequences follow.
- Subdomains of one site (
www.,app.,docs.) are the same site: a hop between them is never an external referral and never starts a new channel, but each subdomain keeps its own anonymous id. The journeys join into one contact the moment the person identifies on either side — a signup email, a Stripe checkout, a form. - A second registrable domain (a separate checkout or app domain) needs
data-domains. List your other domains on the tag and every click to them gets_mcph=<visitor id>.<ts>appended; the tracker on the other domain adopts that id when the link is fresh (under two minutes) and the browser has no persistent id yet, then strips the parameter. The other domain runs its own tag with its own site — the linker only guarantees the same visitor id.
<!-- on www.acme.com -->
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"
data-domains="acme-checkout.com,acmeapp.io"></script>Server-side goals & identify
Some conversions never touch the browser: a payment webhook, a job, a checkout on a provider's domain. Three public endpoints take the tracker id in the path, need no key, and are documented field by field in the API reference:
POST /api/tracker/YOUR_TRACKER_ID/goal—{ name, visitorId?, revenue_cents?, metadata? }. Pass the id you captured withmcph.getVisitorId()to stitch the journey. It is a trusted path with no bot detection, so keep it server-to-server.POST /api/tracker/YOUR_TRACKER_ID/identify—{ email, visitorId?, traits? }for funnels our script cannot see inside (a cross-origin Typeform, an off-domain Heyflow).POST /api/tracker/YOUR_TRACKER_ID/bot-hit— records the AI crawlers that fetch HTML without running JavaScript; the three-line server hook is in Track AI crawlers server-side.
curl -X POST 'https://businessmcp.com/api/tracker/YOUR_TRACKER_ID/goal' -H 'Content-Type: application/json' -d '{ "name": "purchase", "revenue_cents": 4900, "visitorId": "v8f3a2c1b", "metadata": { "plan": "pro" } }'A/B test variants
Experiments (Insights → Experiments, Scale plan) assign each visitor an arm from a pure function of their visitor id, so there is no config fetch and no flicker. Because the tracker loads deferred, the arm has to be decided by a small bootstrap that runs *before* it: an inline script that stamps html[data-mcph-<key>="b"] in <head>, remembers the arm, and hands the exposure to the tracker once it loads. Copy the exact snippet from the experiment card (it carries the key, the page path and the weights) and paste it above the tracker tag, inside `<head>` — placed after it, the arm is stamped too late:
<head>
<!-- 1. the experiment bootstrap, from the experiment card -->
<script>(function (w, d) { … })(window, document)</script>
<!-- 2. the tracker -->
<script defer src="https://businessmcp.com/track.js" data-tracker="YOUR_TRACKER_ID"></script>
</head>Then make variant B a CSS rule scoped to the attribute — it applies before first paint, and the control renders exactly what the page renders today:
html[data-mcph-hero-cta="b"] .hero .cta { background: #111; }In Next.js the site-wide <head> is app/layout.tsx: pass the bootstrap to a <script dangerouslySetInnerHTML={{ __html: … }} /> inside <head>, above your tracker <Script>. (Pages Router: the same tag inside <Head> in pages/_document.tsx; Nuxt: app.head.script in nuxt.config.ts; SvelteKit: src/app.html; Astro: the layout's <head> as <script is:inline>.) When the change needs different markup rather than CSS, read the arm with document.documentElement.getAttribute('data-mcph-hero-cta') or, after load, mcph.getVariant('hero-cta'). The bootstrap never mints a persistent id on its own: with no visitor id yet it stores a session-scoped one, which the tracker adopts, so the arm a visitor saw is the arm that is counted.
Your own domain (one subdomain for everything)
The plain script tag above already works everywhere. But because it loads from a third-party domain, some ad blockers and privacy browsers drop it — which quietly undercounts your traffic. The fix is to serve the *same* tag from a subdomain of your own site so it looks first-party. That one subdomain also serves your booking link (link.yourdomain.com/book/…) and support widget — so you only ever set up a single subdomain.
In onboarding and on the Connections page, open the Your own domain card. We derive the recommended subdomain from your own site — link.yourdomain.com — and show you the exact record to add (with a copy button); there's nothing to type. Add this single CNAME at your domain provider:
Type: CNAME
Name: link (the sub-label — we show your full subdomain in the card)
Value: cname.vercel-dns.com
TTL: Auto (or 3600)Once DNS propagates, the card flips to Installed and shows your first-party tag — https://link.yourdomain.com/track.js with the same data-tracker. Paste that tag in place of the public one: one tag per page, never both (two tags double-count). The script posts back to whichever origin served it, so nothing else changes. Your booking link then reads as https://link.yourdomain.com/book/<you>. This step is optional but recommended.
Privacy & compliance
The tracker is cookieless by design: a visitor id in localStorage instead of cookies, no device fingerprinting, a daily-rotating session salt, and coarse geolocation from request headers rather than GPS. Because it stores no personal identifiers by default and sets no tracking cookies, most sites run it without a consent banner — confirm against your own legal requirements, and where you need a gate use data-require-consent="true" or "split" with mcph.consent() from your own banner.
Do Not Track and Global Privacy Control do not stop first-party analytics by default. DNT was never given legal force, and GPC governs the sale or sharing of data with ad platforms, not a site measuring itself — so GPC is honored exactly where the law places it: US visitors who signal it are suppressed from conversion forwarding (CAPI) and ad-audience syncs (see Do Not Sell or Share). A site that wants the stricter posture adds data-respect-dnt="true", and the tracker exits entirely for visitors sending either signal.
Verify events
After installing, load a page on your site and watch the live "receiving events" indicator in onboarding or Connections flip to active — that confirms events are landing. From there, open the Overview to see visitors and sessions populate. If nothing arrives, the usual culprits are a wrong data-tracker id or an origin mismatch (the script must run on your registered domain). Once events flow, you are done — the rest of BusinessMCP builds on top automatically.
Frequently asked questions
Do I really only need the tracking script?
Yes. It is the single required step in onboarding. Connectors and your MCP endpoint are optional extras — the script alone gives you the analytics spine that visitors, conversion, revenue and attribution are built from.
Does the tracker use cookies?
No. It is cookieless and GDPR-friendly: a visitor id in localStorage, no fingerprinting, no tracking cookies. Do Not Track does not stop it by default (add data-respect-dnt="true" if you want that), and Global Privacy Control is honored on the ad-sharing layer for US visitors. You typically do not need a cookie banner for it.
Will it slow down my site?
No. It is one deferred script (about 26KB gzipped — unminified on purpose so you can read what runs), it loads after your page, and it sends events with sendBeacon so it never delays page load or unload.
Keep going
Turn your company into one AI-ready data platform on a single hosted MCP endpoint.