A service business can spend five figures a month on Google Ads and still not know which campaign paid for which job. The form says "contact us." The phone rings from a missed-call widget. The invoice gets raised three weeks later in a different tool. Analytics reports a conversion, the CRM reports a job, and the two numbers never match.
I've hit this on a parking marketplace rebuild and again on a multi-tenant CRM for trades businesses. This post is the pattern we ship: capture attribution on landing, store it first-party, carry it onto the CRM record, and send the real outcome back to Google Ads.
Why last-click lies for service businesses
Last-click works fine for a SaaS checkout on the same domain as the ad's landing page. It breaks when:
- The customer researches on mobile, then books on desktop by typing the brand name
- The conversion is a phone call, not a form submit
- The sale closes in a CRM days or weeks after the first visit
- Staff create the booking by hand and leave the source field blank
Direct traffic then absorbs credit it didn't earn, and budget decisions get made on that fiction.
The fix is last non-direct-click: ignore pure direct visits when a known campaign or referrer already exists for that person. If someone clicks an ad on Tuesday, comes back via bookmark on Thursday, and books on Thursday, the booking still belongs to the ad.
What to capture on every landing
-
utm_source,utm_medium,utm_campaign,utm_content,utm_term -
gclid,wbraid,gbraid(Google Ads click identifiers) - The external referrer when there are no UTMs (organic, partners, marketplaces)
- The landing page path
The rule that makes the whole thing work: the CRM record is the source of truth for revenue, and the ad platform is the source of truth for spend. You need both IDs on the same record.
The landing capture
Two details here matter more than they look:
-
Ignore same-site referrers. If this runs on every page, internal navigation sets
document.referrerto your own domain and overwrites the campaign. Payment redirects (Stripe, PayPal) do the same thing on the way back from checkout. - Separate reading from persisting. Reading the URL is fine immediately, because a query string isn't a cookie. Writing the 90-day cookie has to wait for consent.
const ATTR_KEYS = [
"utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
"gclid", "wbraid", "gbraid",
];
const COOKIE = "kc_attr";
const MAX_AGE_SECONDS = 60 * 60 * 24 * 90;
const IGNORED_REFERRER_HOSTS = ["stripe.com", "paypal.com"];
function readCookie() {
const raw = document.cookie
.split("; ")
.find((row) => row.startsWith(COOKIE + "="));
if (!raw) return null;
try {
return JSON.parse(decodeURIComponent(raw.slice(COOKIE.length + 1)));
} catch {
return null;
}
}
function externalReferrer() {
if (!document.referrer) return null;
try {
const host = new URL(document.referrer).hostname;
if (host === location.hostname) return null;
const ignored = IGNORED_REFERRER_HOSTS.some(
(h) => host === h || host.endsWith("." + h)
);
return ignored ? null : document.referrer;
} catch {
return null;
}
}
// Pure read: safe to call before consent.
function readTouch() {
const params = new URLSearchParams(location.search);
const touch = {
landing_page: location.pathname,
landed_at: new Date().toISOString(),
};
for (const key of ATTR_KEYS) {
const value = params.get(key);
if (value) touch[key] = value;
}
const hasCampaignParams = ATTR_KEYS.some((k) => touch[k]);
if (!hasCampaignParams) {
const ref = externalReferrer();
if (ref) touch.referrer = ref;
}
return touch;
}
function isNonDirect(touch) {
return Boolean(
touch.utm_source || touch.gclid || touch.wbraid || touch.gbraid || touch.referrer
);
}
// Last non-direct-click: a direct visit never replaces a stored campaign.
function resolveTouch(current) {
const existing = readCookie();
return !isNonDirect(current) && existing ? existing : current;
}
// Call only after Consent Mode grants ad_storage.
function persistTouch(touch) {
document.cookie =
COOKIE + "=" + encodeURIComponent(JSON.stringify(touch)) +
"; Max-Age=" + MAX_AGE_SECONDS +
"; Path=/; SameSite=Lax; Secure";
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: "attribution_captured", ...touch });
}
const currentTouch = resolveTouch(readTouch());
// Attach attribution to the booking payload. Falls back to this page
// load's URL data if consent hasn't allowed the cookie yet, so a
// same-visit booking still carries its source without persisting it.
function withAttribution(payload) {
const stored = readCookie();
return { ...payload, attribution: stored || currentTouch };
}
Wire persistTouch(currentTouch) into your consent banner's "granted" callback for ad_storage. Then on submit:
fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withAttribution({ service: "drain-clear", slot: "2026-09-30T14:00:00" })
),
});
Production note: this sample reads the cookie in the browser so it fits in one file. The stronger version sets the same value server-side on the landing response as an HttpOnly cookie and has the booking handler read it there, so the click ID never travels through a hidden form field. The dataLayer push is for Tag Manager; the CRM write doesn't depend on it.
Consent Mode v2 rules
The four signals are ad_storage, analytics_storage, ad_user_data, and ad_personalization. Default them to denied and let the banner update them.
- Read the URL immediately.
- Write the 90-day cookie only after
ad_storageis granted. - If the visitor books on the same page load before consenting, attach the URL data to that one request and don't persist it.
- Send hashed customer data to Google only when
ad_user_datais granted.
A banner that gets ignored while the script sets the cookie anyway is the setup that fails a review later.
Getting it into the CRM
The attribution fields have to be copied onto the lead record on create, not re-typed by staff later. That applies to forms, chat, and voice agents equally. If a voice agent books from an advertised number, pass the tracking number or call-source parameter into the agent's context and persist it on the appointment. A receptionist edit that replaces the campaign with "phone" erases the ad.
When the invoice is paid, revenue is attributed to the stored source, not to whoever last edited the record.
Sending offline conversions back to Google Ads
Once the CRM source field is trusted, upload the real outcome so Smart Bidding optimizes for booked jobs instead of form fills. Two mechanisms cover it:
-
Click ID upload: send the
gclidstored at landing (orgbraid/wbraidwhen that's what you have). Send at most one click ID per conversion. - Enhanced conversions for leads: send hashed email or phone when the click ID is missing, such as a call, a second device, or a visit where consent blocked the cookie.
Fire it when the job is booked or the invoice is paid, never on the form fill.
import { createHash } from "node:crypto";
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
function buildConversion({ lead, invoice, adUserDataGranted }) {
const attr = lead.attribution || {};
const conversion = {
conversionAction: "customers/1234567890/conversionActions/9876543210",
// Account timezone, with offset, inside your configured conversion window.
conversionDateTime: "2026-09-29 14:05:00-05:00",
conversionValue: invoice.total,
currencyCode: "USD",
orderId: invoice.id, // makes retries idempotent
consent: { adUserData: adUserDataGranted ? "GRANTED" : "DENIED" },
};
// At most one click identifier per conversion.
if (attr.gclid) conversion.gclid = attr.gclid;
else if (attr.gbraid) conversion.gbraid = attr.gbraid;
else if (attr.wbraid) conversion.wbraid = attr.wbraid;
// Enhanced conversions for leads: hashed identifiers, consent-gated.
if (adUserDataGranted) {
const ids = [];
if (lead.email) {
ids.push({ hashedEmail: sha256(lead.email.trim().toLowerCase()) });
}
if (lead.phoneE164) {
ids.push({ hashedPhoneNumber: sha256(lead.phoneE164) });
}
if (ids.length) conversion.userIdentifiers = ids;
}
return conversion;
}
Send the result through the Google Ads API's uploadClickConversions with partialFailure: true, under an OAuth app that has gone through Google's official verification. Unofficial connectors are how these integrations break during review.
Two gotchas:
- If the click is older than the conversion window, the upload is rejected. Match the window (often 90 days) to your sales cycle and your cookie lifetime.
- Normalize before hashing: email trimmed and lowercased, phone in E.164. Hashing an unnormalized value silently produces a non-match.
Checklist
- Inventory the money events: form, call, chat, in-person booking, invoice paid.
- Capture UTMs, external referrer, and click IDs on every public page, ignoring same-site and payment-gateway referrers.
- Gate the cookie on
ad_storageand hashed data onad_user_data. - Copy attribution onto the CRM record on create, including calls from tracking numbers.
- Report last non-direct-click for paid search, with last-click as a secondary view.
- Upload offline conversions only for booked jobs or paid invoices.
- Review weekly: spend vs booked jobs vs paid invoices.
The full version, with how this played out on a parking marketplace and a trades CRM, is on keencraft.tech.
Top comments (3)
the "reading the url is fine, writing the cookie needs consent" split is the part most setups get wrong. seen plenty where the banner is cosmetic and the script fires regardless. separating read from persist like this is the honest version.
Thanks, Om. That split is the whole game. The tricky part is that Consent Mode in advanced mode still loads tags and sends cookieless pings while consent is denied, which is legitimate. So 'the script fired' isn't automatically the violation; setting the cookie is. Teams mix those two up a lot. Curious, where have you seen it go wrong most often: the banner itself, or tags firing from GTM before the consent update arrives?
the gtm race is the one i see most. the banner gets reviewed by legal, the tag sequencing gets reviewed by nobody. a consent update that lands 300ms after the pageview ping is technically compliant and practically useless for the window it was meant to protect.