Automation & Analytics

Attribution tracking that ties ad spend to real bookings

If the conversion happens on a phone call, a booking form, or a job that closes days later, Google Analytics last-click will mis-credit the sale. KeenCraft implements last non-direct-click attribution by reading UTMs and gclid on landing, storing them in a first-party cookie, and copying them onto the CRM record. Offline conversions, a gclid upload or enhanced conversions for leads, go back to Google Ads when the job is booked or the invoice is paid. The marketing cookie is written only after the site's Consent Mode signal allows it.

Last-click mis-credits phone calls and booked jobs. How service businesses capture UTMs and gclid, store them first-party, and send offline conversions back to Google Ads.

Muhammad Jareer · · Updated · 12 min read

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.

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:

  1. Click or call lands with UTMs / gclid stored first-party.
  2. Form, chat, or voice agent creates a CRM lead with those fields copied, not re-typed.
  3. Lead scoring and stage automation keep the job moving without a human re-entering source.
  4. When the invoice is paid, revenue is attributed to the stored source, not to whoever last edited the record.
  5. 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

  1. Inventory the money events: form, call, chat, in-person booking, invoice paid.
  2. Put first-party UTM, referrer, and gclid capture on every public page, and gate the cookie on Consent Mode.
  3. Copy those fields into the CRM on create, including calls from tracking numbers.
  4. Choose last non-direct-click as the default reporting model for paid search. Keep last-click as a secondary view.
  5. 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.
  6. 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.

Questions this article answers

What is last non-direct-click attribution?+

A model that credits the last marketing touch that was not a direct visit. If a customer first arrives from Google Ads, leaves, and later types the URL, the booking still belongs to the ad, not to 'direct'.

What does marketing attribution for service businesses have to include?+

The click, the call, and the booked job on one record. UTMs, referrer, and gclid are captured first-party, copied onto the CRM lead, and sent back to Google Ads as an offline conversion when the job is booked or the invoice is paid. A thank-you page in GA4 is not that record.

How did KeenCraft implement this for PARKees?+

On the PARKees parking marketplace rebuild, KeenCraft designed a last non-direct-click model using dataLayer capture of UTM parameters and referrer data. A reservation completed later from a typed-in URL still carried the original ad click.

What is offline conversion tracking in Google Ads?+

A gclid upload, or enhanced conversions for leads, sent when the money event happens in the CRM. The upload includes the click id or a hashed email and phone, the conversion time, the value, and an order id. It fires on the booked job or the paid invoice, after the CRM source field is trusted.

How do you track Google Ads to booked jobs?+

Store gclid and UTMs in a first-party cookie on landing, copy them onto the CRM lead when the form, call, or voice agent creates the record, and upload an offline conversion when that lead becomes a booked job or a paid invoice.

What is call tracking attribution?+

The same source model on a phone call. A tracking number is useful only when the session that displayed it still has the gclid and UTMs. When the call becomes a job, those fields are copied onto the CRM record. The source is the campaign, and the channel is the phone.

Does Consent Mode block Google Ads attribution?+

It gates the cookie and the upload. Read the landing URL immediately. Write the 90-day first-party cookie only after ad_storage is granted. Send hashed customer data with enhanced conversions only after ad_user_data is granted. A booking on that same page load can still carry the query-string click id on that one request.

Can Google Ads revenue be attributed inside a CRM?+

Yes, when OAuth is verified and conversion data is written back. A multi-tenant CRM KeenCraft built for trades and local services attributes revenue to source including Google Ads via officially verified OAuth.

Ready to build something that saves you money from day one?

Book a free 30-minute call. Tell me what you're trying to build or fix, and I'll tell you honestly whether KeenCraft is the right fit and what it would take.