Integration guide

Tracking, in plain terms.

One script tells you where your customers came from and, behind your login, which of them are still showing up. Here’s exactly what it collects, how to install it, how to wire it to your cookie banner, and which parts are yours rather than ours.

What the script collects

It fires only when a visit carries a real attribution signal. Someone who types your domain and lands on your home page isn’t tracked at all — no request, no cookie, no row. That’s deliberate, and it’s the biggest thing this page has to tell you.

When a signal is there, here’s the whole list. Write your privacy policy from it, and send your lawyer this page.

What we store, when a signal is there

  • The campaign tags on the URL. utm_source, utm_medium, utm_campaign, utm_term, utm_content, and the id variants. Plus the channel we sort the visit into from them — paid search, organic social, email, referral.
  • The ad click id that brought them. gclid, gbraid, wbraid from Google; fbclid, msclkid, li_fat_id, ttclid, twclid, sclid, dclid from paid social and display.
  • The site they came from, and the page they landed on. Both in full: the landing URL including its query string, and the referring URL, plus the referring hostname and the landing path pulled out of them. Nothing about either page’s contents. If you put anything personal in a URL — an email in a query parameter, a token in a path — it lands here, so don’t.
  • The request’s own details. IP address, and the country and city we derive from it. User agent, timezone, browser language, and screen size. Plus when it happened, by their clock and by ours. This is the set that tells you a campaign lands in Berlin on phones, and it’s also what an ad platform wants for match quality if you turn forwarding on.
  • The consent answer it was collected under. Whether a banner was answered yes or none was required, and the jurisdiction we placed the visitor in. Kept beside the touchpoint because it’s only true of the moment it was given, and it’s what a conversion checks before it can be forwarded.
  • The Meta Pixel cookie, if you run one. The value of _fbp, read from cookies your own domain already set. We don’t create it and don’t load the Pixel; if it isn’t there, nothing is stored. It exists so a conversion you forward to Meta matches.
  • A random UUID. Stored in a first-party cookie (_bm_id) set by your own domain, so a visitor who comes back in sixty days is still the same visitor.
  • Behind your login: which billing customer is active, and session timing. The customerId you pass us, a session start, and how long the previous session ran.
  • Custom events you fire yourself. A customer id and an event name. Nothing else rides along.
  • Survey answers, if you run a survey. The answers your customer types or picks, against their billing customer id and the question they answered. Saved from the first answer, so a survey someone abandons halfway still keeps what they said. Nothing about the person at the keyboard — the answer belongs to the account, same as everything else here.
  • At signup, whatever you hand identify. The email and your own user id, and only if you make the call.

What the script keeps on the device

  • _bm_id — the visitor UUID, a cookie, set by the server (below).
  • _bm_c — in localStorage: the answer the visitor gave your banner, so your banner asks once. Written whenever they answer, including “denied”, because a no we forgot is a no we’d re-ask.
  • _bm_ft — in localStorage: their first landing URL, referrer and timestamp, so attribution survives a hop between your subdomains. Written only after consent.
  • _bm_s — in localStorage, product pages only: the running session (customer id, last-active time, seconds counted), so two tabs are one visit. Written only after consent.
  • _bm_sv — in localStorage, product pages only: the surveys this browser closed without answering, and when, so a survey someone waved away doesn’t come back on the next page. Written only after consent, and only if they close one.

What it doesn’t do

  • No fingerprint identifier. A visitor is recognized by the _bm_id cookie, and by nothing else. The request details above are stored beside the touchpoint they arrived on, but they’re never combined into an id, never used to recognize a device that arrives without a cookie, and never matched against another visitor. Clearing the cookie makes someone new.
  • No document.cookie writes. The cookie is set by an HTTP response header and is HttpOnly, so JavaScript and browser extensions can’t read it.
  • Nothing inline, so a strict Content Security Policy needs no exception. Both tags in the snippet load files from your own domain, and so does everything they pull in afterwards — the survey card’s stylesheet included. script-src 'self'; style-src 'self'; connect-src 'self' runs the whole install: no 'unsafe-inline', and no hashes to keep up to date.
  • No per-user tracking inside your product. Engagement rolls up to the billing customer, never the person at the keyboard. Which of your users did what is Mixpanel’s job, not ours.
  • Nothing sold, and no data brokers. Events go to your own domain and stop there. There’s one exception and it’s yours to switch on: ad-platform forwarding, below.

Install the script

Two forms of the same script. Both load a small stub first, which queues calls made while the tracker is still loading. Drop that line and a cookie banner answered in the first second is lost.

Marketing site
<script src="/b/stub.js"></script>
<script src="/b/script.js?k=bm_yourtrackingkey" async></script>

Marketing pages: drop it in the <head>. It captures the channel, campaign, and click id a visitor arrived with.

Product pages
<script src="/b/stub.js"></script>
<script src="/b/script.js?k=bm_yourtrackingkey" data-product="my-product" data-customer-id="cus_xxx" async></script>

Behind your login: the same script, plus who the session belongs to. data-customer-id is the id your billing provider gave that customer, read from your own session.

Swap bm_yourtrackingkey for your account’s key. The app shows both snippets with the real key already in them at Settings → Integrations. The key is public by design: it ships in your page source, so it names your account and never authenticates it.

One requirement, and it isn’t optional: /b/* has to be served from your own domain. A Cloudflare Worker in front of your site does it, and it’s the next section, ready to paste. Without it Safari caps the visitor cookie at seven days, and any consideration window longer than a week stops attributing.

Serving /b/*

The one bit of plumbing the install asks of you, done once. A small Cloudflare Worker on your own zone answers /b/* and hands those requests to us. Here it is, whole — the same file we run.

Why it can’t just point at us

A cookie set by your own domain is first-party, and Safari keeps it for 400 days. Point a subdomain at us with a CNAME and the same cookie reads as cloaked third-party tracking, which Safari caps at seven days — so every visitor who takes longer than a week to decide comes back looking new. The Worker keeps the answer coming from you.

It’s also the only thing that works on a free Cloudflare plan. Routing one path prefix to an outside origin is an Enterprise feature; a Worker isn’t.

worker.js
/**
 * Beautiful Metrics — the `/b/*` tracking proxy, running on your own Cloudflare zone.
 *
 * Bound to the route `example.com/b/*`, it forwards those requests to Beautiful
 * Metrics and returns the response untouched. That is the whole job, and it is the
 * only shape that works: because your own domain answers, the _bm_id cookie in that
 * response is first-party and Safari gives it the full 400 days, where a subdomain
 * CNAME'd to us would read as cloaked third-party tracking and be capped at seven.
 *
 * Variables:
 *   BM_ORIGIN — where Beautiful Metrics answers. The integration guide names it.
 */
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (!url.pathname.startsWith('/b/')) return fetch(request);

    const proxied = new Request(new URL(url.pathname + url.search, env.BM_ORIGIN), request);

    /*
     * _bm_id is the only cookie on this domain that is ours, and only /b/collect
     * reads it. The rest of that header is your own site: your app's session token,
     * your CSRF token. None of it leaves your zone just because the tracker POSTs
     * same-origin and the browser attached every cookie it had.
     */
    const visitorId = request.headers.get('cookie')?.match(/(?:^|;\s*)_bm_id=([^;]*)/)?.[1];
    proxied.headers.delete('cookie');
    if (visitorId && url.pathname === '/b/collect') {
      proxied.headers.set('cookie', `_bm_id=${visitorId}`);
    }

    /*
     * Where the visitor is comes from the edge, never from the page. `new Request(url,
     * request)` copied the visitor's own headers, and `x-bm-city` is our name — nothing
     * upstream overwrites it — so each name is CLEARED first and only then written,
     * whether or not the edge has a value. Writing the value the edge does have, rather
     * than trusting the zone's header settings, is what lets the consent gate rely on
     * it; clearing the one it doesn't is what keeps an unplaceable visitor unplaceable,
     * which is what that gate fails closed on.
     *
     * X-Forwarded-For goes with them, since the collector reads its first entry as the
     * client. CF-Connecting-IP is the only one left as it arrived: Cloudflare replaces
     * it at the edge, so by the time this runs it is already the edge's answer and not
     * the visitor's.
     */
    const { country, city } = request.cf ?? {};
    const edge = { 'cf-ipcountry': country, 'x-bm-city': city, 'x-forwarded-for': null };
    for (const [name, value] of Object.entries(edge)) {
      proxied.headers.delete(name);
      if (value) proxied.headers.set(name, value);
    }

    return fetch(proxied);
  },
};

The same file we run. Paste it as-is — the two things to set are yours: the route example.com/b/* on your own zone, and the BM_ORIGIN variable.

Wiring it up

  1. Create a Worker in the Cloudflare dashboard, on the account your domain sits on, and paste the code above over whatever it starts with.
  2. Add the variable. BM_ORIGIN, set to https://api-861800505678.us-central1.run.app. That one is us, and it’s the same for everybody.
  3. Add the route example.com/b/* on your own zone — your domain, not a workers.dev address. A workers.dev subdomain is a different registrable domain, which lands you back where the CNAME was.
  4. Check it. curl https://example.com/b/consent should answer {"consentRequired":false,"country":"US"}, not your site’s 404 page. Then install the script above and Settings → Integrations will tell you the moment your first visitor lands.

Deploying with Wrangler instead? The route and the variable live in the config.

wrangler.toml
name = "beautiful-metrics-proxy"
main = "worker.js"
compatibility_date = "2026-07-28"
routes = [{ pattern = "example.com/b/*", zone_name = "example.com" }]

[vars]
BM_ORIGIN = "https://api-861800505678.us-central1.run.app"

Beside worker.js, then npx wrangler deploy. zone_name is the domain the route belongs to.

Cloudflare’s free Workers plan stops at 100,000 requests a day, and a Worker past the line returns error 1027 rather than running — which for a tracking endpoint means attribution goes quiet without telling you. Only /b/* reaches it, not your page views, so most sites never come near. If yours does, Workers Paid is $5 a month, charged by Cloudflare rather than by us.

Identify and track

Two optional calls. Both are queued by the stub the snippet loads, so they’re safe to make before the script has finished loading.

Identify, at signup

This is for attribution only: it links the anonymous visitor who clicked your ad to the identity that just signed up.

bm.identify
// After signup or login. Links the anonymous visitor to a known identity
bm.identify({ email: 'user@example.com', userId: 'usr_123' });

Two traits, both optional, at least one required: a call carrying neither email nor userId sends nothing. Spelling counts — userId, not user_id. email is the one that earns you anything, because it’s what your signup form and your billing provider both know, and matching them is how a click becomes a customer. userId is your own id, stored beside it so you can join back to your database.

Track a thing a customer did

An event names a customer and a thing they did, and nothing else. No properties, no per-user identity, no cookie. It becomes a count on that customer: first seen, last seen, how many times. That’s what makes “customers who did X” a segment you can build on.

bm.track
// A thing a customer did. On a product page the customer is already on
// the script tag, so the name is enough.
bm.track('onboarding_complete');
bm.track('feature_used', { customerId: 'cus_xxx' });

Ad platforms: what leaves, and what has to be true first

Turn on Google Enhanced Conversions or Meta CAPI and we send each conversion to that platform from our server: the stored click id, a SHA-256 hash of the email, the IP and user agent, the _fbp cookie value for Meta, and the value of the conversion. That is sharing personal data with a third party for advertising. It’s off until you configure it, and it’s the one place attribution data leaves your own domain.

What we enforce for you

  • Consent, per touchpoint. A click id is forwarded only if the touchpoint carrying it was collected under consent that covers ad-platform sharing, or came from a region that never had to ask. A touchpoint with no recorded answer is never read as permission.
  • A conversion we can’t share is held, not dropped. You see the count in Settings → Integrations, and nothing is sent. If consent turns up later, the next conversion goes.
  • The click comes first. Only a click captured before the conversion can attribute it, and each conversion reaches a platform once per customer.

What’s yours

The consent you collect has to actually cover sharing with Google or Meta. An “analytics” category doesn’t say that. If you forward conversions, gate on the advertising category too.

Forwarding-aware consent
cmp.onConsentChange(function (consent) {
  // Forwarding to Google or Meta needs a scope that covers sharing with them.
  bm.consent(consent.analytics && consent.advertising ? 'granted' : 'denied');
});

Who’s responsible for what

You’re the data controller. We’re the data processor. It’s your product, your visitors, and your call what gets collected. We process it on your instructions and on your behalf.

  1. Disclose the tracking script in your privacy policy. Especially on pages behind your login, where the data belongs to your end-users rather than to prospects. What the script collects is the list to write it from.
  2. Get consent from your EU end-users before attribution tracking fires. We give you the gate, honor it in the script and again at the server, and lose the attribution rather than guess. Wiring it to your banner is yours.
  3. If you forward conversions, make the consent cover it. Google Enhanced Conversions and Meta CAPI share your end-users’ data with those platforms, so the consent you collected has to say so.

We’re not going to tell you this makes you compliant with anything. No vendor can. What we can tell you is exactly what the script does, that the gate fails closed when it can’t tell where a visitor is, and that paying accounts get a data processing agreement naming what we process, where it’s stored, and who our sub-processors are.

What setup buys what

Attribution is worth setting up long before you spend a dollar on ads. Here’s what each step earns.

Where you are What you set up What you get
No ad spend yet Just the script Campaign and referrer capture, and a source on every customer
Running Google Ads The script, plus Google Enhanced Conversions wired at signup and payment gclid attribution, and smart bidding that learns from real revenue
Running Meta Ads The script, plus Meta CAPI at signup and payment, with an optional Pixel for PageView only fbclid attribution, and retargeting audiences if you want them

None of these ask you to touch Pixel conversion events, manage deduplication, or handle event ids. That complexity is ours.

Your key lives in the app. Settings → Integrations hands you both snippets with it already substituted in, then tells you the moment your first visitor lands.