UTM Cross Domain GTM Tag setup guide

This GTM tag captures the UTM tracking parameters a visitor arrives with on your own website and carries them through to the Ticketsolve booking flow, so campaign sales are attributed correctly in Your Reports. Without it, those parameters are dropped when the visitor crosses from your website into Ticketsolve, and the Formatted Trackers field comes back empty.

Set this up if you want to see which campaigns drive your bookings when the booking journey starts on your own website, then continues into Ticketsolve.

Not sure about GTM? The steps below assume you can access and publish changes to your GTM container. If you'd rather we set this up, or you're not certain you have that access, get in touch and we'll help.

What the tag captures

The tag captures a fixed list of tracking parameters from the entry page's address. Anything not on this list is not captured.

  • UTM parameters: utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id, utm_social
  • Ad-platform click IDs: gclid (Google Ads), gbraid and wbraid (Google, iOS), dclid (Google Display), msclkid (Microsoft Ads), fbclid (Meta)
  • Mailchimp: mc_cid, mc_eid

Before you start

Have these to hand before you begin.

  • Access to your GTM container - you'll be adding one variable and one tag, then publishing.
  • Your booking subdomain - the subdomain your booking flow runs on, whether a ticketsolve.com subdomain, or a subdomain mapped to your own domain.
  • Your consent event - the data layer event your cookie banner fires when marketing consent is granted. You'll confirm this in GTM Preview during setup.

Set it up

Work through these in your GTM container.

1. Go to Variables > New, choose type Constant, and name it exactly Cross Domain Tracking Domains. In the value, enter the booking host(s), separated by commas or spaces:

  • Booking flow on ticketsolve.com - include: ticketsolve.com
  • Booking flow on mapped subdomain - include that subdomain, for example: myvenue-tickets.venue.co.uk
  • If unsure - include both, for example: ticketsolve.com, myvenue-tickets.venue.co.uk

2. Create the trigger for your consent event, not All Pages.

  • In GTM Preview on your site, accept the cookie banner and watch the event list for the event that appears at that moment. Common events* are: cookie_consent_update, consent_update, or a CMP-specific event.
Example consent event in GTM.
  • Go to Triggers > New and create a Custom Event trigger matching that consent event name.

*The consent event is whatever your own cookie banner fires when marketing consent is granted. There's no single fixed name, which is why you confirm it in Preview rather than assuming one.

3. Go to Tags > New, choose type Custom HTML, name it: UTM Trackers - Cross Domain, then expand the tag below and copy it into the HTML field.

Show the tag script (click to expand and copy)
<script>
  // Ticketsolve UTM cross-domain tracker tag - v12 (GTM Custom HTML)
  // Requires ONE GTM Constant variable (create it before saving this tag):
  //   Cross Domain Tracking Domains  (required) - booking host(s), comma or space separated

  var TRACKER_KEYS = ['mc_eid','mc_cid','utm_source','utm_medium','utm_campaign','gclid','msclkid','utm_term','utm_content','fbclid','utm_id','utm_social','dclid','gbraid','wbraid'];
  var SS_PREFIX = 'tsutm_';
  var DECOR_FLAG = 'data-tsutm-done';
  var DEBOUNCE_MS = 300;

  function log(){ try { console.log.apply(console, ['[UTM-TAG]'].concat([].slice.call(arguments))); } catch(e){} }

  // Required Constant. Tolerant of a string, a comma/space list, or a single-element array.
  function getTrackedDomains(){
    var raw = {{Cross Domain Tracking Domains}};
    if (Object.prototype.toString.call(raw) === '[object Array]') raw = raw.join(',');
    return String(raw).split(/[,\s]+/).filter(function(d){ return d; }).map(function(d){ return d.toLowerCase().replace(/^\./,''); });
  }

  // sessionStorage survives same-tab navigation within one origin and dies when the tab closes.
  // Errors (private mode, storage blocked) are swallowed; the tag then degrades to URL-only.
  function ssGet(k){ try { return sessionStorage.getItem(SS_PREFIX + k) || ''; } catch(e){ return ''; } }
  function ssSet(k, v){ try { sessionStorage.setItem(SS_PREFIX + k, v); } catch(e){} }

  // Lenient read from the current URL. URLSearchParams does not throw on malformed percent-encoding
  // and does not truncate values that themselves contain "=".
  function urlParam(key){
    try { var v = new URLSearchParams(location.search).get(key); return v === null ? '' : v; }
    catch(e){ return ''; }
  }

  // Surgical single-key update; leaves the rest of the href byte-for-byte so already-encoded
  // booking params are not disturbed. Value is (re-)encoded on write.
  function updateQueryString(key, value, url){
    var re = new RegExp('([?&])' + key + '=.*?(&|#|$)(.*)', 'gi'), hash, enc = encodeURIComponent(value);
    if (re.test(url)){
      return url.replace(re, '$1' + key + '=' + enc + '$2$3');
    } else {
      var sep = url.indexOf('?') !== -1 ? '&' : '?';
      hash = url.split('#');
      url = hash[0] + sep + key + '=' + enc;
      if (typeof hash[1] !== 'undefined' && hash[1] !== null) url += '#' + hash[1];
      return url;
    }
  }

  function capture(){
    TRACKER_KEYS.forEach(function(k){ var v = urlParam(k); if (v !== '') ssSet(k, v); });
  }
  function resolveKey(k){
    var v = urlParam(k);
    if (v === '') v = ssGet(k);
    else ssSet(k, v);
    return v;
  }
  function getValues(){
    var values = {}, any = false;
    TRACKER_KEYS.forEach(function(k){ var v = resolveKey(k); values[k] = v; if (v !== '') any = true; });
    return any ? values : null;
  }

  // Host-based match on the parsed hostname, then the booking path gate.
  function isTrackedBookingLink(href, domains){
    var u;
    try { u = new URL(href, location.href); } catch(e){ return false; }
    var host = u.hostname.toLowerCase(), ok = false;
    for (var i = 0; i < domains.length; i++){
      var d = domains[i];
      if (host === d || (host.length > d.length && host.slice(-(d.length + 1)) === '.' + d)){ ok = true; break; }
    }
    if (!ok) return false;
    var p = u.pathname.toLowerCase();
    return p.indexOf('/ticketbooth/') !== -1 || /\/shows\/\d+/.test(p);
  }

  function closestAnchor(node){
    while (node && node.nodeType === 1){ if (node.tagName && node.tagName.toLowerCase() === 'a') return node; node = node.parentNode; }
    return null;
  }
  function decorateAnchor(el, values){
    for (var key in values){ if (values[key] !== '') el.href = updateQueryString(key, values[key], el.href); }
  }

  function decorateAll(when){
    var values = getValues(); if (!values){ if (when) log('decorateAll [' + when + '] no values'); return; }
    var domains = getTrackedDomains(), els = document.getElementsByTagName('a'), n = 0;
    for (var i = 0; i < els.length; i++){ var el = els[i];
      if (el.getAttribute(DECOR_FLAG)) continue;
      if (isTrackedBookingLink(el.href, domains)){ decorateAnchor(el, values); el.setAttribute(DECOR_FLAG, '1'); n++; }
    }
    if (when) log('decorateAll [' + when + '] decorated =', n);
  }

  // Intent-time decoration is the source of truth before navigation, ignores the done flag.
  function onIntent(e){
    var el = closestAnchor(e.target); if (!el) return;
    if (!isTrackedBookingLink(el.href, getTrackedDomains())) return;
    var values = getValues(); if (!values) return;
    decorateAnchor(el, values);
  }

  function debounce(fn, wait){ var t; return function(){ clearTimeout(t); t = setTimeout(fn, wait); }; }

  try {
    capture();
    decorateAll('immediate');
    window.addEventListener('load', function(){ decorateAll('load'); });
    if (window.MutationObserver){
      var deb = debounce(function(){ decorateAll(); }, DEBOUNCE_MS);
      new MutationObserver(deb).observe(document.documentElement, { childList: true, subtree: true });
    }
    document.addEventListener('mousedown', onIntent, true);
    document.addEventListener('click', onIntent, true);
    log('tag ready v12');
  } catch(e){ log('THREW', e && e.message ? e.message : e); }
</script>
  • Under Triggering, choose the consent event trigger you created in Step 2.
  • Open Advanced Settings > Consent Settings, and under Require additional consent for tag to fire add ad_storage. This sits on top of the consent-event trigger as a second safeguard. The trigger controls when the tag fires, the consent requirement controls whether it's allowed to.
  • Save your tag.

4. Click Submit, then Publish.

 Important

The trigger must be the consent event, not All Pages. A tag set to fire before consent is granted is blocked and never runs again, it does not wait for consent to arrive later. Firing on the consent event means the tag runs at the moment consent is granted, when it's allowed to do its work.

Check it works

  1. Open your site in a fresh private or incognito window with test UTM parameters added to the URL, for example ?utm_source=test&utm_medium=email&utm_campaign=check
  2. Accept marketing cookies.
  3. Browse to another page on the site that has a booking link, then click it. The address you land on should carry your utm_source=test and the rest. There may also be a _gl= code on the address, which is expected and can be ignored.
  4. Complete a test or comp booking, then run a Your Report with the Formatted Trackers field for that time window and confirm the codes show against the order.

Frequently asked questions

Why are the UTMs lost without this tag?

UTM codes live in the address of the page the visitor lands on. When they move from your website to Ticketsolve, nothing carries those codes across to the booking address unless they're added to the link. Without the tag, the booking flow never sees them, so there's nothing to record in Formatted Trackers.

Does it work across multiple pages on your site?

Yes. The tag keeps the codes for the browsing session and reapplies them, so a visitor can land, browse the About and What's On pages, then book, and the codes still come through. On a standard website the cookie banner re-confirms consent on each new page, so the tag runs across the whole journey. Single-page-application sites are handled too: because they don't reload between pages, the tag watches for booking links added after the first load and also applies the codes when a booking link is clicked.

What is the _gl code on the booking address?

That's Google's own cross-domain parameter, added by GA4 when the visitor clicks through. It sits alongside the UTM codes and does not interfere with them. You can ignore it.

Want to know more?

Was this article helpful?

Comments

0 comments

Please sign in to leave a comment.