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.
| Platform | URL parameter | Added automatically? |
|---|---|---|
| Google Ads | gclid, gbraid, wbraid, dclid | Yes, with auto-tagging on |
| Microsoft Ads | msclkid | Yes, with auto-tagging on |
| Meta | fbclid | Yes |
li_fat_id | Yes, once first-party tracking is on in the Insight Tag | |
| OpenAI Ads | oppref | Yes |
rdt_cid | No. Add rdt_cid={rdt_cid} to the ad’s click URL | |
| StackAdapt | ref_id (or whatever you name it) | Set in StackAdapt’s tracking settings, using the {SA_POSTBACK_ID} macro |
| TikTok, X, Pinterest | ttclid, twclid, epik | Yes |
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 onapp.orgo.. - It never lowercases anything. Click IDs are case-sensitive, and a “cleaned up” GCLID won’t match.
How do you install it?
- In GTM: Tags → New → Tag Configuration → Custom HTML. Paste the script and change
ROOT_DOMAINto yours. - Triggering → Initialization – All Pages. It has to run before your forms render (more on that below).
- 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.
- 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 to0-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. - 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.
- That’s it for the new forms editor. The tag listens for HubSpot’s
hs-form-event:on-readyevent and fills the fields withsetFieldValue. 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:
- Open GTM Preview and load your site with a fake click ID:
yoursite.com/?gclid=TEST-AbC-123. - In Chrome DevTools → Application → Cookies, confirm
cid_gclidis there, on.yoursite.com, expiring in 90 days. Check the value kept its capital letters. - 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.
- Submit the form with a test email. Open that contact in HubSpot and check the
gclidproperty saysTEST-AbC-123. - 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-readylistener 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.