The short answer
On the first visit, read the UTM parameters and the referrer, write them to local storage with a timestamp, and don't overwrite them for 90 days. On every form submit, copy that stored object into hidden fields. On every outbound booking link, append it to the URL before the click. Map the fields to your CRM once and leave it alone.
Why first touch, and why 90 days
Last touch is the default everywhere and it's the wrong default for B2B. In a six to nine month cycle the last touch is almost always branded search or a direct visit, because by then they know who you are. Crediting that is like crediting the taxi that took you to the airport with the holiday.
First touch has the opposite flaw, so don't throw the rest away. Store the first touch as the thing that found them, keep the latest touch alongside it, and use both. First touch tells you where demand comes from. Last touch tells you what closes it. Retargeting will look terrible on the first and brilliant on the second, and neither read is the truth on its own.
"Last touch credits the taxi to the airport with the holiday."
Ninety days is the window we default to because it covers the realistic gap between an ad click and a first conversation in most B2B markets, without keeping identifiers around longer than you can justify. Longer cycles can push it further, but be able to explain the choice.
Capture and store on the first visit
Run this as early as you can on the first page load. If the URL has UTMs, use them. If it doesn't, infer something sensible from the referrer so you're not filing genuine organic search under Direct.
// Runs once, on the first page of the first visit.
const KEY = 'eg_attr';
const NINETY_DAYS = 90 * 24 * 60 * 60 * 1000;
function captureFirstTouch() {
const existing = JSON.parse(localStorage.getItem(KEY) || 'null');
if (existing && Date.now() - existing.ts < NINETY_DAYS) return existing;
const p = new URLSearchParams(window.location.search);
const attr = {
source: p.get('utm_source') || inferSource(document.referrer),
medium: p.get('utm_medium') || '',
campaign: p.get('utm_campaign') || '',
content: p.get('utm_content') || '',
landing: window.location.pathname,
ts: Date.now(),
};
localStorage.setItem(KEY, JSON.stringify(attr));
return attr;
}- Store the landing page as well as the source. It tells you which content did the finding, which is the bit that actually informs next month's plan.
- Keep a separate latest-touch object that you do overwrite on every visit. Two fields, two questions answered.
- Local storage survives tab closes and browser restarts, which cookies with short lifetimes often don't. It won't survive a different device or a cleared browser, and nothing will.
Keep it legal and consent-aware
You're storing campaign parameters and a timestamp, not a name or an email. That's a low-risk payload, but it still sits behind your consent setup and your privacy policy needs to say it exists.
- Gate the write behind analytics consent where your policy requires it, and accept that you'll lose the source for people who decline. That loss is real and it is heaviest in DACH.
- Never write personal data into the attribution object. No emails, no names, nothing from a form field.
- Add a blocklist so query strings containing anything that looks like an email address or a phone number never make it into storage or into your analytics.
- Expire the record. Ninety days, then it clears itself. Indefinite storage is harder to defend and adds nothing useful.
Want this built and wired into your CRM properly?
Book a 30 minute call. Tell us what stack you are on and we will be straight about what it takes.
Write it into every form
Storage does nothing on its own. The value only appears when it lands on a record a human can read months later.
- First touch source, medium, campaign. Hidden, populated from storage on submit.
- First landing page and first touch date. The date is what lets you see how long the gap really is between ad and conversation.
- Latest touch source. Separate fields, overwritten each visit.
- How did you hear about us. Visible, optional, free text or a short list. Unfashionable and the only thing that survives everything else failing.
Every form. Contact, download, demo request, newsletter. Miss one and it becomes the one that generates your best lead of the quarter with an empty source field.
Carry it across to the booking tool
The handoff to Calendly, HubSpot Meetings or whatever you use is where most setups quietly fail. New domain, new session, source gone. Fix it by putting the attribution into the URL before the visitor leaves.
// Add the stored attribution to an outbound booking link
function appendAttribution(url) {
const a = JSON.parse(localStorage.getItem('eg_attr') || 'null');
if (!a) return url;
const u = new URL(url);
u.searchParams.set('utm_source', a.source);
u.searchParams.set('utm_medium', a.medium);
u.searchParams.set('utm_campaign', a.campaign);
return u.toString();
}- Wrap every booking and external form link in that function. One helper, used everywhere, rather than hardcoded URLs scattered across components.
- Add the booking domain to GA4 cross-domain measurement and to the unwanted referrals list.
- Map the incoming UTM parameters to fields inside the booking tool so they reach the CRM record rather than sitting in the scheduler.
- Fire a CTA click event before the redirect. You then have a funnel step between page view and booking, which is where the drop-off you cannot currently see is hiding.
What the CRM side needs
Create the custom fields once and protect them. The single most common way this breaks after launch is a well-meaning workflow that overwrites first touch when a contact re-converts.
- First touch fields set on create only, never on update. Lock them if your CRM allows it.
- Latest touch fields updated freely.
- A report that groups closed-won deals by first-touch source, with the first-touch date next to the close date so the true cycle length is visible.
- An "unknown source" count on the dashboard. When that number climbs, something in the chain has broken and you want to know that week, not at the quarterly review.
Test it like a buyer would
Not like a developer. Buyers arrive on phones and come back on laptops.
Confirm the parameters get stored, not just present in the URL.
Type the domain in directly, then submit a form. It should still report the ad.
Check the source landed on the CRM record, not just in the booking tool.
Confirm you fail cleanly rather than storing something you shouldn't.
Set a short expiry temporarily and confirm the record actually clears.
Once it holds up under all five, you have a source on most of your records and a defensible answer to where demand comes from. What it still won't do is capture the buyer who saw four ads and never clicked one, and that gap is what company-level influence measurement is for.
Read next
Common questions
If you want the whole attribution chain built and tested
- First-touch and latest-touch capture built, consent-gated and expiring on a defined window.
- Hidden fields on every form, mapped to protected CRM fields that workflows cannot overwrite.
- Booking and external form handoffs fixed so the source survives the redirect.
- CTA click and form start events so you can see drop-off between intent and submission.
- A closed-won by first-touch-source report, with true cycle length visible next to it.
Starter £975/mo, Growth £1,725/mo, Scale £4,025+/mo. Usually two to three weeks of work inside the retainer. Standalone tracking rebuilds are quoted on the stack involved.

