Emtiaz
ServicesCase StudiesTestimonialsAboutFAQBlogs
Markting Analytics & Attribution6 min read

How I Built a Shopify Attribution Pipeline (UTM & Click IDs → Orders)

E

Author Name

Emtiaz Hossain

Last Edit : Jul 20, 2026, 04:35:00 PM
How I Built a Shopify Attribution Pipeline (UTM & Click IDs → Orders)

TABLE OF CONTENTS▼
Why Shopify Loses AttributionThe Fix: Capture, Persist, AttachWhat Gets CapturedWhy localStorage and Not CookiesThe Capture ScriptAttaching Attribution to the OrderThe ResultKey TakeawaysFrequently Asked QuestionsDoes this attribution data survive if the customer leaves and comes back days later?What happens if a customer's first and last touch use different campaigns?Will this work if a customer switches devices between clicking the ad and checking out?Does adding this script slow down the storefront?

Contents

0%
Why Shopify Loses AttributionThe Fix: Capture, Persist, AttachWhat Gets CapturedWhy localStorage and Not CookiesThe Capture ScriptAttaching Attribution to the OrderThe ResultKey TakeawaysFrequently Asked QuestionsDoes this attribution data survive if the customer leaves and comes back days later?What happens if a customer's first and last touch use different campaigns?Will this work if a customer switches devices between clicking the ad and checking out?Does adding this script slow down the storefront?

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.

Why Shopify Loses Attribution

Attribution data only exists in the URL the moment a visitor lands:

javascript
?utm_source=google&utm_medium=cpc&utm_campaign=summer_sale&gclid=123abc

Shopify's default behavior breaks that in a few specific ways:

  • ▸UTM parameters disappear the instant the visitor navigates to a second page, they were never in the URL to begin with
  • ▸A returning visitor who bookmarks the site or comes back directly loses the original campaign entirely
  • ▸Multi-step checkout can drop query parameters between steps
  • ▸Nothing in the order object captures any of this by default

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 Fix: Capture, Persist, Attach

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.

What Gets Captured

Ad platform click IDs, these are what let revenue trace back to the specific ad platform:

PlatformParameter
Google Adsgclid, gbraid, wbraid
Meta Adsfbclid
TikTokttclid
Microsoft Adsmsclkid
LinkedInli_fat_id
Pinterestepik
Snapchatsc_click_id
Twitter/Xtwclid
Redditrdtclid

First-party tracking cookies, where the platform already sets one:

CookieSet by
_fbpMeta (browser identifier)
_fbcMeta (click identifier)
_ttpTikTok

Standard UTM parameters: utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id.

Why localStorage and Not Cookies

MethodLimitation
CookiesCan be blocked or overwritten by the browser or other scripts
HttpOnly cookiesNot readable by JavaScript at all
URL parameters aloneGone the moment the visitor navigates
localStoragePersists across pages and return visits, readable client-side

The Capture Script

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.

javascript
(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:

json
{
  "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"
  }
}

Attaching Attribution to the Order

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:

javascript
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:

javascript
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]

The Result

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.

Key Takeaways

  • ▸Attribution data only exists in the URL for a single page load, unless you deliberately persist it, it's gone the moment the visitor navigates
  • ▸localStorage beats cookies here because it isn't blocked by the same restrictions and survives return visits
  • ▸Merging instead of overwriting matters: a returning visitor's second-session UTMs shouldn't erase their original first-touch click ID
  • ▸Cart attributes are the mechanism that gets client-side data onto the actual Shopify order, capture without this step is just data sitting in the browser

Frequently Asked Questions

Does this attribution data survive if the customer leaves and comes back days later?

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.

What happens if a customer's first and last touch use different campaigns?

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.

Will this work if a customer switches devices between clicking the ad and checking out?

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.

Does adding this script slow down the storefront?

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.

#GA4#GTM#ecommerce#web analytics#full stack web analytics#marketing#tracking#tracking setup#AdvancedTracking#JavaScript#Marketing Attribution#UTM Parameters

Discussed this on LinkedIn

Found this helpful?

Drop a comment on the LinkedIn post – I read every reply and love connecting with tracking folks.

View on LinkedIn

Not Sure If Your Tracking Is Costing You?

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.

More Blog Posts

View all
The GA4 & GTM Audit That Found a Shopify Store's Google Ads Data Was 2x Inflated
Audit and Reporting

The GA4 & GTM Audit That Found a Shopify Store's Google Ads Data Was 2x Inflated

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.

Complete LinkedIn Conversion Tracking Setup
Advanced Tracking

Complete LinkedIn Conversion Tracking Setup

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.

Fixing Double Form Submission Tracking in GTM
Audit and Reporting

Fixing Double Form Submission Tracking in GTM

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.

Emtiaz
EmailLinkedInPrivacyTerms

© 2026 Emtiaz Hossain