Attribution
We were spending real money on Google Ads and couldn't tell which keywords turned into signups. Paid signups were showing up in HubSpot as direct traffic with no keyword, and Google Ads was reporting a fraction of the conversions it should have. So I went through every system in the chain to find out where the data was getting lost.
Map the whole chain before touching anything
Tracking at a startup piles up over the years, so before changing anything I went through every piece: Tag Manager on the website, Segment and all its destinations, HubSpot, Google Ads, GA4 and PostHog. Most of what looked messy was old stuff that was already turned off. The handful of things that were actually broken were all in the same spot, where someone goes from our website to signing up in the app.

Two tags fighting over one cookie
Google Ads was showing about one signup in two weeks when there should have been more than a dozen. Two tags in Tag Manager were both writing the click ID cookie in different formats, and the app only understood one of them, so whichever tag fired last decided whether the conversion counted. The fix was a few lines telling our custom tag to leave the cookie alone if Google's own tag had already set it.
var gclid = new URLSearchParams(location.search).get('gclid');
if (!gclid) return;
// Don't overwrite _gcl_aw if the Conversion Linker already wrote it.
// It writes GCL.timestamp.gclid, which the app's cookie reader expects.
// Our raw format was breaking it.
if (document.cookie.indexOf('_gcl_aw=') > -1) {
localStorage.setItem('gclid', gclid); // keep a backup copy
return;
}The keyword was dying between www and the app
With the click ID fixed, the keyword and campaign were still blank. I read the app's signup code to see exactly which cookies it reads, then loaded our site in a test browser and looked at the cookies directly. They were being saved to www only, so the app on its own subdomain couldn't see them. One line in the site's footer fixed it, and I checked it the same way afterward.
?utm_term=invoice+ocr- Click ID
- …
- Source
- …
- Keyword
- …
- new UtmCookie({ additionalParams: ['gclid'] })+ new UtmCookie({ domain: '.sensible.so', additionalParams: ['gclid'] })
Two destinations writing the same fields
Then some records looked right and quietly changed later. I went through every Segment destination's field mappings one by one, not just the list of names, and found two different HubSpot destinations writing the same first-touch fields from different places. Whichever fired last won. I fixed the mappings and picked one as the source of truth.
// 1. HubSpot Cloud Mode (server), on "Signed Up - Server"
"first_touch_keyword": "$.properties.utm_term",
"first_touch_campaign": "$.properties.utm_campaign",
// 2. HubSpot Web (browser), on identify
"first_touch_keyword": "$.traits.utm_cookies.IUTERM",
"first_touch_campaign": "$.traits.utm_cookies.UCAMPAIGN",
// same contact, same fields, last writer winsSend Google a better signal
Once records were clean, I wanted Google's bidding to learn from more than a raw form fill. A daily script pulls new HubSpot contacts that came from an ad click and sends each one back to Google Ads as a conversion, worth more when it's a business email than a Gmail address, so the bidding learns which clicks turn into actual companies.
"""Upload domain-weighted lead conversions to Google Ads.
Reads new HubSpot contacts that carry a Google click ID,
classifies the email domain as free-provider vs business,
and uploads a click conversion to "HubSpot Lead - Domain Weighted".
"""
VALUE_FREE = 10.0 # gmail.com, outlook.com, ...
VALUE_BUSINESS = 300.0 # a company domain, close to an MQLCheck it against what people do in the app
PostHog is where the product events live, so that's how I checked all of this against reality: real signups, guest sessions from the widget on our homepage, and a lot of test signups from our own deploys that had to be filtered out before any of the numbers meant anything.
SELECT toStartOfWeek(timestamp) AS week, count() AS signups
FROM events
WHERE event = 'Account Created'
AND NOT person.properties.email ILIKE '%mailosaur%' -- deploy smoke tests
GROUP BY week
ORDER BY week DESCSignups land in HubSpot with their source and keyword, and Google Ads gets a conversion signal that can tell a business signup from a Gmail one.