All posts
Performance Ads

How to Capture GCLID for Offline Conversions

Capture GCLID for offline conversions the right way: read the _gcl_aw cookie (not just the URL), handle gbraid and wbraid, and deploy it via GTM.

August 1, 2026·10 min read·by Olexander Cheberko
Table of contentstap to expand

If you want offline conversions to work, the click id has to be captured cleanly at the moment someone books or submits a form, and reading it from the URL alone will quietly lose a chunk of your conversions. The reliable way is to read the _gcl_aw cookie as well as the URL, handle gbraid and wbraid alongside gclid, and persist both a first-touch and a last-touch snapshot so it survives navigation. You can ship all of it through Google Tag Manager without touching a single site file.

I built exactly this for a multi-location US medical practice that runs Google Ads for calls and online bookings on a shared host I could not deploy to. The constraint forced the clean version of this pattern, and it is the version I now reuse. Here is how to capture GCLID for offline conversions so it holds up in the real world.

Quick answer

Do thisWhy it matters
Read _gcl_aw, not only the URLThe freshest click often sits in the cookie after the URL is stripped
Capture gbraid and wbraid tooThese are the iOS click ids; they upload through different fields than gclid
Store first-touch AND last-touchThe id has to survive multi-page journeys and return visits
Also store UTM, landing page, referrerYou will want them for reporting and to debug bad rows
Deploy via a GTM Custom HTML tag (All Pages)Zero site-file changes, works on a shared host
Turn on Auto-tagging and add Conversion LinkerAuto-tagging sets gclid; Conversion Linker writes _gcl_aw
Click lands
gclid on the URL
Read both
URL + _gcl_aw cookie
Store
first-touch + last-touch
Upload
at the real outcome
Read both sources, prefer the freshest click, keep it through navigation, and only an anonymous id ever moves.

Why is reading gclid from the URL not enough?

The obvious approach is to grab ?gclid=... off the landing URL and call it done. It works in a demo and leaks in production. Two things break it.

First, the URL does not stay put. A visitor lands on an ad URL carrying the gclid, then clicks into a services page, a location page, and finally the booking widget. By the time your submit handler runs, location.search may be completely clean. If the only place you looked was the URL, that conversion is now anonymous.

Second, Google itself keeps a copy for you. With Auto-tagging on and the Conversion Linker tag present, Google writes the click id into a first-party cookie named _gcl_aw, in the shape GCL.<timestamp>.<gclid>. That cookie is frequently the freshest source of truth, precisely because it persists after the query string is gone. I have watched a booking come in with a spotless URL while the real click sat waiting in _gcl_aw the whole time. Read the cookie, and that conversion is no longer lost.

So the rule is simple: read both, and prefer whichever click is freshest. The URL wins on the very first pageview of a new click; the cookie wins on every page after that.

What about gbraid and wbraid?

gclid is not the only click id anymore. On iOS traffic, where Apple's privacy rules block a normal gclid in some flows, Google uses two alternatives:

  • gbraid shows up on app-to-web journeys (someone taps an ad inside an app, then lands on your site).
  • wbraid shows up on web-to-web journeys under the same privacy constraints.

They behave differently from gclid in one way that bites people: they upload to Google through their own dedicated fields, not the gclid field. If you shove a wbraid into a gclid column at upload time, Google rejects the row. So you cannot just capture "the click id" as one blob. You have to capture the value and remember which kind it is, then map it to the right field when you send conversions to Google.

Practically, you read gbraid and wbraid from the URL the same way you read gclid, and you store a click_id_type next to the value. Everything downstream branches on that type. This is one of those details that never surfaces in testing, because most test traffic is desktop Chrome, and only shows up as missing conversions once real iPhone users start clicking your ads. Getting the click-id plumbing right is the unglamorous core of any real Google Ads conversion tracking setup.

Store first-touch and last-touch, not just the current click

A single snapshot is not enough, because attribution questions come in two flavors and they want opposite things. "Which campaign first brought this person in" wants first-touch. "Which click should get credit for this booking" usually wants last-touch. If you only keep one, you cannot answer the other later, and you will not get a second chance to collect it.

So I persist both in localStorage:

  • First-touch is written once, on the first visit that carries any click id, and never overwritten. It answers the discovery question.
  • Last-touch is overwritten only when a new pageview actually carries a click id, which is the key subtlety. If you overwrite last-touch on every pageview, ordinary internal navigation (which has no click id) wipes the real source and replaces it with nothing. Overwrite only on a genuine click, and internal clicks leave it alone.

Alongside each snapshot I keep the UTM parameters, the landing page path, the referrer, and a capture timestamp. The UTMs and referrer are cheap to grab and save you hours when a row looks wrong and you need to reconstruct where it came from.

The capture module

Here is the module I deploy. It reads _gcl_aw and the URL, handles all three click-id types, persists first-touch and last-touch, and exposes a single get() that returns clean JSON matching the backend fields. Drop it into a GTM Custom HTML tag as-is.

<script>
(function () {
  var FIRST_KEY = 'cc_attr_first';
  var LAST_KEY  = 'cc_attr_last';
 
  function readCookie(name) {
    var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
    return m ? decodeURIComponent(m.pop()) : '';
  }
 
  // _gcl_aw looks like "GCL.1735689600.Cj0KCQ..." - the click id is everything
  // after the second dot (gclids can themselves contain dots, so rejoin).
  function gclidFromCookie() {
    var raw = readCookie('_gcl_aw');
    if (!raw) return '';
    var parts = raw.split('.');
    return parts.length >= 3 ? parts.slice(2).join('.') : '';
  }
 
  function param(name) {
    var m = new RegExp('[?&]' + name + '=([^&#]+)').exec(location.search);
    return m ? decodeURIComponent(m[1]) : '';
  }
 
  // iOS privacy ids win when present; they upload through their own fields.
  function detectClick() {
    var gbraid = param('gbraid');
    if (gbraid) return { id: gbraid, type: 'gbraid' };
    var wbraid = param('wbraid');
    if (wbraid) return { id: wbraid, type: 'wbraid' };
    var gclidUrl = param('gclid');
    if (gclidUrl) return { id: gclidUrl, type: 'gclid' };
    var gclidCookie = gclidFromCookie();
    if (gclidCookie) return { id: gclidCookie, type: 'gclid' };
    return { id: '', type: '' };
  }
 
  function snapshot() {
    var c = detectClick();
    return {
      click_id: c.id,
      click_id_type: c.type,
      utm_source: param('utm_source'),
      utm_medium: param('utm_medium'),
      utm_campaign: param('utm_campaign'),
      utm_term: param('utm_term'),
      utm_content: param('utm_content'),
      landing_page: location.pathname + location.search,
      referrer: document.referrer || '',
      captured_at: new Date().toISOString()
    };
  }
 
  function load(key) {
    try { return JSON.parse(localStorage.getItem(key) || 'null'); }
    catch (e) { return null; }
  }
  function save(key, val) {
    try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) {}
  }
 
  var now = snapshot();
 
  // First-touch: write once, never overwrite.
  var first = load(FIRST_KEY);
  if (!first) { first = now; save(FIRST_KEY, now); }
 
  // Last-touch: overwrite ONLY when this pageview carries a real click id,
  // so internal navigation does not blank out the source.
  if (now.click_id) { save(LAST_KEY, now); }
  var last = load(LAST_KEY) || first;
 
  window.clickCapture = {
    get: function () {
      return {
        click_id: last.click_id || first.click_id || '',
        click_id_type: last.click_id_type || first.click_id_type || '',
        first_touch: first,
        last_touch: last
      };
    }
  };
})();
</script>

At submit time you call window.clickCapture.get() and get back exactly the shape your backend expects:

{
  "click_id": "Cj0KCQ...",
  "click_id_type": "gclid",
  "first_touch": {
    "click_id": "Cj0KCQ...",
    "click_id_type": "gclid",
    "utm_source": "google",
    "utm_medium": "cpc",
    "utm_campaign": "brand",
    "landing_page": "/?gclid=Cj0KCQ...",
    "referrer": "",
    "captured_at": "2026-08-01T14:22:05.311Z"
  },
  "last_touch": { "click_id": "Cj0KCQ...", "click_id_type": "gclid", "captured_at": "2026-08-01T15:04:41.902Z" }
}

Notice there is nothing personal in there. No name, no phone, no health information. That is deliberate, and it is what makes this safe to run for a medical practice.

How do I deploy this without touching site files?

This is the part that makes GTM worth it. On the practice's shared host, I had no deploy access to the site itself, and even if I had, editing a live medical site to add a script is the kind of change nobody wants to own at 5pm on a Friday. GTM sidesteps the whole problem.

  1. In Google Tag Manager, create a new tag of type Custom HTML.
  2. Paste the module above into it, <script> tags included.
  3. Set the trigger to All Pages so it runs everywhere, on every pageview.
  4. Set the tag firing priority high enough that it runs early, before your submit handlers.
  5. Preview it, confirm window.clickCapture.get() returns real data in the console, then publish.

Zero site files changed. The whole capture layer lives in the tag container, which means you can iterate on it without ever asking the site's developer for a deploy. When I need to add a field or fix an edge case, it is a container change and a publish, not a release.

There is one hard prerequisite people miss. The _gcl_aw cookie only exists if Auto-tagging is enabled in Google Ads and the Conversion Linker tag is live in the same GTM container. Auto-tagging is what appends the gclid to your ad URLs; the Conversion Linker is what reads it and writes the first-party _gcl_aw cookie. If you skip the Conversion Linker, the cookie is never written, your fallback disappears, and you are back to relying on the URL alone. Add the Conversion Linker on All Pages before you trust the cookie path.

Common ways gclid capture silently fails

SymptomUsual cause
Conversions land but with no click idRead the URL only; the click was in _gcl_aw
iPhone conversions rejected at uploadwbraid/gbraid sent in the gclid field instead of their own
_gcl_aw cookie never appearsConversion Linker tag missing, or Auto-tagging off
Source flips to blank after a few clicksLast-touch overwritten on pageviews with no click id
Works in preview, empty in productionTag fires after the submit handler; raise its priority
Old clicks rejected by GoogleClick older than the conversion window; drop by captured_at

Most of these look like a broken pipeline and are actually a capture bug. That is why I treat the capture module as the foundation of any offline conversion tracking setup, not an afterthought bolted on at the end.

Keeping it HIPAA-conscious

Because this runs for a healthcare group, what leaves the browser matters more than usual. The capture module never touches a patient record. It collects an anonymous ad-click id, some campaign context, and timestamps. Nothing in the JSON identifies a person.

When a booking completes, the backend attaches a conversion value and a timestamp to the click id and uploads that to Google. The caller's phone number, when it is used to match a call, is hashed before it goes anywhere. No patient data is persisted for attribution purposes. In other words, only an anonymous click id, a value, and a timestamp ever leave the system. That boundary is what lets a medical practice measure real return on ad spend without turning its ad platform into a place where health data can leak.

If you are wiring click capture into a booking flow and want the whole path, from _gcl_aw to a clean upload, done in a way that respects that boundary, that is the work I do. You can see how I approach the full conversion tracking pipeline, or send me the constraint you are stuck on and I will tell you straight whether GTM alone can solve it.

Tags

capture-gclidoffline-conversionsgoogle-adsconversion-trackinggtmgclid

Frequently asked questions

Where does Google store the gclid besides the URL?

Google Ads auto-tagging writes the click id into the _gcl_aw first-party cookie, in the format GCL.<timestamp>.<gclid>. That cookie often holds a fresher click than a URL you read later, because the query string gets stripped as the visitor moves around the site. Read both and prefer the freshest.

What are gbraid and wbraid, and how are they different from gclid?

They are the privacy-safe click ids Google uses for iOS traffic where a normal gclid cannot be set. gbraid appears on app-to-web journeys and wbraid on web-to-web ones. You capture them from the URL like a gclid, but they upload to Google through their own fields, so store the id type alongside the id.

How do I capture gclid without editing my website's code?

Deploy a Custom HTML tag in Google Tag Manager on the All Pages trigger. It runs your capture script on every page and touches zero site files, which is the only practical option on a shared host you cannot deploy to.

Do I need the Conversion Linker tag to capture gclid?

Yes, if you want the _gcl_aw cookie. Auto-tagging in Google Ads puts the gclid on the URL, and the Conversion Linker tag is what reads it and writes the first-party _gcl_aw cookie. Without both, you can still read the URL, but you lose the cookie fallback that catches clean-URL sessions.

How long is a gclid valid for offline conversions?

Google accepts an offline conversion tied to a click within the conversion window, which defaults to around 90 days and can be extended in the conversion action settings. Capture the click timestamp so you can drop anything outside the window before you upload.

Is capturing gclid HIPAA-conscious for a medical practice?

It can be, because a gclid is an anonymous ad-click id, not patient data. In the setup I run, only the click id, a value, and a timestamp ever leave the system, the caller's phone number is hashed, and no patient record is persisted for attribution.

Need something like this built?

Free 15-min discovery call. I'll listen, ask honest questions, and tell you if I can help.

More in Performance Ads