UTM parameters only exist in the URL of the page a visitor lands on, so the moment they click to a second page, the source is gone and your form captures nothing. The fix has three parts: save the values in a first-party cookie on arrival, copy them into hidden form fields at conversion, and map those fields to CRM properties that carry through to the deal. Then pipeline and revenue can be reported by source instead of guessed.
Why UTMs get lost between landing and conversion
A typical path looks like this: a LinkedIn ad sends someone to a landing page tagged utm_source=linkedin&utm_medium=paid-social. They read it, click to pricing, then to the demo page, and fill in the form. The form sits on a URL with no query string, so it has nothing to read.
Other ways the data disappears:
- Return visits. The buyer clicks the ad, leaves, and comes back directly a week later to convert.
- Redirects. HTTP-to-HTTPS rules, trailing-slash rules, vanity URLs and link shorteners can drop the query string before the page loads.
- Analytics isn’t the CRM. GA4 knows the session came from LinkedIn, but it can’t tell you which named lead that was or whether their deal closed.
- Built-in CRM tracking is coarse. HubSpot’s original and latest source properties, for example, are worth keeping, but they sort visits into HubSpot’s own categories and drill-downs, not all five of your UTM values in fields you control.
First-touch vs last-touch source fields
Store two sets of fields, because each answers a different question.
| First touch | Last touch | |
|---|---|---|
| Written when | The first visit you record | Every new tagged or external visit before conversion |
| Overwrite rule | Never | Yes, but never by a direct visit |
| Answers | Which channels create new demand? | Which channels trigger the conversion? |
| Tends to overcredit | Organic content and prospecting ads | Brand search, email and retargeting |
For each set, capture:
- Source, medium, campaign, content and term
- Landing page path
- Referrer domain, so untagged organic and referral visits still get a source
- Timestamp of the visit
Store raw values and derive the channel later in the CRM. If you store “Paid Social” instead of linkedin / paid-social, you can’t re-sort history when your channel definitions change.
The “never overwrite with direct” rule matters. Someone who clicks a Google ad and later types your URL to convert should keep Google as last touch; otherwise direct traffic swallows most of your conversions.
Persisting UTMs with first-party cookies or local storage
The logic runs on every page load:
- Read UTM parameters and click IDs from the URL.
- If no first-touch cookie exists, write one with whatever you found: UTMs, referrer, or “(direct)”.
- If the URL is tagged or the referrer is another domain, overwrite the last-touch cookie.
- Otherwise, change nothing.
A minimal version, loaded site-wide (replace example.com with your domain):
(function () {
var KEYS = ['utm_source','utm_medium','utm_campaign','utm_term','utm_content',
'gclid','gbraid','wbraid','fbclid','msclkid'];
var p = new URLSearchParams(location.search), t = {};
KEYS.forEach(function (k) { var v = p.get(k); if (v) t[k] = v.slice(0, 200); });
var host = document.referrer ? new URL(document.referrer).hostname : '';
var external = host && !/(^|\.)example\.com$/.test(host);
var tagged = Object.keys(t).length > 0;
t.landing_page = location.pathname;
t.referrer = host || '(direct)';
t.ts = new Date().toISOString();
var val = encodeURIComponent(JSON.stringify(t));
var opts = '; path=/; domain=.example.com; max-age=7776000; SameSite=Lax; Secure';
function has(n) { return new RegExp('(^|;\\s*)' + n + '=').test(document.cookie); }
if (!has('ft_touch')) document.cookie = 'ft_touch=' + val + opts;
if (tagged || external || !has('lt_touch')) document.cookie = 'lt_touch=' + val + opts;
})();
Details that decide whether this holds up:
- Cookie domain. Set it on the root domain (
.example.com) so the marketing site and an app or docs subdomain share it. Separate root domains don’t share cookies at all; pass the values through link parameters instead. - Lifetime. The example uses 90 days. Safari caps cookies written by JavaScript to a much shorter lifetime, so if your cycle is long, set the cookie in an HTTP response from your own web server or edge layer. Safari can also cap server-set cookies from endpoints hosted apart from your site, so test it.
- localStorage is simpler, but it belongs to one exact origin, so
wwwandappcan’t see each other’s values. It’s fine for a single-domain site. - Sanitize. Cap value length and never render stored values back into the page as HTML.
- Consent. Where consent is required, load this script under the same consent category as your analytics.
Hidden fields in HubSpot, Webflow and other form tools
Every form needs hidden inputs for the fields above, plus a small script that fills them from the cookies when the form loads. Most tools pre-fill hidden fields from the query string only on the form’s own page, which is exactly the case that fails.
| Form tool | How to add hidden fields | Watch out for |
|---|---|---|
| HubSpot forms | Create the contact properties, then add them to the form as hidden fields; fill them from your script when the form is ready | Fields can only use properties that already exist, and picking a similarly named property writes to the wrong place without any error |
| Webflow | No dedicated hidden-field element; add hidden inputs through a code embed inside the form block | Submissions stay in Webflow until an integration, webhook or automation pushes them to the CRM, and every field must be mapped there |
| Gravity Forms and similar WordPress plugins | Hidden field type with dynamic population by parameter name | Dynamic population reads the current URL, so fill from the cookie instead |
| Typeform, Calendly and other embeds | Declare hidden fields or pass parameters in the embed URL | An iframe can’t read your site’s cookies; your page must pass the values in |
On the CRM side, last-touch fields can overwrite on every submission. Most form tools also overwrite existing values, so send first-touch values to staging properties and use a workflow to copy them into the permanent first-touch properties only when those are empty.
On Shopify, the order is the conversion: write the cookie values into cart attributes before checkout so they land on the order.
Capturing click IDs such as gclid and fbclid for offline conversions
UTMs tell you the campaign. Click IDs tell the ad platform which exact click became a customer, so you can send CRM outcomes back for bidding.
- gclid is added by Google Ads auto-tagging. You may also see
gbraidorwbraidon some iOS traffic, so capture those too. Google Ads only accepts offline conversions for clicks within the last 90 days, and it can also match on hashed email through enhanced conversions for leads when the gclid is missing. - fbclid is appended to links clicked from Facebook and Instagram. Meta’s Conversions API expects a formatted value (the
fbcparameter) built from the click ID and click time, which the Meta Pixel also keeps in its_fbccookie. Store the timestamp alongside the raw ID. - msclkid comes from Microsoft Advertising auto-tagging. LinkedIn and TikTok use their own identifiers; check each platform’s conversions API documentation for the parameter name and format.
Store the most recent click ID per platform on the contact and copy it to the deal. The upload side, matching stages to conversion actions and sending values, is covered in the offline conversion tracking guide.
Reporting pipeline and revenue by source
Contacts carry the source, but revenue lives on deals. Copy the source fields from the primary contact to the deal when the deal is created, not when it closes, so later touches don’t rewrite history. In HubSpot, a workflow can copy the contact’s source properties onto the associated deal when it’s created. In Salesforce, map the custom lead fields to opportunity fields in lead conversion settings.
Then add a derived channel property with explicit rules, such as google plus cpc equals Paid Search. The rules only work if tagging is consistent, which is where a shared UTM naming convention pays off.
Build one report in both views:
| Channel | Deals created | Pipeline value | Closed-won revenue | Win rate | Median days to close |
|---|---|---|---|---|---|
| Paid Search | |||||
| Paid Social | |||||
| Organic Search | |||||
| Referral / Partner | |||||
| Direct / Unknown |
Run it once by first-touch channel and once by last-touch channel. A channel that ranks high on first touch and low on last touch is creating demand that other channels close; cutting it on last-touch numbers alone is how teams starve their pipeline a quarter or two later. Keep “Direct / Unknown” visible: if it’s a large or growing share of pipeline, check capture before anything else.
This is the plumbing I set up first in marketing attribution and tracking work, because no attribution model can fix source data that was never captured.
Testing and common failure points
Test every path before trusting a report:
- Land on a tagged URL, browse three pages, submit the form: all fields populated in the CRM
- Land tagged, leave, return by typing the URL, convert: last touch still shows the tagged visit
- Land tagged on
www, convert on a subdomain: values carried over - Hit each redirect and vanity URL: query string survives
- Submit twice with different UTMs: first-touch fields unchanged
- Create a deal: source fields copied from the contact
- Decline consent: form still submits cleanly with empty source fields
The failures I find most often in audits:
- Hidden fields on some forms but not others, usually the newest landing pages
- Fields present on the form but not mapped to any CRM property
- Imports, list uploads or enrichment tools overwriting source fields
- Contact merges keeping the wrong record’s source
- Single-page apps that change the URL without a page load, so the script never re-runs
- Mixed case and spelling (
LinkedIn,linkedin,li) splitting one channel into three rows
Get it built
If your CRM can’t tell you which channels produce closed revenue, I can review your source capture in a Growth Audit ($1,500 fixed, credited if you continue) and then build the fix with your team. See pricing or get in touch.