B2B SaaS only See if you're a fit →

← All articles

Offline conversion tracking

How to Capture GCLID in HubSpot Hidden Fields (GTM, 2026)

By James Gregg · October 30, 2026

When I check why an account’s offline conversions look thin, it’s usually not the CRM sync. It’s that the click ID never made it into the form. The buyer clicked an ad, read your pricing page, wandered into the docs, came back to the demo form, and the GCLID was long gone from the URL.

The fix is one Google Tag Manager tag and a few hidden fields. The tag reads every ad platform’s click ID off the landing URL, stores each one in a first-party cookie for 90 days (with a localStorage backup), and writes them into matching hidden fields on your HubSpot forms. Then every form submission carries the click ID into your CRM, ready to send back to the ad platforms. Below is the tag, the HubSpot setup, and how to test it in about 10 minutes. This is the detailed version of Step 1 in how to set up offline conversion tracking in Google Ads.

Which click IDs should you capture?

All of them you can get. Even if you only send Google conversions today, the day you turn on a second channel you’ll want its history.

PlatformURL parameterAdded automatically?
Google Adsgclid, gbraid, wbraid, dclidYes, with auto-tagging on
Microsoft AdsmsclkidYes, with auto-tagging on
MetafbclidYes
LinkedInli_fat_idYes, once first-party tracking is on in the Insight Tag
OpenAI AdsopprefYes
Redditrdt_cidNo. Add rdt_cid={rdt_cid} to the ad’s click URL
StackAdaptref_id (or whatever you name it)Set in StackAdapt’s tracking settings, using the {SA_POSTBACK_ID} macro
TikTok, X, Pinterestttclid, twclid, epikYes

Two of those bite people. Reddit won’t send anything unless you add the parameter yourself, and you can’t edit the URL once the ad is live. StackAdapt lets you name its parameter anything in the tracking settings. I usually see ref_id, but check yours and use the same name in the tag. (If an affiliate or referral tool on your site also uses ref_id, rename StackAdapt’s to something unique so the two don’t overwrite each other.) YouTube ads run through Google Ads, so their clicks come in as Google click IDs and are already covered.

What does the GTM tag look like?

Here’s the whole thing. It’s the same logic we run in our own client setups, trimmed to the parts you need.

<script>
(function () {
  // ---- Edit these two lines ----
  var ROOT_DOMAIN = 'example.com'; // your root domain, no "www"
  var DAYS = 90;                   // Google accepts a GCLID up to 90 days old

  // Click IDs to capture. Add your own if you use a custom parameter.
  var IDS = ['gclid', 'gbraid', 'wbraid', 'dclid', // Google
             'msclkid',                            // Microsoft
             'fbclid',                             // Meta
             'li_fat_id',                          // LinkedIn
             'rdt_cid',                            // Reddit
             'oppref',                             // OpenAI Ads
             'ttclid', 'twclid', 'epik',           // TikTok, X, Pinterest
             'ref_id'];                            // StackAdapt (match your tracking settings)

  var MAX_MS = DAYS * 864e5;
  var host = location.hostname;
  var onRoot = host === ROOT_DOMAIN || host.slice(-ROOT_DOMAIN.length - 1) === '.' + ROOT_DOMAIN;
  var domainAttr = onRoot ? '; domain=.' + ROOT_DOMAIN : '';

  function writeCookie(name, value, ms) {
    document.cookie = name + '=' + encodeURIComponent(value) +
      '; max-age=' + Math.floor(ms / 1000) + '; path=/' + domainAttr + '; SameSite=Lax; Secure';
  }
  function readCookie(name) {
    var m = document.cookie.match('(?:^|; )' + name + '=([^;]*)');
    return m ? decodeURIComponent(m[1]) : '';
  }
  function readBackup(name) {
    try {
      var o = JSON.parse(localStorage.getItem(name));
      if (o && Date.now() - o.ts < MAX_MS) return o;
    } catch (e) {}
    return null;
  }

  // 1) Capture: a click ID on the URL wins and starts a fresh 90-day clock.
  var params = new URLSearchParams(location.search);
  IDS.forEach(function (id) {
    var key = 'cid_' + id;
    var fromUrl = params.get(id);
    if (fromUrl) {
      writeCookie(key, fromUrl, MAX_MS);
      try { localStorage.setItem(key, JSON.stringify({ v: fromUrl, ts: Date.now() })); } catch (e) {}
    } else if (!readCookie(key)) {
      var b = readBackup(key); // cookie gone? restore it from the backup, same expiry
      if (b) writeCookie(key, b.v, MAX_MS - (Date.now() - b.ts));
    }
  });

  window.getClickId = function (id) {
    var key = 'cid_' + id;
    var b = readBackup(key);
    return readCookie(key) || (b ? b.v : '');
  };

  // 2) HubSpot forms (new editor): fill hidden fields when the form is ready.
  window.addEventListener('hs-form-event:on-ready', function (event) {
    var form = HubSpotFormsV4.getFormFromEvent(event);
    IDS.forEach(function (id) {
      var v = window.getClickId(id);
      if (!v) return;
      try { form.setFieldValue('0-1/' + id, [v]); } catch (e) {} // hidden fields take an array
    });
  });
})();
</script>

What it does, in plain terms:

  • A click ID on the URL always wins. It overwrites whatever was stored and starts a fresh 90-day clock. Latest click wins, which is what the platforms expect.
  • No click ID on the URL? It leaves the stored one alone. Browsing to the docs and back doesn’t wipe anything.
  • If the cookie got cleared, it restores it from localStorage with the original expiry, so a 90-day-old GCLID never gets passed off as new.
  • It’s scoped to your root domain, so a click that lands on www. still counts on app. or go..
  • It never lowercases anything. Click IDs are case-sensitive, and a “cleaned up” GCLID won’t match.

How do you install it?

  1. In GTM: Tags → New → Tag Configuration → Custom HTML. Paste the script and change ROOT_DOMAIN to yours.
  2. Triggering → Initialization – All Pages. It has to run before your forms render (more on that below).
  3. Name it something you’ll recognize, like “Click ID capture”, then test in Preview before you publish the container.

How do you set up the HubSpot fields?

You need one contact property per click ID, and each one added to your forms as a hidden field.

  1. Create the properties. In HubSpot, go to Settings → Data Management → Properties, choose Contact properties, and create one for each ID: field type single-line text, with an internal name that exactly matches the parameter (gclid, msclkid, fbclid, li_fat_id, rdt_cid, oppref, and so on). The tag writes to 0-1/ plus the internal name, so the names have to match. HubSpot moves its menus around now and then, but the property settings stay the same.
  2. Add them to every form that matters (demo, contact, trial, content) and mark each one as a hidden field. A hidden field can’t be required, so leave that off.
  3. That’s it for the new forms editor. The tag listens for HubSpot’s hs-form-event:on-ready event and fills the fields with setFieldValue. Hidden fields take an array, which is why the value is wrapped in brackets.

What about legacy HubSpot forms or Webflow forms?

The on-ready event only fires on forms built in HubSpot’s updated editor. For legacy HubSpot embeds, or a plain HTML form (Webflow, WordPress) with hidden inputs named gclid and so on, add this second Custom HTML tag on a DOM Ready trigger. It reuses the capture tag’s getClickId:

<script>
(function () {
  var IDS = ['gclid', 'gbraid', 'wbraid', 'dclid', 'msclkid', 'fbclid', 'li_fat_id',
             'rdt_cid', 'oppref', 'ttclid', 'twclid', 'epik', 'ref_id'];
  function fill() {
    if (!window.getClickId) return;
    IDS.forEach(function (id) {
      var v = window.getClickId(id);
      if (!v) return;
      document.querySelectorAll('input[name="' + id + '"]').forEach(function (el) {
        if (!el.value) { el.value = v; el.dispatchEvent(new Event('input', { bubbles: true })); }
      });
    });
  }
  fill(); // plain HTML forms already on the page
  window.addEventListener('message', function (e) { // legacy HubSpot embeds
    if (e.data && e.data.type === 'hsFormCallback' && e.data.eventName === 'onFormReady') fill();
  });
})();
</script>

One warning for non-HubSpot forms: HubSpot’s tracking code doesn’t collect hidden fields from them. If your Webflow form reaches HubSpot through the tracking code alone, the click IDs get dropped. Send those submissions through HubSpot’s Forms API or a native integration that maps the hidden inputs to properties.

How do you test that it works?

Ten minutes, and do it before you trust a single offline conversion:

  1. Open GTM Preview and load your site with a fake click ID: yoursite.com/?gclid=TEST-AbC-123.
  2. In Chrome DevTools → Application → Cookies, confirm cid_gclid is there, on .yoursite.com, expiring in 90 days. Check the value kept its capital letters.
  3. Click to two or three other pages without the parameter, then go to your demo form. This is the step that catches most broken setups.
  4. Submit the form with a test email. Open that contact in HubSpot and check the gclid property says TEST-AbC-123.
  5. Repeat with ?msclkid= and ?li_fat_id= if you run those, then delete the test contacts.

If step 4 comes back empty, it’s almost always one of three things: the property’s internal name doesn’t match, the field isn’t on that form, or the form beat the tag (next section).

What breaks this setup?

  • The race. HubSpot says to register the on-ready listener before the embed code runs. GTM loads asynchronously, so if your form embed sits high in the page HTML, it can render first. If you see empty fields on fast pages, paste the capture script directly in the page head, above the form embed, instead of (or as well as) GTM.
  • Safari. WebKit deletes cookies set by JavaScript, and localStorage, after seven days without a visit, and caps them at 24 hours in some link-decoration cases. The backup doesn’t beat that. A buyer who comes back on Safari three weeks later shows up with nothing, and that’s the gap server-side tracking closes.
  • In-app browsers. Clicks from Instagram or Facebook open in the app’s own browser with its own cookie jar. If that person converts later in Chrome, no cookie connects the two. On one client it was a big share of Meta traffic. Expect it.
  • Redirects that strip parameters. A vanity URL, a geo redirect, or a “clean URL” plugin can drop the click ID before the tag ever sees it. Click your own ad (or use the platform’s preview) and check the final URL still has it.
  • Consent. If your consent banner blocks the tag until someone opts in, it can’t capture anything for people who don’t. Decide with whoever owns privacy whether this tag counts as essential or marketing, and set GTM’s consent settings to match.

What do you do with the click IDs once they’re in HubSpot?

Send them back. Once every contact carries its click IDs, your CRM stages can go back to Google tied to the original click, which is the whole point. Which stages to send, what to value them at, and when to bid on them is all in the full offline conversion tracking setup guide. If you’re still deciding whether it’s worth the effort, here’s my honest take on OCT.

This tag gets most accounts most of the way. In my experience, storing click IDs in a cookie adds 5–10% more tracked conversions and the hidden fields roughly another 10%. In a low-volume account, that can be the difference between five tracked leads and ten. It’s boring plumbing under everything in our multi-channel PPC playbook, and it’s usually the first thing I check.

Not sure your forms are actually catching click IDs? That’s part of our free PPC audit.

Frequently asked questions

How do I get the GCLID into a HubSpot form?

Create a single-line text contact property named gclid, add it to the form as a hidden field, then use a Google Tag Manager tag to store the GCLID in a first-party cookie on landing and write it into the field when the form loads. On new HubSpot forms, use the hs-form-event:on-ready event and setFieldValue.

How long should click ID cookies last?

Set them for 90 days, the oldest GCLID Google Ads will accept for an offline conversion. Safari still deletes cookies set by JavaScript after seven days without a visit, so a browser cookie alone won't hold a long B2B journey. Server-side tracking covers the rest.

Does Google's Conversion Linker already store the GCLID?

It stores it in Google's own _gcl cookies for Google's tags to read, in a format Google doesn't document for you to use. It doesn't put the GCLID into your CRM, and it does nothing for Microsoft, Meta, LinkedIn, Reddit or OpenAI click IDs. Store your own copy.

Do Reddit and StackAdapt add a click ID automatically?

No. Google, Microsoft, Meta and OpenAI append theirs automatically, and LinkedIn does once first-party tracking is on in the Insight Tag. Reddit needs you to add rdt_cid to the ad's click URL yourself before the ad goes live. StackAdapt's parameter is set in its tracking settings and can be named anything (often ref_id), so match the name in your tag.

Why is my HubSpot hidden field empty even though the cookie is set?

Usually a race: the form rendered before the GTM tag registered its listener. Fire the tag on Initialization, or paste the same script in the page head above the form embed. Also check the field's internal name matches the ID exactly, and that it's a contact property.

Want to know what's actually holding PPC back?

Get a free audit with real fixes—not a pitch deck.

If we're not the right fit, we'll tell you—and you'll still leave with useful next steps.