Experience · Tracking & Attribution

Fixing broken lead attribution on a WordPress and HubSpot site

Every enquiry was arriving in the CRM as Direct traffic, so a business spending real money on paid search had no idea which leads came from ads. One symptom sat on top of five separate faults. Here is how I traced each one from the outside in and rebuilt the attribution path, then proved it end to end before closing the job.

October 2026 · ~9 min read · WordPress, HubSpot Forms API, GTM, FluentSMTP

The whole problem in one picture: with no tracking cookie, the server-side form reached the CRM with an empty token and every lead defaulted to Direct.

A morning "please fix this asap" came in from a sales team: every enquiry from the website was being recorded in the CRM as Direct traffic, all the campaign data was getting lost, and that made it impossible to judge the ad spend. The headline symptom was simple. The cause was not. It was a custom-coded, server-side form pipeline where five things were quietly broken at once, and two more only surfaced once I started testing. This is how I grounded the diagnosis in evidence instead of guessing, fixed the chain in order, and followed one real submission all the way into the backend to prove it.

The goal

The objective was narrow and measurable: every new lead should carry its true traffic source and campaign data into the CRM, and enquiry notifications should reach the correct inbox rather than a leftover development address. The business was paying for paid search but could not attribute a single enquiry to it, so close to 100% of leads were tagged Direct, which left the marketing team unable to make a budget decision with any confidence. Fixing the attribution was the whole job.

Diagnosis before a single change

I started by inspecting the live site rather than taking the brief at face value. Reading the rendered DOM and the network layer showed the real tracking stack, Google Tag Manager, GA4, Google Ads and Microsoft Ads, was present and firing, but there was no HubSpot script anywhere among the scripts the page loaded. That single fact explained the headline symptom. HubSpot derives a contact's original source from its own first-party cookie (hubspotutk), set by its tracking script and read back on form submission. With no script, that cookie never exists, so every contact reaches the CRM with nothing to tie it to a session and defaults to Direct.

The forms were not a standard plugin. They were custom multi-step quote forms that submitted through the WordPress REST API and fanned the lead out to several destinations server-to-server, so the fix was always going to live in PHP and in the tracking layer, not in a form-builder UI. I pulled the theme's form handlers and read them directly instead of inferring from the front end. That turned guesses into facts, and corrected my own first pass:

What the handlers actually did

The forms did integrate with HubSpot: each handler already built a HubSpot Forms API request and even tried to read hubspotutk from the cookie. The pieces were half-built. They had simply never been able to work, because the cookie was missing and the campaign fields were not in the payload. The internal notification, meanwhile, was wired to a personal developer inbox left in from the build, not the client's enquiries address.

The five faults under one symptom

  1. No CRM tracking code on the site. The HubSpot script was not loading on any page, so the first-party cookie attribution depends on was never created.
  2. Custom server-side forms. The forms posted to the CRM server-to-server through the REST API, so none of the usual client-side form tracking applied.
  3. Fragile UTM capture. The forms read UTM parameters from the live page URL at the moment of submit, with no persistence. The moment a visitor clicked through to a second page, the campaign data was gone.
  4. Campaign data never reached the CRM. Even where UTMs were captured, the Forms API payload did not include them. They were only sent to unrelated downstream systems.
  5. Notifications going to the wrong place. The internal alert was routed to a personal inbox, so the enquiries team was not reliably seeing leads at all.

How I fixed them

I ordered the work so the bleeding stopped first: tracking code and routing, then persistence, then the payload. Each change went onto the live server through the host file manager and GTM, not the downloaded copy, with a backup of each file first.

1. Installed the CRM tracking code site-wide

Adding the HubSpot loader through the theme means the tracking cookie is created on first visit, which is exactly what the existing form code was already looking for. This one change is the single biggest fix.

add_action('wp_head', function () { ?>
<script type="text/javascript" id="hs-script-loader" async defer
        src="//js.hs-scripts.com/<portal-id>.js"></script>
<?php }, 20);

2. Made UTM capture survive navigation

Instead of reading the URL only at submit time, I captured the campaign parameters and click ID on first landing, stored them in a first-party cookie, and exposed a small helper the forms read from. Campaign data now persists across a multi-page visit instead of evaporating on the first click.

add_action('wp_head', function () { ?>
<script>
(function(){
  var KEYS=['utm_source','utm_medium','utm_campaign','utm_term','utm_content','gclid'];
  var q=new URLSearchParams(location.search), found={}, has=false;
  KEYS.forEach(function(k){ if(q.get(k)){ found[k]=q.get(k); has=true; } });
  if(has) document.cookie='attrib='+encodeURIComponent(JSON.stringify(found))
    +';path=/;max-age='+(60*60*24*90)+';SameSite=Lax';
  window.getAttrib=function(){
    if(/utm_|gclid/.test(location.search)) return location.search;
    var m=document.cookie.match(/(?:^|; )attrib=([^;]+)/);
    try{ return m ? '?'+new URLSearchParams(JSON.parse(decodeURIComponent(m[1]))).toString() : ''; }
    catch(e){ return ''; }
  };
})();
</script>
<?php }, 5);

3. Passed the campaign data into the CRM

I added the UTM and click-ID fields to the HubSpot Forms API payload in each handler, backed by matching contact properties in the CRM, so campaign data lands on the contact record as a durable backstop alongside the cookie-based source.

array('name' => 'utm_source',   'value' => isset($utm['utm_source'])   ? $utm['utm_source']   : ''),
array('name' => 'utm_medium',   'value' => isset($utm['utm_medium'])   ? $utm['utm_medium']   : ''),
array('name' => 'utm_campaign', 'value' => isset($utm['utm_campaign']) ? $utm['utm_campaign'] : ''),
array('name' => 'utm_term',     'value' => isset($utm['utm_term'])     ? $utm['utm_term']     : ''),
array('name' => 'utm_content',  'value' => isset($utm['utm_content'])  ? $utm['utm_content']  : ''),
array('name' => 'gclid',        'value' => isset($utm['gclid'])        ? $utm['gclid']        : ''),

4. Fixed notification routing and cleaned the email

I repointed the internal enquiry notification to the correct inbox and replaced a raw debug dump with a clean, readable lead summary, so the people acting on enquiries get a usable email rather than a page of diagnostics.

$lead_body  = "New enquiry from the website\n\n";
$lead_body .= "Name:       $name\n";
$lead_body .= "Email:      $email\n";
$lead_body .= "Phone:      $phone\n";
$lead_body .= "Collection: {$o['place_name']}\n";
$lead_body .= "Delivery:   {$d['place_name']}\n";
$lead_body .= "Date:       $the_date\n";
$lead_body .= "Load type:  $ltt\n";
$lead_body .= "Notes:      $notes\n";
wp_mail('<enquiries-inbox>', 'New Website Enquiry: ' . $name, $lead_body, $headers);

Beating the false negative

My first verification pass ran in a browser with heavy tracker blocking, a local security suite plus extensions. The analytics requests were being blocked locally, so the cookie did not set and the Google tags were returning 503, and a perfectly valid lead would have looked like a failure. Rather than trust that reading, I re-ran the submission in a clean browser with no blockers, which is how a real ad visitor behaves. There the cookie set correctly and the HubSpot analytics object initialised, which is the reading that actually matters. The lesson I keep from this is blunt: never validate tracking in your own hardened browser, because it lies to you in the safe direction.

Surfacing the hidden failures

With notifications now readable, the API responses that had been buried in debug output became visible, and they showed two downstream quote APIs returning errors on every submission (one 404, one 500). Those failures were pre-existing and unrelated to the attribution fix, but they were quietly dropping data. I also found the forms were posting to a development endpoint in production, and that a DNS check proved the supposed production host did not resolve at all, so changing the URL would have broken a working integration rather than fixing it. I flagged each with its exact status code and the business context needed to decide whether to repair or remove it, rather than leaving them to fail silently.

How I verified it

One controlled test both proved the fix and found the next problem.

I ran a controlled test enquiry through a clean browser with a fully tagged URL, used a number from the range reserved for fiction so it would pass validation but reach no real person, and then followed the submission into the backend through the FluentSMTP mail log. The trace confirmed each link in the chain: the submission returned 200, the tracking cookie set, the UTMs and click ID were captured and carried through, the CRM accepted the lead with a 200, the internal notification reached the correct inbox, and the customer confirmation sent. The same trace is what exposed the two failing vendor APIs, so one test did double duty.

What it changed

Paid-search leads now arrive in the CRM carrying their real source and campaign instead of defaulting to Direct, which is exactly the capability the marketing team needed to judge their ad spend. Enquiry notifications reach the right inbox in a clean, usable format. After the changes went live, the client team independently confirmed that lead source was reporting correctly on their side, which closed out the original complaint. The two unrelated downstream integrations were handed back as a scoped, clearly explained follow-up rather than an open mystery.

What this did not solve, and what I would carry forward

An honest limit: only the homepage form was live-tested end to end. The other handlers share the same structure and the same edits, but I did not fire a real submission through each, so that remains a verification gap I named rather than papered over. The two failing vendor APIs were genuinely out of scope for an attribution fix and still need the client's side to decide repair or removal. A note I kept for myself: a development endpoint and a debug inbox left live in production are the kind of thing that survive for months precisely because nobody reads the debug output they hide in.

Stacks involved

WordPressWordPress REST APIPHPHubSpot Forms APIGoogle Tag ManagerGA4Google AdsMicrosoft AdsFluentSMTPfirst-party cookie attribution
← more of my work