← All work

Case study · Google Ads · GA4 · Attribution

The ads that
never brought a lead.

“No lead ever comes from Ads.” The lead sheet said so, and it was about to decide where the budget went. GA4 disagreed. Both were looking at the same customers.

Client
Innovation Campus, coworking in Málaga
Role
Growth engineer (freelance)
Tools
GA4, Google Ads API, Cloudflare Workers, D1, Claude Code
When
Sep – Oct 2026
0%
of leads with an ad click ID at the start
4
separate breaks in one chain
3
campaigns now bidding on their own conversion

Summary

Problem

0% of leads carried a gclid, the ID Google Ads puts on a paid click, while GA4 reported Paid Search sessions converting.

Solution

Attribution that survives a return visit, ad URLs that tag themselves correctly, server-side events GA4 can attribute, and one conversion goal per campaign.

Ruled out first

Before changing anything I checked every link where the click ID could be lost, one at a time:

  • The site's capture code, the form, the Worker and the sheet script: each read directly, no gaps.
  • The Ads account, queried through the API: auto-tagging on, no tracking template, no URL suffix rewriting anything.
  • The site's own redirects: tested live, the gclid survives a 301.

Nothing was obviously broken. So the problem wasn't one bug, and I stopped looking for one.

Break 1 · The click died with the tab

Attribution was kept in sessionStorage, which disappears when the tab closes. GA4 credits an ad click for up to 90 days. So a visitor who clicked an ad, left, and came back days later to fill in the form arrived “organic”, while GA4 still credited the ad.

I confirmed it on the two leads GA4 had attributed to Paid Search that month: both were recorded in the sheet as organic return visits, landing on pages no campaign points to.

Fix: a first-party cookie with a 90-day life, matching GA4's window, written only with marketing consent. Without consent, behaviour stays exactly as before.

Break 2 · The ads tagged themselves wrong

The English ads had UTM tags written by hand into their final URLs, with three defects:

  • {campaignname} isn't a valid Google placeholder, so it arrived in the sheet literally, as the text “{campaignname}”.
  • {keyword} was URL-encoded, so Google never replaced it either.
  • Several ads pointed at dead WordPress URLs, so every paid click went through two redirects.

Fix: one account-level URL suffix with valid placeholders (campaign ID, keyword), appended by Google after its own click ID, so a paid click now arrives with both. Five ad URLs cleaned and repointed. Read back from the API: every live ad on a bare URL, every destination answering 200 directly. The first paid lead afterwards arrived with the campaign ID and the real search term filled in.

Break 3 · Conversions with no channel

Lead events had already been moved server-side, so ad blockers couldn't drop them. But they reached GA4 without a session ID, so GA4 counted them under “Unassigned”: a conversion credited to nothing, which Google Ads can't learn from.

The first fix parsed the session from gtag's cookie, and passed its test. A weekly check two weeks later showed every server-side event still unassigned. The cause: gtag had moved to a new cookie format (GS2), and my test only covered the old one. I wrote a test with the new format, watched it fail, then fixed the parser.

Break 4 · Every campaign bid on everything

All campaigns optimised toward one catch-all lead event that fired for every form, including front-desk walk-ins signing up through a paper QR code. A walk-in who had once clicked a meeting-room ad could count as a meeting-room lead.

Fix, through the Ads API with a dry run first: per-service conversions imported with the right categories, and one custom goal per campaign: meeting rooms on room and terrace leads, offices on office leads, coworking on the two online hand-offs (plan sign-up and tour booking).

Still open, honestly

The last proof is a paid lead that arrives with its click ID in the sheet. The first candidate couldn't be checked, because that month's sheet tab was missing the column. Safari's Link Tracking Protection, which strips click IDs from URLs, may still cost some. That's why matching on hashed contact details (Enhanced Conversions for Leads) is the next step, not more URL work.

Why not just raise the budget?

Because the account couldn't see which clicks became customers. More budget would have meant more spend optimised toward the wrong signal.

“The ads weren’t failing. The evidence was getting lost on the way.”