Client Resource

This guide is shared with active clients. Enter your access code to continue.

Enter your 5-digit code

Don't have a code? Get in touch

Client Guide

B2B Measurement Setup:
First-Touch Attribution + GA4

By Tsvi Machluf July 2026 GTM · GA4 · Analytics ~15 min read
Developer Version — Scripts & Setup Only
Standard GA4 gives upgrade credit to the last session before payment. For B2B SaaS with a 30–90 day sales cycle, that's almost always wrong. This guide sets up a first-touch source that captures where the user came from on visit #1, stores it, and passes it alongside 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

User lands on universell.us via Google Ads
→ 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.

HTML / universell.us — every page <head>
<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>
Note on UTM vs gclid: If Google Ads auto-tagging is ON (recommended), the script catches 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.

The 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.
sign_up — post-signup confirmation page
<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.

login — post-login page
<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>
source_first_visit on login is optional. It's useful only if you want to segment login activity by acquisition cohort (e.g. "do Google Ads users log in more than organic users?"). For basic setup you can remove it — the essential parameters are 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.

demo_request — booking confirmation page
<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.

purchase — payment success page
<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>
Why the ecommerce object matters. GA4 and Google Ads read 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):

PHP — filling the {{...}} placeholders
'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

Parts 3 and 4 are yours, not your developer's. Everything from here is configured inside the Tag Manager and Google Analytics interfaces — no code involved. Your developer only needs Parts 1 and 2.

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_visitsource_first_visit
dlv - account_idaccount_id
dlv - user_iduser_id
dlv - plan_tierplan_tier
dlv - productproduct

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 NameEvent Name to Match
Trigger - sign_upsign_up
Trigger - loginlogin
Trigger - demo_requestdemo_request

Step C — Create 3 GA4 Event Tags

Tags → New → Google Analytics: GA4 Event — one tag per event, each firing on its matching trigger.

TagEvent NameParametersTrigger
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 NameScopeEvent Parameter
Source First VisitEventsource_first_visit
Account IDUseraccount_id
User IDUseruser_id
Plan TierEventplan_tier
ProductEventproduct
Important: Custom dimensions only populate from the moment you create them forward. No backfill. Create them before you go live.
Recommended: Set up cross-domain tracking between universell.us and universal.mybmr.com. Without it, GA4 standard reports will show all app sessions as source = "referral / universell.us" instead of the original traffic source. Your 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

  1. Enable GTM Preview mode
  2. Go through a test signup on the app domain
  3. In GA4: Admin → DebugView
  4. You should see sign_up event 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_visit when 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.

Tsvi Machluf, PPC Expert

13+ years running paid media for B2B SaaS companies, ecommerce brands, and growth-stage SMBs. Google Ads, Meta Ads, retail media (Target.com, Nordstrom), GTM from set up to ongoing optimization.

Startup PPC freelancer in Israel → Work with a PPC consultant by the hour