Author Name
Emtiaz Hossain
Most Shopify stores lose attribution the moment a visitor leaves the landing page. A shopper clicks a Google Ad, browses for two days, comes back through a bookmark, and checks out, and the order in Shopify admin shows no trace of the campaign that actually earned it. Multiply that across every paid channel and the reporting a marketing team relies on to make budget decisions is quietly wrong.
This is the architecture I built for TinyBot Vinyl, a Shopify vinyl record store running paid campaigns across Google and Meta, to capture attribution at first click and carry it all the way through to the order.
Attribution data only exists in the URL the moment a visitor lands:
?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&gclid=123abcShopify's default behavior breaks that in a few specific ways:
For an agency running paid budget across multiple platforms, this isn't a cosmetic gap. It means Smart Bidding and reporting are both working from incomplete signal.
The approach has three layers: capture attribution data on landing, persist it in localStorage so it survives navigation and return visits, then flatten and attach it to the Shopify cart so it rides along into the order.
Ad platform click IDs, these are what let revenue trace back to the specific ad platform:
| Platform | Parameter |
|---|---|
| Google Ads | gclid, gbraid, wbraid |
| Meta Ads | fbclid |
| TikTok | ttclid |
| Microsoft Ads | msclkid |
| li_fat_id | |
| epik | |
| Snapchat | sc_click_id |
| Twitter/X | twclid |
| rdtclid |
First-party tracking cookies, where the platform already sets one:
| Cookie | Set by |
|---|---|
| _fbp | Meta (browser identifier) |
| _fbc | Meta (click identifier) |
| _ttp | TikTok |
Standard UTM parameters: utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id.
| Method | Limitation |
|---|---|
| Cookies | Can be blocked or overwritten by the browser or other scripts |
| HttpOnly cookies | Not readable by JavaScript at all |
| URL parameters alone | Gone the moment the visitor navigates |
| localStorage | Persists across pages and return visits, readable client-side |
This runs on every page load, injected into theme.liquid right after <head>. It reads whatever click IDs, cookies, and UTM parameters are present in the current URL, merges them into whatever's already stored (new non-empty values win, existing values are preserved if the current page has none), and writes the result back to localStorage.
(function () {
var LS_KEY = "attribution_data";
function getParam(name) {
try { return new URLSearchParams(location.search).get(name); }
catch (e) { return null; }
}
function getCookie(name) {
try {
var m = document.cookie.match(
new RegExp("(?:^|; )" + name.replace(/[$()*+./?[\\\]^{|}-]/g, "\\$&") + "=([^;]*)")
);
return m ? decodeURIComponent(m[1]) : null;
} catch (e) { return null; }
}
function setLS(key, obj) {
try { localStorage.setItem(key, JSON.stringify(obj)); } catch (e) {}
}
function getLS(key) {
try {
var v = localStorage.getItem(key);
return v ? JSON.parse(v) : null;
} catch (e) { return null; }
}
function merge(a, b) {
a = a || {};
Object.keys(b || {}).forEach(function (k) {
if (b[k] !== null && b[k] !== undefined && b[k] !== "") {
a[k] = b[k];
}
});
return a;
}
var clickIdKeys = ["gclid", "gbraid", "wbraid", "fbclid", "ttclid", "msclkid", "li_fat_id", "epik", "sc_click_id", "twclid", "rdtclid"];
var utmKeys = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id"];
var newClickIds = {};
clickIdKeys.forEach(function (k) {
var v = getParam(k);
if (v) newClickIds[k] = v;
});
var newUtm = {};
utmKeys.forEach(function (k) {
var v = getParam(k);
if (v) newUtm[k] = v;
});
var newCookies = {
_fbp: getCookie("_fbp"),
_fbc: getCookie("_fbc"),
_ttp: getCookie("_ttp"),
};
var stored = getLS(LS_KEY) || {};
var updated = {
click_ids: merge(stored.click_ids, newClickIds),
cookies: merge(stored.cookies, newCookies),
utm: merge(stored.utm, newUtm),
meta: {
landing_url: (stored.meta && stored.meta.landing_url) ? stored.meta.landing_url : window.location.href,
last_seen_url: window.location.href,
},
};
setLS(LS_KEY, updated);
})();This only captures and stores. It doesn't send anything to Shopify yet, that happens at checkout, in the next step.
The stored object looks like this after a visit:
{
"click_ids": {
"gclid": "abc123",
"fbclid": "xyz456"
},
"cookies": {
"_fbp": "fb.1.123456789"
},
"utm": {
"utm_source": "google",
"utm_campaign": "summer_sale"
},
"meta": {
"landing_url": "https://example.com/?utm_source=google",
"last_seen_url": "https://example.com/collections/all"
}
}Storing the data client-side doesn't help unless it ends up on the order. Shopify's cart attributes are the bridge, anything written there rides through checkout and shows up on the order in admin. This runs when the cart page loads or before checkout begins:
function flattenAttribution(data) {
var flat = {};
Object.assign(flat, data.click_ids || {});
Object.assign(flat, data.utm || {});
if (data.meta && data.meta.landing_url) {
flat.landing_url = data.meta.landing_url;
}
return flat;
}
var attributionData = JSON.parse(localStorage.getItem("attribution_data") || "{}");
var flatAttributes = flattenAttribution(attributionData);
fetch("/cart/update.js", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ attributes: flatAttributes }),
});Once this fires, the order in Shopify admin shows attributes like:
utm_source : google
utm_medium : cpc
utm_campaign : summer_sale
gclid : Cj0KCQj...
landing_url : https://example.com/?utm_source=google[Screenshot: Shopify order admin panel showing populated cart attributes]
After this went live on TinyBot Vinyl's store, attributed orders that previously showed no campaign source started carrying full first-touch data, click IDs included. The agency reported a 716% ROAS on the campaigns this made visible, revenue that existed before but couldn't be tied back to the ads driving it.
Yes, that's the point of persisting it to localStorage rather than relying on the URL or a session cookie alone. As long as the browser storage isn't cleared, the captured UTM and click ID values stay attached to that visitor until checkout, even across multiple sessions.
The merge logic keeps both: first-touch values are captured once and never overwritten, while last-touch values update on every new qualifying visit. Both sets get attached to the order at checkout, so you can build first-touch and last-touch attribution reports from the same dataset instead of picking one model upfront.
No. This is a client-side, browser-storage-based approach, so it can't bridge a gap between a phone click and a desktop purchase. Cross-device attribution needs a server-side or identity-resolution layer on top of this, this pipeline solves the much more common problem of same-device attribution loss from cookie restrictions and ad blockers.
Not meaningfully. The capture logic runs once on page load and only writes to localStorage, it doesn't make network calls or block rendering. The only network activity is the existing call to Shopify's /cart/update.js, which the store already uses.
Discussed this on LinkedIn
Found this helpful?
Drop a comment on the LinkedIn post – I read every reply and love connecting with tracking folks.
Book a free 30-minute call. I will review your current tracking setup, show you where conversions are leaking, and tell you exactly what I would fix.
A full GA4/GTM/Google Ads audit on a UK Shopify store spending £18,000+/month found duplicate conversion tags inflating reported purchases 2x, a dropped GCLID at checkout, and misconfigured variables silently degrading Enhanced Conversions. Here's the audit, the fixes, and the migration off a legacy GTM setup before Shopify's deprecation deadline.
How a UK marketing agency's LinkedIn Insight Tag upgrade turned into a full conversion audit, catching duplicate tags and stale triggers across eight lead-gen conversions.
Duplicate form conversions in GA4 are usually a reload firing the same tag twice, not a real second submission. Here's the Custom JavaScript variable that catches it, plus other common causes to rule out.