A trades 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 is raised three weeks later in a different tool. Analytics reports a conversion. The CRM reports a job. The two numbers never match.
That gap is not a reporting preference. It is how companies keep buying the wrong keywords. KeenCraft ran into it on a parking marketplace rebuild (PARKees) and again while building a multi-tenant CRM for trades. The rest of this article is the model we actually ship.
Marketing attribution for service businesses
Last-click is fine when the product is a SaaS checkout on the same domain as the ad 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 record by hand and leave the source field blank.
Direct traffic then absorbs credit it did not earn. Paid search looks weaker than it is, or stronger than it is, depending on how cookies and call tracking were set up. Operators make budget decisions on that fiction.
Last non-direct-click, in operational terms
Last non-direct-click means: ignore pure direct visits when a known campaign or referrer already exists for that person. If someone clicks a Google Ad on Tuesday, comes back via bookmark on Thursday, and books on Thursday, the booking still belongs to the ad.
On PARKees, that model was implemented with dataLayer capture of UTM parameters and referrer data. The important part is not the Google Tag Manager checkbox. It is that the first-party site writes a durable source onto the session and then onto the booking.
What you have to capture on every landing
- utm_source, utm_medium, utm_campaign, utm_content, utm_term when present.
- The HTTP referrer when UTMs are absent (organic, partner, marketplace).
- gclid / wbraid / gbraid for Google Ads click identifiers.
- A first-party identifier (cookie or authenticated user id) that survives the booking flow.
- Landing page path, so you can tell a service page from a blog post from a homepage.
A dataLayer capture you can ship
The sample below is the whole path on one page. readTouch() reads UTMs, the Google click ids, and an external referrer, and it ignores same-site and payment-gateway hosts. resolveTouch() keeps the previous campaign when this visit is direct. persistTouch() writes the 90-day first-party cookie and pushes the dataLayer event, and it runs only after Consent Mode grants ad_storage. withAttribution() attaches the stored touch, or this page load's URL data if the cookie is not written yet. The cookie name kc_attr is an example, not a product requirement.
Landing capture
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 the consent banner's granted
// callback for ad_storage. Do not call it before that.
fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
withAttribution({ service: "drain-clear", slot: "2026-09-30T14:00:00" })
),
});That cookie is readable by the page because the sample attaches it in the browser. In production, set the same value from the server on the landing response when you can, and have the booking handler read it there. An HttpOnly cookie is the stronger version: the click id never has to travel back through a hidden form field. The dataLayer push is for Tag Manager. The CRM write does not depend on it. Because this runs on every public page, an internal click would otherwise set the referrer to your own domain and replace the ad campaign. A return from Stripe or PayPal does the same thing. Both are ignored, so last non-direct-click still keeps the original click.
Consent Mode and the attribution cookie
Google Consent Mode v2 is the signal this script has to wait on: ad_storage, analytics_storage, ad_user_data, and ad_personalization. Default them to denied, and let the banner update them. The four consent signals and their defaults are documented in Google's Consent Mode guide. Read the landing URL immediately, because the query string is not a cookie. Write the 90-day cookie only after ad_storage is granted, which is what persistTouch() is for. If the visitor books on that same page load before consent, attach the UTMs and gclid from the URL to that one request and do not persist them. Enhanced conversions and the offline upload send customer data only when ad_user_data is granted. On that upload, set the Google Ads API consent field adUserData to GRANTED or DENIED so it matches the signal you stored. For EEA traffic the conversion object is rejected without it. A banner that is ignored, while the script sets the cookie anyway, is the setup that fails the review later.
Closed-loop: ads spend in, invoices out
The trades CRM is a live multi-tenant system KeenCraft built for local services. It auto-scores leads 1–100, maps form submissions into CRM records, runs email automations by job stage, tracks jobs from lead to invoice, and attributes revenue back to source, including Google Ads through officially verified OAuth.
That last clause matters. Unofficial scraping of Google Ads is how integrations break during verification and how accounts get banned. Official OAuth is slower to set up and is the only version that survives a real audit.
A closed loop, in the form we implement under automation and CRM integrations:
- Click or call lands with UTMs / gclid stored first-party.
- Form, chat, or voice agent creates a CRM lead with those fields copied, not re-typed.
- Lead scoring and stage automation keep the job moving without a human re-entering source.
- When the invoice is paid, revenue is attributed to the stored source, not to whoever last edited the record.
- Google Ads (or the equivalent) receives an offline conversion so bid strategy can see real jobs, not just form fills.
Offline conversion tracking for Google Ads
Once the CRM source field is trusted, the search that follows is offline conversion tracking. Two uploads cover it. A click-id upload sends the identifier stored at landing, and at most one of gclid, gbraid, or wbraid. Enhanced conversions for leads send a hashed email or phone when the click id is missing: a call, a second device, or a visit where consent blocked the cookie. Google's enhanced conversions for leads guide covers the normalization and hashing requirements. Both belong on the booked job or the paid invoice. A form-fill upload teaches Smart Bidding to buy more forms. The upload itself goes through the Google Ads API's click conversion upload, documented in Manage offline conversions.
- At most one click id: gclid, or else gbraid, or else wbraid. Omit all three when you only have hashed user data.
- The conversion action id from the Google Ads account, via the official API.
- Conversion time in the account timezone, with the offset, inside the conversion window you configured. Ninety days is the usual match for the cookie above.
- Value and currency from the quote or the invoice, so the bid strategy sees revenue.
- An order id, so a retry does not count the same job twice.
Offline upload
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 what buildConversion returns through UploadClickConversions, with partial failure enabled, under the same OAuth the trades CRM already took through official verification. Unofficial connectors are how this breaks during review. Google's page warns that UploadClickConversion requests fail for developer tokens that had not already been uploading before 15 June 2026, and points those accounts at the Data Manager API. If the click is older than the conversion window, the upload will be rejected. That is a reporting setting to match to the sales cycle, not a bug in the CRM. Normalize before hashing: email trimmed and lowercased, phone in E.164. Hashing an unnormalized value silently produces a non-match.
What the booking record shows after the fix
On PARKees, a reservation completed days later by typing the URL still carried the Google Ads campaign from the first click. A last-click report calls that visit direct. The booking keeps the campaign. On the trades CRM, those same fields stay on the lead through quote and invoice, and the offline conversion goes to Google Ads when the job is booked or the invoice is paid. The weekly budget review is spend against booked jobs and paid invoices. A campaign that only produced form fills stops looking like a campaign that produced work.
Call tracking attribution
Call tracking attribution is that same record on a phone number. For appointment businesses, the highest-value conversion often never hits a thank-you page. Dynamic number insertion can show a different number by source, and the number only helps if the session that displayed it still holds the gclid and UTMs. When the call becomes a job, copy that session onto the CRM record. A pool number with an empty click id leaves the source as phone, which tells you the channel and hides the campaign.
KeenCraft's voice agents write the same source fields a web form would. If VoiceCake books a dental appointment from a missed-call number that was advertised, the CRM still needs the campaign that paid for the ring. Pass the tracking number or the call-source parameter into the agent context and persist it on the appointment. A receptionist edit that replaces the campaign with 'phone' erases the ad.
How to track Google Ads to booked jobs
- Inventory the money events: form, call, chat, in-person booking, invoice paid.
- Put first-party UTM, referrer, and gclid capture on every public page, and gate the cookie on Consent Mode.
- Copy those fields into the CRM on create, including calls from tracking numbers.
- Choose last non-direct-click as the default reporting model for paid search. Keep last-click as a secondary view.
- Upload offline conversions, a gclid or enhanced conversions for leads, only after the CRM source field is trusted, and only for booked jobs or paid invoices.
- Review weekly: spend vs booked jobs vs paid invoices.
This is not a full marketing stack replacement. It is the minimum architecture that stops you from scaling ads into a black hole. If you already have HubSpot, Salesforce, or Zoho, the same pattern applies; the CRM brand is less important than whether source survives from click to cash. The ad-to-booking leak calculator separates bookings the ad report cannot see from calls the ads paid for that nobody answered.