Salesforce UTM Capture and Lead Source Attribution
Last updated · published
Salesforce captures no UTM parameters for you. The Lead object ships with one LeadSource picklist, and that is the entire out-of-the-box attribution story.
So you build the rest: hidden form fields, a persistence layer that survives the journey from landing page to form, custom fields on Lead and Contact, and a mapping that keeps first-touch values alive through conversion.
The mechanics are straightforward. The discipline is where teams lose it, usually at the convert step.
TL;DR
- UTMs enter Salesforce through Web-to-Lead, marketing automation forms, or a custom Apex endpoint. All four paths need the same persistence layer underneath.
- Store first-touch and last-touch in separate field sets. First-touch is written once; last-touch is overwritten.
LeadSourceis a standard field, and Salesforce only lets you restrict custom picklists. Use a validation rule, or put the controlled vocabulary in a custom channel field.- Lead conversion drops anything you did not explicitly map from Lead to Contact.
- Governing the values upstream is what keeps your Lead Source automation to one branch per channel rather than one per spelling.
Where UTMs Enter
Web-to-Lead posts an HTML form to Salesforce’s endpoint. You add hidden inputs for each field and populate them with JavaScript before submission.
Marketing automation forms (Account Engagement, Marketo, HubSpot) capture in their own layer and sync mapped fields across. Each platform stores source data differently, so platform defaults alone will not give you consistent first and last touch.
A custom Apex endpoint is where teams land once they need server-side validation, deduplication or progressive profiling. You post JSON, your code decides what happens.
Every path has the same shape underneath: read the parameters, persist them, write them into the form, map them to fields.
First Touch and Last Touch
Someone arrives from a paid search ad, comes back direct a week later, then clicks a LinkedIn post and books a demo.
First touch credits the ad that introduced them. Last touch credits the post that closed it. Both are partial, which is why you store both:
FT_UTM_Source__c,FT_UTM_Medium__c,FT_UTM_Campaign__c,FT_UTM_Content__c,FT_UTM_Term__c,FT_Landing_Page__c,FT_Timestamp__c- The same seven with an
LT_prefix
First-touch fields are written once and never again. Last-touch fields are overwritten whenever a new tagged session arrives. Enforce that in both places: the browser layer must not overwrite the first-touch cookie, and no automation may blow the fields away on conversion.
For anything richer than two points, write each session as a touchpoint on a related object or push the stream to a warehouse.
Persistence
Parameters exist in the URL for exactly one page view. By the time someone submits a form three pages later, the query string is long gone.
One small script, loaded everywhere, owns this:
(function () {
var UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
var FT_DAYS = 180;
var LT_DAYS = 30;
function getParam(name) {
var match = window.location.search.match(new RegExp('[?&]' + name + '=([^&]+)'));
return match ? decodeURIComponent(match[1].replace(/\+/g, ' ')) : null;
}
function setCookie(name, value, days) {
var d = new Date();
d.setTime(d.getTime() + days * 864e5);
document.cookie = name + '=' + encodeURIComponent(value) +
';path=/;expires=' + d.toUTCString() + ';SameSite=Lax';
}
function getCookie(name) {
var m = document.cookie.match(new RegExp('(^|;)\\s*' + name + '=([^;]+)'));
return m ? decodeURIComponent(m[2]) : null;
}
if (UTM_KEYS.some(function (k) { return getParam(k) !== null; })) {
UTM_KEYS.forEach(function (key) {
var value = getParam(key);
if (!value) return;
if (!getCookie('ft_' + key)) setCookie('ft_' + key, value, FT_DAYS);
setCookie('lt_' + key, value, LT_DAYS);
});
if (!getCookie('ft_landing_page')) {
setCookie('ft_landing_page', window.location.pathname, FT_DAYS);
setCookie('ft_timestamp', new Date().toISOString(), FT_DAYS);
}
setCookie('lt_landing_page', window.location.pathname, LT_DAYS);
setCookie('lt_timestamp', new Date().toISOString(), LT_DAYS);
}
window.Attribution = {
populate: function (form) {
UTM_KEYS.forEach(function (key) {
var ft = form.querySelector('[name="ft_' + key + '"]');
var lt = form.querySelector('[name="lt_' + key + '"]');
if (ft) ft.value = getCookie('ft_' + key) || '';
if (lt) lt.value = getCookie('lt_' + key) || '';
});
}
};
})();
Four details decide whether this survives contact with reality.
SameSite=Lax is right for first-party use. An iframed form on another domain needs SameSite=None; Secure.
The first-touch lifetime is a judgement call. A year is common and hard to defend in the EU and UK, where regulators expect cookie lifetimes no longer than necessary. Ninety to 180 days is the usual compromise, and the code above uses 180.
Storing only the path keeps session IDs and tokens out of your CRM.
If your site and your forms live on different subdomains, add ;domain=.example.com to both calls, because host-only cookies are not readable across them.
Every form calls Attribution.populate(form) before submitting. One file, one place to change.
A Custom Endpoint
When Web-to-Lead stops being enough, an Apex REST resource gives you validation, dedup and control.
@RestResource(urlMapping='/lead-capture/*')
global with sharing class LeadCaptureController {
global class LeadPayload {
public String email;
public String firstName;
public String lastName;
public String company;
public String ftUtmSource;
public String ftUtmMedium;
public String ftUtmCampaign;
public String ltUtmSource;
public String ltUtmMedium;
public String ltUtmCampaign;
}
@HttpPost
global static Map<String, Object> captureLead(LeadPayload payload) {
if (String.isBlank(payload.email)) {
RestContext.response.statusCode = 400;
return new Map<String, Object>{ 'error' => 'email is required' };
}
try {
List<Contact> contacts = [
SELECT Id FROM Contact WHERE Email = :payload.email LIMIT 1
];
if (!contacts.isEmpty()) {
Contact c = contacts[0];
c.LT_UTM_Source__c = payload.ltUtmSource;
c.LT_UTM_Medium__c = payload.ltUtmMedium;
c.LT_UTM_Campaign__c = payload.ltUtmCampaign;
c.LT_Timestamp__c = System.now();
update c;
return new Map<String, Object>{ 'status' => 'contact_updated', 'id' => c.Id };
}
List<Lead> leads = [
SELECT Id FROM Lead
WHERE Email = :payload.email AND IsConverted = false LIMIT 1
];
if (!leads.isEmpty()) {
Lead l = leads[0];
l.LT_UTM_Source__c = payload.ltUtmSource;
l.LT_UTM_Medium__c = payload.ltUtmMedium;
l.LT_UTM_Campaign__c = payload.ltUtmCampaign;
l.LT_Timestamp__c = System.now();
update l;
return new Map<String, Object>{ 'status' => 'lead_updated', 'id' => l.Id };
}
Lead newLead = new Lead(
Email = payload.email,
FirstName = payload.firstName,
LastName = String.isBlank(payload.lastName) ? 'Unknown' : payload.lastName,
Company = String.isBlank(payload.company) ? 'Unknown' : payload.company,
Channel__c = deriveChannel(payload.ftUtmMedium),
FT_UTM_Source__c = payload.ftUtmSource,
FT_UTM_Medium__c = payload.ftUtmMedium,
FT_UTM_Campaign__c = payload.ftUtmCampaign,
FT_Timestamp__c = System.now(),
LT_UTM_Source__c = payload.ltUtmSource,
LT_UTM_Medium__c = payload.ltUtmMedium,
LT_UTM_Campaign__c = payload.ltUtmCampaign,
LT_Timestamp__c = System.now()
);
insert newLead;
return new Map<String, Object>{ 'status' => 'lead_created', 'id' => newLead.Id };
} catch (DmlException e) {
RestContext.response.statusCode = 500;
return new Map<String, Object>{ 'error' => 'persistence_failed', 'message' => e.getMessage() };
}
}
private static String deriveChannel(String medium) {
if (String.isBlank(medium)) return 'Other';
String m = medium.toLowerCase();
if (m == 'cpc' || m == 'ppc') return 'Paid Search';
if (m == 'organic') return 'Organic Search';
if (m == 'paid-social') return 'Paid Social';
if (m == 'organic-social') return 'Organic Social';
if (m == 'email') return 'Email';
if (m == 'referral') return 'Referral';
return 'Other';
}
}
Create the custom fields before you deploy, or the class will not compile. Dedup prefers Contacts over Leads, so a converted customer filling in another form updates their record rather than starting a parallel funnel.
Returning a map rather than a string lets the framework serialise the response; returning a raw string has been observed to double-encode the body, which breaks most clients. Salesforce’s own guidance for full control is to write to RestResponse directly.
Lead Source Without the Rot
Two things go wrong with LeadSource everywhere.
Drift. Values multiply until the picklist holds eighty entries and seven of them are spellings of “webinar”.
Conflation. One field is asked to hold a channel, a mechanism and a campaign at once, so every report picks whichever meaning its author assumed.
Here is the constraint that matters, and it is the opposite of what most guides say. Salesforce only lets you convert a custom picklist into a restricted one. LeadSource is standard, so you cannot lock it that way.
Two workable options:
Put the controlled vocabulary in a custom field. Channel__c as a restricted picklist with eight to twelve values, set by automation from FT_UTM_Medium__c, and report on that. The Apex above does exactly this.
Or keep LeadSource and guard it with a validation rule that rejects anything outside your agreed list. Weaker than a restricted picklist, because it lives in rules rather than metadata, but it works on the standard field.
Worth knowing while you design this: Salesforce’s own documentation disagrees with itself about what LeadSource ships with. The standard value set lists Web, Phone Inquiry, Partner Referral, Purchased List and Other, while the Lead Source help page lists a longer set including Advertisement, Employee Referral, Trade Show and Word of mouth. Check your own org rather than any article.
Then keep the meanings separate: the channel in Channel__c, the originating campaign in a detail field set from FT_UTM_Campaign__c, and the raw values in the UTM fields.
Keeping Attribution Through Conversion
This is where most teams lose their data.
On conversion, Salesforce copies only the fields you mapped from Lead to Contact. Map every UTM field, first-touch and last-touch, and create them on Contact first, with matching types. Anything unmapped dies with the Lead, and your pipeline reports lose the first touch entirely.
Test it before you trust it. Convert one lead in a sandbox and read the resulting Contact.
Reporting
Marketing-sourced pipeline by first touch. Use the Opportunities with Contact Roles report type, filter to the stages and dates you care about, and group by the contact’s first-touch medium or your channel field. If the joined contact fields are not available as a grouping in your org, fall back to a warehouse query rather than forcing it.
First touch against last touch. The same report twice, grouped differently. Paid search usually shows a higher last-touch share, content a higher first-touch share, and the gap between them is the interesting part.
Leakage. Records created in the last 90 days where the first-touch source is null but the record came in through the web. That is capture failure: the script did not run, cookies expired, or a redirect stripped the parameters. More than roughly one in ten inbound web records means fix capture before trusting any channel number.
Account Engagement Specifics
Pardot became Marketing Cloud Account Engagement in April 2022, and the wider Marketing Cloud family was rebranded again in 2025, so expect three names across the documentation for one product.
Forms. Native forms render in an iframe; form handlers let you own the HTML. For UTM capture, handlers win, because your persistence script can populate the fields directly.
Cookies. The tracking snippet defines piAId and piCId as JavaScript variables, not cookies, which is a common misreading. The cookies it actually sets are visitor_id<accountid> and its hash companion, plus opt-in, last-page-view and session cookies. Track that distinction when debugging, because searching for a piAId cookie finds nothing.
Sync. Custom fields on the Prospect must be mapped to matching fields on Lead and Contact in the connector settings. Skip that and the values stay in Account Engagement forever.
Edge Cases
Cross-domain forms. Cookies set on your site are unreadable inside an iframe on another domain. Pass the values through, or use a form handler and avoid the problem.
Anonymous visitors. Salesforce only ever sees people who submitted something. Reporting channel performance from CRM alone describes converters, not visitors.
Consent. In the EU and UK, load the persistence script after consent. Visitors who decline arrive with empty fields, and that is correct rather than broken. Do not set cookies first and apologise later.
iOS stripping. Apple removes known click identifiers in Mail, Messages and Private Browsing by default, and utm_ parameters survive. Apple publishes no list, so treat community testing as the best available evidence. Details in what link tracking protection strips.
Field lengths. A standard text field holds up to 255 characters, which a long utm_content or referrer can exceed. Truncate client-side or use a long text area, which holds far more and defaults to a smaller limit you can raise.
Spam. Honeypot fields, server-side referrer validation and a captcha on high-value forms. Spam UTMs pollute reports and consume storage.
Govern the Values Upstream
The deriveChannel mapping only works while the mediums arriving are ones it recognises. Free-type ppc one week, cpc the next and paid-search the week after, and you are maintaining a branch per spelling forever.
Terminus, the marketing taxonomy governance platform, constrains those values when the link is built: picklists per parameter, casing applied automatically, campaign names checked against a pattern. Salesforce then receives a vocabulary your automation already understands.
Constrain the input. Everything downstream gets shorter.
FAQ
How do I capture UTMs in Web-to-Lead?
Generate the form from Setup (search Web-to-Lead in Quick Find), include your custom UTM fields, and add a script that reads parameters into cookies on landing and writes them into the hidden inputs at submit.
Can I make Lead Source a restricted picklist?
No. Salesforce restricts custom picklists only, and LeadSource is standard. Use a validation rule on it, or put the controlled vocabulary in a custom channel field and report on that.
Can Salesforce capture first and last touch separately?
Not natively. Create two field sets and enforce the rule in your persistence layer and your automation.
How do I stop UTM data dying on conversion?
Map every UTM field from Lead to Contact under Map Lead Fields, with the fields created on Contact first. Unmapped values are discarded at conversion.
What is the difference between Pardot and Account Engagement?
The same product. Pardot was renamed Marketing Cloud Account Engagement in 2022, and the parent Marketing Cloud brand changed again in 2025. Documentation uses all three names.
Are piAId and piCId cookies?
No. They are JavaScript variables in the tracking snippet. The cookies are the visitor-id family, plus opt-in and session cookies.
How do I report marketing-sourced pipeline?
Opportunities with Contact Roles, filtered to stage and date, grouped by first-touch medium or your channel field, summing amount. Verify the grouping is available in your org before building the dashboard on it.
How do I keep Lead Source automation working?
Govern the UTM vocabulary upstream. The mapping is only as stable as the values arriving, and cleaning up downstream is a quarterly tax you pay forever.
Every account starts with a 21-day trial, no credit card required.