B2B Measurement Setup:
First-Touch Attribution + GA4
user_id, account_id, and plan_tier to GA4 at signup — so you can see which channels actually bring paying customers.
How the Full Flow Works
→ Script detects
gclid → saves source_first_visit = "google_ads" in localStorage→ User clicks "Get Started" → link gets
?source_first_visit=google_ads appended→ User lands on universal.mybmr.com
/signup?source_first_visit=google_ads→ After signup, page fires
dataLayer.push with source + account_id + user_id + plan_tier + product→ GTM catches the event → sends GA4 event
sign_up with all 5 parameters→ GA4 records the conversion with first-touch source attached
Part 1: Marketing Domain Script
Place this in <head> on every page of universell.us. It runs before the page renders, detects where the user came from, saves it once, and rewrites your app links to carry the value over.
<script>
(function() {
// Detect the traffic source for this visit
function getOrigin() {
var p = new URLSearchParams(window.location.search);
var r = document.referrer.toLowerCase();
if (p.has('gclid') || p.has('gbraid')) return 'google_ads';
if (p.has('fbclid')) return 'meta_ads';
if (p.has('utm_source')) return 'utm_' + p.get('utm_source').toLowerCase();
if (r.includes('google.com')) return 'organic_google';
if (r.includes('linkedin.com')) return 'organic_linkedin';
if (r.includes('reddit.com')) return 'organic_reddit';
return 'direct_or_unknown';
}
// Save once on first visit — never overwrite
if (!localStorage.getItem('source_first_visit')) {
localStorage.setItem('source_first_visit', getOrigin());
}
var firstSource = localStorage.getItem('source_first_visit');
// Append source_first_visit to every link pointing to the app
window.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('a[href*="universal.mybmr.com"]').forEach(function(link) {
try {
var url = new URL(link.href);
url.searchParams.set('source_first_visit', firstSource);
link.href = url.toString();
} catch(e) {}
});
});
})();
</script>
gclid and returns "google_ads". If you also use UTMs, UTMs take lower priority here. Decide on one convention. Recommended: leave auto-tagging ON and don't add UTMs to Google Ads — that way you always get a clean google_ads value.
Part 2: App Domain Scripts
Three separate events fire on universal.mybmr.com — one for each key action. Each script goes on the relevant page. Server-rendered placeholders ({{...}}) are replaced by your backend before the HTML reaches the browser.
Event 1 — Sign Up
Fires on the post-signup confirmation page — shown once immediately after account creation.
product parameter tells you which product in the suite the user signed up for — e.g. esign, crm, inventory, marketing, or deals_pipeline. Pick short, consistent lowercase values and use the same ones everywhere — this becomes a GA4 dimension you can break every report down by.
<script>
window.dataLayer = window.dataLayer || [];
var p = new URLSearchParams(window.location.search);
var firstSource = p.get('source_first_visit') || 'direct_or_unknown';
window.dataLayer.push({
'event': 'sign_up',
'source_first_visit': firstSource, // from URL param (passed from universell.us)
'account_id': '{{ACCOUNT_ID}}', // CRM Company/Account ID
'user_id': '{{USER_ID}}', // CRM User/Person ID
'plan_tier': '{{PLAN_TIER}}', // e.g. "free_account", "pro_monthly"
'product': '{{PRODUCT}}' // which product they signed up for — e.g. "esign", "crm"
});
</script>
Event 2 — Sign In (Login)
Fires on the post-login page — after a returning user authenticates. Tracks which paying accounts are actively using the product, and ties each session to an account and plan tier.
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'login',
'account_id': '{{ACCOUNT_ID}}', // from session / DB
'user_id': '{{USER_ID}}', // from session / DB
'plan_tier': '{{PLAN_TIER}}', // from DB
'product': '{{PRODUCT}}', // which product they logged into
'source_first_visit': '{{SOURCE_FIRST_VISIT}}' // optional — from DB (stored at signup), for cohort analysis
});
</script>
account_id, user_id, and plan_tier. If you do include it, it must come from your database — returning users arrive via direct/bookmark and carry no URL param.
Event 3 — Demo Request
Fires on the demo booking confirmation page (after the scheduling form is submitted on requestTrainingEmployee/...). This is a lead — the prospect hasn't signed up yet, so there's no user_id or account_id yet.
<script>
window.dataLayer = window.dataLayer || [];
var p = new URLSearchParams(window.location.search);
// Try URL param first (cross-domain), fall back to localStorage
var firstSource = p.get('source_first_visit')
|| localStorage.getItem('source_first_visit')
|| 'direct_or_unknown';
window.dataLayer.push({
'event': 'demo_request',
'source_first_visit': firstSource
// no account_id or user_id — prospect hasn't signed up yet
});
</script>
Event 4 — Purchase (paid conversion / upgrade)
Fires on the payment success / order confirmation page — when a plan is bought or a free account upgrades to paid. This is the money event: it carries the GA4 ecommerce object (so revenue shows in reports and feeds Google Ads value-based bidding) plus the same identity and attribution keys as the other events, and hashed user data for enhanced conversions.
<script>
window.dataLayer = window.dataLayer || [];
// Clear any previous ecommerce object so values don't bleed between pushes
window.dataLayer.push({ 'ecommerce': null });
window.dataLayer.push({
'event': 'purchase',
'ecommerce': {
'transaction_id': '{{ORDER_ID}}', // unique order/subscription ID — REQUIRED, dedupes conversions
'value': {{ORDER_VALUE}}, // number only, no symbol — e.g. 490.00
'currency': '{{CURRENCY}}', // ISO 4217 — e.g. "USD"
'tax': {{TAX}}, // optional
'coupon': '{{COUPON}}', // optional promo code
'items': [{
'item_id': '{{PLAN_CODE}}', // SKU / plan code
'item_name': '{{PLAN_NAME}}', // e.g. "Pro Monthly"
'item_category': '{{PRODUCT}}', // optional — product line, e.g. "esign", "crm"
'price': {{PLAN_PRICE}}, // per-unit price
'quantity': 1
}]
},
// same identity + attribution keys as sign_up / login (custom — optional but recommended)
'account_id': '{{ACCOUNT_ID}}',
'user_id': '{{USER_ID}}',
'plan_tier': '{{PLAN_TIER}}',
'product': '{{PRODUCT}}',
'source_first_visit': '{{SOURCE_FIRST_VISIT}}', // read from DB — source stored at signup
// OPTIONAL — enhanced conversions; the SAME fields feed both Google and Meta matching (hashed)
'user_data': {
'email': '{{USER_EMAIL}}', // recommended — strongest match key
'phone_number': '{{USER_PHONE}}' // optional — include only if you collect a verified phone
}
});
</script>
value and currency from inside ecommerce to report revenue and power value-based bidding (tROAS). transaction_id is required — it stops the same purchase being counted twice on a page refresh. Always send value as a raw number, never "$490".
The {{ACCOUNT_ID}}-style placeholders above aren't real code — your backend has to swap them for actual values when it builds the page. Here's what that looks like in PHP (adapt to your stack):
'account_id': '<?= htmlspecialchars($user->company_id) ?>',
'user_id': '<?= htmlspecialchars($user->id) ?>',
'plan_tier': '<?= htmlspecialchars($user->plan) ?>',
'product': '<?= htmlspecialchars($user->product) ?>',
'source_first_visit': '<?= htmlspecialchars($user->source_first_visit) ?>'
Part 3: GTM Setup
GTM needs to be installed on universal.mybmr.com too. These steps create the plumbing that reads the dataLayer values and sends them to GA4.
Step A — Create 5 Data Layer Variables
In GTM: Variables → New → Data Layer Variable. Create one for each field. All Version 2.
| Variable Name (GTM) | Data Layer Variable Name |
|---|---|
dlv - source_first_visit | source_first_visit |
dlv - account_id | account_id |
dlv - user_id | user_id |
dlv - plan_tier | plan_tier |
dlv - product | product |
Step B — Create 3 Triggers
Triggers → New → Custom Event — one per event. Each fires when GTM detects a dataLayer.push with that event name.
| Trigger Name | Event Name to Match |
|---|---|
| Trigger - sign_up | sign_up |
| Trigger - login | login |
| Trigger - demo_request | demo_request |
Step C — Create 3 GA4 Event Tags
Tags → New → Google Analytics: GA4 Event — one tag per event, each firing on its matching trigger.
| Tag | Event Name | Parameters | Trigger |
|---|---|---|---|
| GA4 - sign_up | sign_up |
source_first_visit, account_id, user_id, plan_tier, product |
Trigger - sign_up |
| GA4 - login | login |
account_id, user_id, plan_tier, product, source_first_visit (optional) |
Trigger - login |
| GA4 - demo_request | demo_request |
source_first_visit |
Trigger - demo_request |
For each tag, set each parameter value to its matching GTM variable: {{dlv - source_first_visit}}, {{dlv - user_id}}, etc. Then Publish the container.
Part 4: GA4 Setup
Create Custom Dimensions
GA4 needs to know about these parameters before they appear in reports. Go to: Admin → Property → Custom Definitions → Custom Dimensions → Create
| Dimension Name | Scope | Event Parameter |
|---|---|---|
| Source First Visit | Event | source_first_visit |
| Account ID | User | account_id |
| User ID | User | user_id |
| Plan Tier | Event | plan_tier |
| Product | Event | product |
source_first_visit custom dimension stays correct either way — but the built-in GA4 acquisition reports will be noisy. Takes about 5 minutes to set up. Cross-domain tracking setup guide →
Verify with DebugView
- Enable GTM Preview mode
- Go through a test signup on the app domain
- In GA4: Admin → DebugView
- You should see
sign_upevent with all 5 parameters
Reading the Data in GA4
Once live, go to Explore → Blank exploration:
- Dimension: Source First Visit
- Metric: Event count (filter: event_name = sign_up)
This shows exactly how many signups came from each first-touch source.
To track upgrades: when a user upgrades, fire a second event (purchase) and include source_first_visit again, reading it from your database this time (you stored it at signup via user_id).
Common Issues
Source always shows "direct_or_unknown"
Google Ads auto-tagging must be ON. Check: Google Ads → Settings → Account Settings → Auto-tagging. If it's off, gclid won't appear in the URL and the script falls through to direct_or_unknown.
The app link doesn't carry the param
The script rewrites <a> tags. If your CTA button uses window.location.href = ... in JavaScript instead, handle it separately by appending the param to that URL manually.
localStorage cleared between sessions (iOS)
iOS Safari with ITP can clear localStorage after 7 days of inactivity. For B2B SaaS with a long cycle, also write source_first_visit to your database at signup — that's the server-side backup and doesn't depend on the browser.
How Long Does source_first_visit Last in localStorage?
localStorage has no built-in expiry — it stays until the user clears their browser data. But there are platform differences:
- Mac / Windows / Android — all browsers: Persists indefinitely. A user who visited 3 months ago will still have their
source_first_visitwhen they come back. - iOS Safari (iPhone/iPad) only: Apple's ITP clears localStorage on a domain after 7 days of no visits. So if a prospect visits on their phone, doesn't return for a week, and then signs up — the value is gone from localStorage. The URL param passed from the marketing site link is the only backup.
In practice for B2B SaaS, most sign-ups happen on desktop so the 7-day limit rarely matters. But if you want to be safe for all cases, write source_first_visit to your database at signup — once it's in the DB, browser storage lifetime is irrelevant.
Short version: Mac = forever · iOS = 7 days of inactivity
GTM Server-Side Tracking Setup →Send This Guide to Your Developer
Your developer needs Parts 1 and 2 of this guide. Enter their email below and optionally add a note — they'll get the code snippets and instructions directly.
* This feature is coming soon — your developer can also access this page directly using the same code.