splitforms.com
← Back to the journal

Track Form Submission Sources with UTM Fields (2026)

Capture UTM parameters and referrer in hidden form fields with a tiny script, so every lead shows its campaign source in your dashboard, CRM, or Slack.

Start free — 500 submissions/moSee pricing →No credit card. Paid plans from $5/mo.

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 pattern: hidden UTM fields

Hidden inputs post to https://splitforms.com/api/submit exactly like visible ones, and their values land in the dashboard and every webhook payload right alongside name, email, and message. Add seven of them to your lead capture form — five UTM parameters, plus a referrer and a landing page — and every submission carries its own source data.

<form action="https://splitforms.com/api/submit" method="POST">
  <input type="hidden" name="access_key" value="YOUR_ACCESS_KEY" />

  <input name="name" type="text" required />
  <input name="email" type="email" required />
  <textarea name="message" required></textarea>

  <!-- Filled by the script below — leave value empty -->
  <input type="hidden" name="utm_source" />
  <input type="hidden" name="utm_medium" />
  <input type="hidden" name="utm_campaign" />
  <input type="hidden" name="utm_term" />
  <input type="hidden" name="utm_content" />
  <input type="hidden" name="referrer" />
  <input type="hidden" name="landing_page" />

  <button type="submit">Send</button>
</form>

No tag manager or data layer required. The nameattribute on each hidden input is what the script matches against — and it's the exact column name you'll see once submissions start arriving.

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.referrer on 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 blank referrer as 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_source text 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 as type="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 /contact page 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.

Related articles

More practical guidance from guides.

Browse the journal →
Guides

Elementor Form Not Sending Email? 5 Fixes That Work (2026)

Why Elementor form notifications never arrive: the wp_mail root cause, how to confirm it in

8 min readRead →
Guides

Why mailto: Doesn't Work as a Form Action (and What Converts 3–10× Better)

Using action="mailto:you@example.com" opens the visitor's mail app instead of sending anythi

6 min readRead →
Guides

Why Your Form Works in Preview but Not on the Live Site

Form submits perfectly in your builder's preview but fails in production? The usual causes:

7 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewWhat Is a Form Backend? (Complete Guide)Start here →GuideSelf-Hosted vs SaaS Form BackendRead →GuideForm Backend vs Form BuilderRead →GuideWhere Do Form Submissions Go?Read →ReferenceForm backend guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 500 submissions a month for free.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe