cJS – Traffic Source
cJS – Traffic Source
GTM Custom JavaScript Variable Last updated: September 2026
Overview
This GTM Custom JavaScript variable detects and returns the traffic source for a website visitor as one flat string (google_ads, facebook_ads, google_organic, newsletter_email, referral, direct, …). It is designed to work for both lead generation (form submissions) and ecommerce (Purchase events) by populating a traffic_source or source custom parameter on conversion tags — most commonly the Meta Pixel Lead and Purchase events.
Its classification rules mirror the MTK Attribution engine: the same click IDs, the same utm_medium values, the same referrer domain lists, and the same fresh-cookie rescue — collapsed to a single string instead of a separate channel and source. If a site runs both, the two agree on where a visit came from.
The variable uses last-touch attribution within a tab session: each new entry to the site with a fresh click ID or UTM replaces the prior value, so the conversion is credited to the channel that most recently brought the user in. SessionStorage carries the resolved value through multi-page funnels so attribution is preserved through popup forms on internal pages and multi-step checkouts.
How It Works
Detection Priority
The variable resolves an entry from that pageview's URL parameters, referrer, and platform cookies, walking the rules below in order. The first match wins. All comparisons are case-insensitive.
| Priority | Signal | Returns |
|---|---|---|
| 1 | gclid, gbraid, or wbraid in URL, or utm_medium=cpc / paid_search with a Google (or no) utm_source | google_ads |
| 2 | msclkid in URL, or utm_medium=cpc / paid_search with utm_source = bing / bingads / microsoft | microsoft_ads |
| 3 | utm_medium=cpc / paid_search with any other utm_source | {source}_ads |
| 4 | utm_medium=paid_social | {source}_ads — facebook_ads when utm_source is missing |
| 5 | ttclid in URL | tiktok_ads |
| 6 | utm_medium=email | {source}_email, or email when utm_source is missing |
| 7 | Any other utm_source | {source}_{medium} (e.g. partner_referral), or {source} when there is no medium |
| 8 | Referrer host is a search engine (google., bing., yahoo., duckduckgo., ecosia.) | {engine}_organic — a Google referrer with a fresh _gcl_aw cookie upgrades to google_ads |
| 9 | Referrer host is a social network (facebook., fb., instagram., linkedin., twitter., x.com, t.co, youtube., reddit., pinterest., tiktok.), or fbclid in URL with any external referrer | {network}_organic — a Facebook/Instagram referrer with a fresh _fbc cookie and no fbclid in the URL upgrades to facebook_ads |
| 10 | Any other external referrer | referral |
| 11 | fbclid in URL with no referrer at all | facebook_organic |
| 12 | No signals, but a fresh _gcl_aw cookie | google_ads |
| 13 | No signals, but a fresh _fbc cookie | facebook_ads |
| 14 | Nothing identifying | direct |
utm_source values are normalized through a small alias table before they are used, so Meta's {{site_source_name}} values come out readable: fb → facebook, ig → instagram, msg → messenger, an → audience_network, x → twitter, googleads / adwords → google, bingads / bing → microsoft. Anything else is lower-cased and snake_cased (Facebook Ads → facebook_ads).
Referrer domains are matched against the referrer's hostname only, with label boundaries: google. matches google.com and news.google.de but not notgoogle.com; x.com matches x.com and www.x.com but not wix.com; bing. never matches plumbing.com. A referrer from the site's own hostname is internal navigation and never classifies.
Attribution Model
This variable uses last-touch attribution within a tab session. The value is resolved once, on the pageview that brought the visitor in, and stored. A later pageview only re-resolves when:
- the URL carries a fresh campaign signal — any
utm_source,utm_medium,utm_campaign, or any ofgclid,gbraid,wbraid,msclkid,fbclid,ttclid; or - the tab has been idle for more than 30 minutes (GA-style session timeout); or
- nothing is stored yet (first pageview in the tab, or storage was blocked).
Internal navigation never changes attribution — and neither does an external referrer on its own. A mid-checkout bounce through PayPal, Stripe, or an OAuth provider comes back with a foreign referrer and no campaign signal, and the stored paid source survives it.
This means:
- A user who clicks a Google Ad, leaves, and returns via a Facebook Ad will be attributed to Facebook on conversion — and vice versa. The newest signal always wins, whichever platform it came from.
- A user who clicks a Facebook Ad, browses to checkout over multiple pages, and converts is still attributed to Facebook because sessionStorage carries the resolved value through the funnel.
- A user who leaves the tab open overnight and comes back via organic search is re-resolved as organic search.
- A new browser session (closed tab, new browser) starts fresh — no localStorage means no cross-session attribution carryover.
To switch to first-touch attribution: skip the re-resolve when a value is already stored, and add a localStorage layer for cross-session persistence. (Or use MTK Attribution, which reports first touch, last touch, and the full journey.)
Funnel Persistence via SessionStorage
Without persistence, multi-page funnels would lose attribution. By the second page of a checkout, document.referrer becomes your own domain, and click IDs disappear from the URL once the user navigates internally.
The variable stores a single key, cjs_traffic_source, holding the resolved value and the time of the last pageview: {"v":"google_ads","t":1700000000000}. Every pageview in the same tab reads the stored value and refreshes the timestamp, so a visitor who keeps navigating never times out.
Why UTMs Are Required for Paid Social
Facebook (and Instagram) append fbclid to all outbound links — both paid ads and organic posts. Relying on fbclid or the _fbc cookie alone would incorrectly label organic Facebook traffic as paid.
To distinguish paid from organic, paid social ad URLs must include UTMs following this convention:
| Channel | utm_source | utm_medium |
|---|---|---|
| Meta Ads (all placements) | {{site_source_name}} (fb, ig, msg, an) | paid_social |
| LinkedIn Ads | paid_social | |
| TikTok Ads (paid) | tiktok | paid_social |
Any click with fbclid that does NOT have utm_medium=paid_social is treated as organic. Without consistent UTM tagging on ad URLs, paid and organic Facebook traffic will be misattributed.
The one exception is the _fbc cookie rescue: when a Facebook or Instagram referrer arrives with no fbclid in the URL but the Pixel's _fbc cookie was created within the last 30 minutes, the click's parameter was lost in transit (consent redirect, URL rewrite) and the visit is credited to facebook_ads. An _fbc sitting next to an fbclid proves nothing — the Pixel writes _fbc from whichever fbclid it sees, organic or paid — so that case stays organic.
For Google, Microsoft, and TikTok, the platform-specific click IDs (gclid / gbraid / wbraid, msclkid, ttclid) are unique to paid traffic and don't have this collision problem. UTMs are optional for those channels but recommended for GA4 reporting consistency; utm_medium=cpc on its own is also recognised as paid search.
Google Ads Attribution
Google Ads detection works in three layers:
gclid,gbraid, orwbraidin the URL — present on the landing page when a user clicks a Google Ad.gbraidandwbraidare the iOS privacy-preserving equivalents ofgclid; without them, iOS ad clicks fall through to organic or direct.utm_medium=cpc— catches manually tagged ads when auto-tagging is off or a redirect stripped the click ID.- Fresh
_gcl_awcookie — set automatically by gtag.js / the Conversion Linker when auto-tagging is enabled. The cookie embeds its creation time (GCL.{seconds}.{gclid}), and only a cookie created within the last 30 minutes counts: it either upgrades a Google organic referrer togoogle_adsor rescues a direct arrival. A stale cookie is ignored — those cookies live ~90 days, and honouring one would relabel every returning visitor asgoogle_adsfor months, over their real email, Microsoft Ads, or organic source.
For the _gcl_aw fallback to work, the Google Ads account must have auto-tagging enabled and gtag.js or the Conversion Linker must be firing on the site.
Lead Gen vs. Ecommerce Use
The same variable works for both conversion types because the underlying attribution problem is the same: a user enters with a source signal, navigates internally, and converts on a deeper page.
For lead gen (popup forms, contact forms):
- Used in Meta Pixel Lead event as
traffic_sourceparameter - Captures source even when the popup appears on a different page than the landing page
For ecommerce (multi-step checkout):
- Used in Meta Pixel Purchase event as
sourceparameter - Captures source through cart → checkout → confirmation flow
Both use cases benefit from the sessionStorage persistence layer — without it, attribution would collapse to direct on internal pages.
Cross-Tab Behavior
SessionStorage is scoped to a single browser tab. If a user opens checkout in a new tab partway through a funnel, the new tab starts fresh and re-detects from the URL, referrer, and cookies at that moment.
For most flows this is fine — the new tab usually opens with the source URL intact, and a fresh _gcl_aw / _fbc cookie will still rescue a paid click. If cross-tab persistence is required (e.g. multi-tab purchase comparison flows), add a localStorage layer that writes alongside sessionStorage and reads as a fallback.
Known Limitations
- No cross-session attribution. Closing the browser ends the session. A user who visits via Facebook Ads on Monday and converts via direct on Wednesday will be attributed to direct on Wednesday. This is intentional last-touch behavior; use MTK Attribution for first-touch and journey reporting.
- iOS Safari ITP restricts cookie and storage lifetimes on Apple devices. The
_fbcand_gcl_awcookies may have shortened lifespans, which can affect the cookie rescues specifically. - Untagged Meta ads look organic. A paid Facebook click that arrives with
fbclidbut noutm_medium=paid_socialreturnsfacebook_organic. This is by design — see above. - Untagged landing pages with no referrer are direct. If a user lands with no UTMs, no click ID, no referrer, and no fresh platform cookie, the visit is
direct, and a later form submit on an internal page inherits that. UTM-tag your landing pages. - Referrer-based organic detection relies on
document.referreron the entry pageview. If the landing page is reached via a redirect that strips the referrer, the visit resolves asdirectunless a fresh platform cookie rescues it. - Unknown referrers are bucketed as
referral, not reported by hostname, to keep the value's cardinality low in Meta and GA4 reports.
Usage in GTM
Variable Setup
- Variable type: Custom JavaScript
- Variable name: cJS - Traffic Source
- Return type: String
Paste the contents of cjs-traffic-source.js as the variable body, or import the GTM Essentials container which includes it.
Tag Implementation — Lead Event
<script>
fbq('track', 'Lead', {
lead_type: 'Form Submit',
content_name: '{{Page Path}}',
traffic_source: '{{cJS - Traffic Source}}', // last-touch traffic source
ad_placement: '{{cJS - Ad Placement}}'
}, {
eventID: '{{cJS - Custom Event ID}}'
});
</script>
Tag Implementation — GA4 Event Parameter
| Parameter | Value |
|---|---|
cjs_traffic_source | {{cJS - Traffic Source}} |
Register cjs_traffic_source as a custom dimension in GA4 to report on it.
The code
/*!
* Marketing Toolkit — cJS – Traffic Source
* Version: v2.0.0
* Last updated: 2026-09-02
* https://github.com/whboggs/marketing-toolkit
*/
function() {
// cJS - Traffic Source
//
// Last-touch traffic source for lead gen and ecommerce conversion tags, returned as one
// flat snake_case string (google_ads, facebook_ads, google_organic, newsletter_email,
// referral, direct, ...).
//
// Classification mirrors the MTK Attribution engine (resolveTouch): the same click IDs,
// the same utm_medium values, the same referrer domain lists, and the same fresh-cookie
// rescue — collapsed to a single string instead of channel + source.
//
// An ENTRY is resolved once, from the pageview that brought the visitor in, and carried
// through the tab in sessionStorage so popup forms and multi-step checkouts on internal
// pages still report the source that started the visit. A later pageview only replaces
// the stored entry when it carries a fresh campaign signal in the URL (a click ID or a
// UTM) or the tab has been idle for longer than the session window — internal navigation
// never changes attribution, and neither does a mid-funnel bounce through a payment
// gateway or an OAuth provider.
var STORE_KEY = 'cjs_traffic_source'; // sessionStorage key: {"v": "<source>", "t": <last activity ms>}
var SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes, GA-style — idle longer than this = new entry
var PAID_SOCIAL_MEDIUM = 'paid_social'; // the utm_medium ad URLs must carry to count as paid social
// Referrer domain lists — same fragments as the MTK engine defaults. A trailing dot means
// "this label anywhere in the host" (google.com, news.google.de); an inner dot means "this
// exact domain or a subdomain of it" (x.com, www.x.com — but never wix.com).
var SEARCH_DOMAINS = ['google.', 'bing.', 'yahoo.', 'duckduckgo.', 'ecosia.'];
var SOCIAL_DOMAINS = ['facebook.', 'fb.', 'instagram.', 'linkedin.', 'twitter.', 'x.com', 'youtube.', 'reddit.', 'pinterest.', 'tiktok.', 't.co'];
// utm_source aliases -> the platform name this variable reports. Covers Meta's
// {{site_source_name}} values (fb / ig / msg / an) recommended in the UTM docs, so a paid
// Facebook click tagged utm_source=fb returns facebook_ads, not fb_ads.
var SOURCE_ALIASES = {
fb: 'facebook', ig: 'instagram', msg: 'messenger', an: 'audience_network',
x: 'twitter', 'x.com': 'twitter',
googleads: 'google', adwords: 'google',
bingads: 'microsoft', bing: 'microsoft'
};
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
var params = new URLSearchParams(window.location.search);
function param(key) { return params.get(key) || ''; }
function lower(s) { return s ? String(s).toLowerCase() : ''; }
// Turn any typed value into a safe snake_case token ("Facebook Ads" -> "facebook_ads").
function slug(s) {
return lower(s).replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
// Alias lookup, then slug — so utm_source=IG and utm_source=Instagram both give "instagram".
function sourceName(raw) {
var key = lower(raw);
if (!key) return '';
return SOURCE_ALIASES.hasOwnProperty(key) ? SOURCE_ALIASES[key] : slug(key);
}
// sessionStorage access that degrades to "no persistence" in private browsing / blocked storage.
function readStore() {
try { var raw = sessionStorage.getItem(STORE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; }
}
function writeStore(value) {
try { sessionStorage.setItem(STORE_KEY, JSON.stringify({ v: value, t: Date.now() })); } catch (e) {}
}
// Referrer HOST only — scheme, userinfo, port, and path stripped — so a path or query string
// containing "x.com" or "google." can never be mistaken for the referring domain.
var referrer = lower(document.referrer);
var refHost = referrer
.replace(/^[a-z][a-z0-9+.-]*:\/\//, '')
.split('/')[0]
.split('@').pop()
.split(':')[0];
var selfHost = lower(window.location.hostname);
// A referrer from the site's own hostname is internal navigation, not attribution.
var hasReferrer = referrer !== '' && refHost !== selfHost;
// Boundary-aware domain match against refHost (see SEARCH_DOMAINS comment for the styles).
function hostMatches(frag) {
if (!frag) return false;
if (frag.charAt(frag.length - 1) === '.') {
// "google." — label prefix anywhere in the host
return refHost.indexOf(frag) === 0 || refHost.indexOf('.' + frag) > -1;
}
if (frag.indexOf('.') > -1) {
// "x.com" — exact registrable domain or a subdomain of it
if (refHost === frag) return true;
var tail = '.' + frag;
return refHost.length > tail.length && refHost.indexOf(tail) === refHost.length - tail.length;
}
return refHost.indexOf(frag) > -1; // plain substring for loose custom entries
}
function matchesList(list) {
for (var i = 0; i < list.length; i++) { if (hostMatches(list[i])) return true; }
return false;
}
// The platform name behind a referrer, used for the "<name>_organic" buckets. Anything not
// listed reports its registrable-ish host label (e.g. "ecosia" for ecosia.org).
function referrerName() {
if (hostMatches('google.')) return 'google';
if (hostMatches('bing.')) return 'bing';
if (hostMatches('yahoo.')) return 'yahoo';
if (hostMatches('duckduckgo.')) return 'duckduckgo';
if (hostMatches('facebook.') || hostMatches('fb.')) return 'facebook';
if (hostMatches('instagram.')) return 'instagram';
if (hostMatches('linkedin.')) return 'linkedin';
if (hostMatches('twitter.') || hostMatches('x.com') || hostMatches('t.co')) return 'twitter';
if (hostMatches('youtube.')) return 'youtube';
if (hostMatches('reddit.')) return 'reddit';
if (hostMatches('pinterest.')) return 'pinterest';
if (hostMatches('tiktok.')) return 'tiktok';
var labels = refHost.replace(/^www\./, '').split('.');
return slug(labels.length > 1 ? labels[labels.length - 2] : labels[0]);
}
// Cookie reader — exact name match, so "_gcl_aw" can't be satisfied by "x_gcl_aw".
function readCookie(name) {
var parts = document.cookie ? document.cookie.split('; ') : [];
for (var i = 0; i < parts.length; i++) {
var pair = parts[i].split('=');
if (pair[0] === name) {
try { return decodeURIComponent(pair.slice(1).join('=')); } catch (e) { return ''; }
}
}
return '';
}
// Platform cookies embed the click ID AND its creation time. Only a FRESH cookie — created
// within the session window — may classify an entry. A stale one just means "returning
// visitor" (these cookies live ~90 days); using it would erase Direct and double-credit ads
// for months. Returns the click ID, or null.
function freshClickCookie(name, tsIndex, tsUnit) {
var raw = readCookie(name);
if (!raw) return null;
var parts = raw.split('.');
var ts = parseInt(parts[tsIndex], 10);
if (!ts) return null;
if (tsUnit === 's') ts = ts * 1000;
var age = Date.now() - ts;
if (age < 0 || age > SESSION_TIMEOUT) return null;
return parts.slice(tsIndex + 1).join('.') || null;
}
function freshGclid() { return freshClickCookie('_gcl_aw', 1, 's'); } // "_gcl_aw" = GCL.{seconds}.{gclid}
function freshFbclid() { return freshClickCookie('_fbc', 2, 'ms'); } // "_fbc" = fb.1.{ms}.{fbclid}
// Does THIS pageview carry a campaign signal — any UTM or any ad-platform click ID? Read from
// the URL only: cookies outlive the click by weeks and must never look like a fresh arrival.
function hasCampaignSignal() {
if (param('utm_source') || param('utm_medium') || param('utm_campaign')) return true;
return !!(param('gclid') || param('gbraid') || param('wbraid') ||
param('msclkid') || param('fbclid') || param('ttclid'));
}
// ---------------------------------------------------------------------------------------
// Resolver — the MTK engine's rule order, flattened to one string. First match wins.
// ---------------------------------------------------------------------------------------
function resolve() {
var utmSourceRaw = param('utm_source');
var utmSource = lower(utmSourceRaw);
var utmMedium = lower(param('utm_medium'));
var gclid = param('gclid');
var gbraid = param('gbraid'); // iOS web-to-app Google Ads click ID
var wbraid = param('wbraid'); // iOS app-to-web Google Ads click ID
var msclkid = param('msclkid');
var fbclid = param('fbclid');
var ttclid = param('ttclid');
// 1. Paid Search — any Google / Microsoft click ID, or utm_medium=cpc / paid_search
// (manual tagging with auto-tagging off, or a click ID stripped by a redirect).
if (gclid || gbraid || wbraid || msclkid || utmMedium === 'cpc' || utmMedium === 'paid_search') {
if (msclkid || utmSource === 'bing' || utmSource === 'bingads' || utmSource === 'microsoft') return 'microsoft_ads';
if (!utmSource || utmSource === 'google' || utmSource === 'googleads' || utmSource === 'adwords') return 'google_ads';
return sourceName(utmSourceRaw) + '_ads'; // some other paid-search network, tagged by hand
}
// 2. Paid Social — REQUIRES the utm_medium convention. Meta appends fbclid to organic posts
// too, so fbclid / _fbc alone can never prove a paid click.
if (utmMedium === PAID_SOCIAL_MEDIUM) {
return (sourceName(utmSourceRaw) || 'facebook') + '_ads';
}
// 3. TikTok Ads — ttclid is paid-only (organic TikTok links don't carry it).
if (ttclid) return 'tiktok_ads';
// 4. Email — "<source>_email" (newsletter_email, klaviyo_email), or plain "email" when untagged.
if (utmMedium === 'email') {
return utmSource ? sourceName(utmSourceRaw) + '_email' : 'email';
}
// 5. Any other tagged campaign — "<source>_<medium>" (partner_referral, podcast_sponsorship),
// or just "<source>" when there is no medium.
if (utmSource) {
return utmMedium ? sourceName(utmSourceRaw) + '_' + slug(utmMedium) : sourceName(utmSourceRaw);
}
// 6. Untagged arrival with an external referrer.
if (hasReferrer) {
if (matchesList(SEARCH_DOMAINS)) {
// Same-platform upgrade: an ad click FROM google.com whose gclid was lost in transit
// still set a fresh _gcl_aw — that's Google Ads, not organic.
if (hostMatches('google.') && freshGclid()) return 'google_ads';
return referrerName() + '_organic';
}
if (matchesList(SOCIAL_DOMAINS) || fbclid) {
// Same-platform upgrade — but ONLY when the URL carried no fbclid. The Pixel writes _fbc
// from whichever fbclid it sees, so a fresh _fbc next to an fbclid is just that (possibly
// organic) click echoed back. With no fbclid in the URL, a fresh _fbc is real evidence of
// a paid click whose parameter went missing.
if (!fbclid && (hostMatches('facebook.') || hostMatches('fb.') || hostMatches('instagram.')) && freshFbclid()) {
return 'facebook_ads';
}
// fbclid from a referrer outside the social list (e.g. a Messenger in-app browser) is still Meta.
return (matchesList(SOCIAL_DOMAINS) ? referrerName() : 'facebook') + '_organic';
}
return 'referral'; // some other site we don't bucket
}
// 7. fbclid with no referrer at all (some in-app browsers strip it) — still an organic Meta click.
if (fbclid) return 'facebook_organic';
// 8. Cookie rescue — last resort before Direct. A FRESH platform cookie means an ad click just
// happened but its URL signal never reached us (consent redirect, URL rewrite, tag first
// firing mid-session). Stale cookies never classify.
if (freshGclid()) return 'google_ads';
if (freshFbclid()) return 'facebook_ads';
// 9. Nothing identifying — typed URL, bookmark, app, dark social.
return 'direct';
}
// ---------------------------------------------------------------------------------------
// Entry handling — reuse the stored entry through internal navigation; re-resolve on a fresh
// campaign signal, after the idle window, or when nothing is stored yet.
// ---------------------------------------------------------------------------------------
var stored = readStore();
var expired = !stored || !stored.t || (Date.now() - stored.t) > SESSION_TIMEOUT;
if (stored && stored.v && !expired && !hasCampaignSignal()) {
writeStore(stored.v); // touch last-activity, keep the value
return stored.v;
}
var value = resolve();
writeStore(value);
return value;
}