The problem: leads with no source
A contact form submission arrives with a name, an email, and a message. What it doesn't carry, by default, is any sign of where that person came from — the LinkedIn ad you're paying for, last week's newsletter, a Google search, or someone who bookmarked the page months ago. Without that answer, every lead looks the same in the dashboard: a name and a message, no context.
Analytics doesn't close this gap on its own. Google Analytics or Plausible can report that a landing page got a certain number of views from a certain campaign, and separately that the form fired a certain number of submissions — two aggregate counts on two different reports, not one row that says "this specific person, from this specific campaign, submitted at this specific time."
Sales and marketing both want that missing piece: source data attached to the lead itself, not the page. Marketing wants to know which campaign actually produces submissions, not just clicks. Sales wants context before the first call — a lead from a pricing-page ad is a different conversation than one from a top-of-funnel blog post. Getting there doesn't take a tag manager or a CDP — a handful of hidden fields and one short script cover it.
The copy-paste script
One script does two jobs: capture the UTM parameters and referrer once on the visitor's first page, and fill the matching hidden inputs on whichever page the form lives on. Load it sitewide — a shared layout or footer include — since the campaign parameters usually land on a different page than the contact form itself.
<script>
(function () {
var KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "referrer", "landing_page"];
var STORE_KEY = "sf_source";
// First-touch capture: write once per session, first page wins.
if (!sessionStorage.getItem(STORE_KEY)) {
var q = new URLSearchParams(location.search);
var source = {
utm_source: q.get("utm_source") || "",
utm_medium: q.get("utm_medium") || "",
utm_campaign: q.get("utm_campaign") || "",
utm_term: q.get("utm_term") || "",
utm_content: q.get("utm_content") || "",
referrer: document.referrer || "",
landing_page: location.pathname,
};
sessionStorage.setItem(STORE_KEY, JSON.stringify(source));
}
document.addEventListener("DOMContentLoaded", function () {
var source = JSON.parse(sessionStorage.getItem(STORE_KEY) || "{}");
KEYS.forEach(function (key) {
if (!source[key]) return;
document.querySelectorAll('input[name="' + key + '"]').forEach(function (el) {
el.value = source[key];
});
});
});
})();
</script>The if (!sessionStorage.getItem(STORE_KEY)) guard is the entire trick. Without it, the script overwrites the saved source on every page view, so a visitor who arrived from a Google ad and browsed three more pages before finding the contact form would submit with last-touch data — whatever internal page they were on right before hitting submit. With the guard, the first page of the session writes the value once and every later page reads it back untouched: first-touch attribution, the campaign that actually brought the visitor in.
Both models are legitimate. First-touch tells you which channel generates interest; last-touch tells you what finally convinced someone to convert. For a source field next to a contact form, first-touch is the more useful default — it answers "which campaign should get credit," not "what page were they on thirty seconds ago." Want last-touch instead? Delete the if guard so the object rebuilds on every page.
Where the source data shows up
Once the hidden fields are populated, there's nothing else to configure. utm_source, utm_campaign, referrer, and the rest ride along with the submission like any other field — columns in the splitforms dashboard next to every lead, in the same payload your other integrations already read. That's lead source tracking handled at the form level, no separate platform required.
Forward submissions over webhooks (available on paid plans, from $5/month) and the campaign source lands in the same JSON your CRM already parses — no lookup table to join later. Post to Slack and the notification your team reads shows the source next to the name and message. Send it to HubSpot and it fills a custom property on the contact record, so a rep sees the campaign before the first call instead of asking "where did you hear about us" on a discovery call. See pricing for the full plan breakdown — the fields themselves capture identically on every plan, including Free.
Honest caveats
This setup is useful, not magic. A few things worth knowing before you rely on it:
- The referrer field is often empty. Browsers strip
document.referreron an HTTPS-to-HTTP navigation, most privacy-focused browsers and extensions block it outright, and any visitor arriving via direct traffic — a typed URL, a bookmark, most native mobile apps — never had a referrer to begin with. Treat a blankreferreras expected, not broken. - UTM parameters only exist if you put them there. The script can't invent a campaign source; it can only read what's in the URL. That takes UTM discipline on your side — every ad, email link, and social post needs
?utm_source=...appended, or the fields stay blank for that traffic. - This is per-lead attribution, not multi-touch analytics. It answers "where did this specific lead come from" with one first-touch snapshot. It doesn't model a visitor who saw an ad, read three blog posts over two weeks, and finally converted from an email link — that's a job for a dedicated analytics or CDP tool, not seven hidden form fields.
On privacy: UTM parameters and referrer are low-risk next to third-party ad cookies — first-party values read from the URL and the browser's own referrer, stored in sessionStorageon your own domain, never shared with an ad network. That doesn't make disclosure optional — add a line to your privacy policy noting that campaign source and referring page are captured alongside the submission.
This is general guidance, not legal advice. Confirm UTM and referrer collection is covered in your own privacy policy, and consult counsel for GDPR, CCPA, or other regional specifics that apply to your business.
Common mistakes
- Visible inputs instead of hidden ones. A visible
utm_sourcetext box invites a visitor to edit it and adds clutter to a form that should stay short — see contact form conversion rate benchmarks for why every extra visible field costs submissions. Keep all seven astype="hidden". - Only adding the script to the page with the form. Campaign parameters usually land on a landing page, blog post, or homepage — not the
/contactpage itself. If the script only runs where the form lives, it never sees the original URL. Load it sitewide, in a shared layout or footer include. - Using localStorage instead of sessionStorage. localStorage persists indefinitely, so a visitor's campaign source from months ago would silently attach itself to an unrelated submission today. sessionStorage clears when the tab closes, which keeps attribution scoped to the visit it actually describes.
FAQ
What's the difference between this and UTM tracking in Google Analytics?
Google Analytics reports campaign performance in aggregate — sessions, pageviews, and form views tied to a utm_source, but as separate report totals. It doesn't attach a source to one specific lead. Hidden UTM fields do the opposite: no dashboard-level charting, but every individual submission carries its own campaign data, which is what a sales rep or CRM record actually needs.
Why sessionStorage instead of localStorage?
sessionStorage clears when the browser tab closes, so captured source data stays scoped to the visit that produced it. localStorage persists indefinitely, which would let a campaign parameter from months ago silently attach itself to a submission from an unrelated later visit — the opposite of accurate attribution.
Does this work if the form is on a different page than the campaign landing page?
Yes — that's the reason for sessionStorage in the first place. The script captures UTM parameters and referrer on whichever page the visitor lands on first, then reads that saved value back on any later page in the same session, including a /contact page the visitor navigates to afterward.
What happens if a visitor has JavaScript disabled?
The hidden fields simply submit empty. The form itself still works normally — name, email, and message still post to your endpoint — you just don't get source data for that specific submission. Nothing breaks; the fallback is silent.
Should I use first-touch or last-touch attribution for a contact form?
First-touch is the better default for a single source field next to a lead — it credits the campaign that actually brought the visitor in, not whichever internal page they happened to be browsing right before they submitted. The script below defaults to first-touch with a sessionStorage guard; remove that guard if you specifically want last-touch instead.
Is capturing UTM parameters and referrer GDPR-compliant?
It's low-risk compared to third-party ad cookies — the values come from the URL and the browser's own referrer, stored in sessionStorage on your own domain, never shared with an ad network. Disclosure is still the right move: add a line to your privacy policy noting that campaign source and referring page are captured alongside each submission.
Want every lead in your dashboard already tagged with the campaign that produced it? Get a free splitforms access key — hidden fields capture the same way on every plan, including Free, and submission-notification emails are free on every plan too.