To track Google Business Profile clicks in GA4, add UTM parameters to each location's website URL, using utm_source=google, utm_medium=organic, and a campaign name such as gbp_listing. GA4 keeps that traffic in Organic Search while letting you isolate it by campaign. For many locations, update the websiteUri field through the Business Profile API.
Updated September 2026.
Without tagging, a click from a Business Profile usually lands in GA4 as ordinary google / organic, mixed in with every other organic visit. This post covers a naming scheme that behaves well in GA4's default channel rules, a Python script that tags every location with a dry run first, and a way to compare GA4 sessions with the click counts Google reports.
Why do GBP clicks disappear into organic traffic?
Because the referrer is Google, and GA4 treats Google as a search source. Nothing in the default setup tells you whether the visit came from a blue link, an AI Overview, or the website button on a Business Profile.
UTM parameters solve that. Google Analytics Help describes them in its guide to collecting campaign data with custom URLs, and recommends always setting utm_source, utm_medium, and utm_campaign together.
Which UTM values should you use?
Pick values that still match GA4's Organic Search rule. GA4's default channel group puts a session in Organic Search when the source matches its list of search sites, or when the medium exactly matches organic.
If you invent a medium like gbp or local, and the source does not match GA4's list of search sites, the traffic can land in Unassigned unless you build a custom channel group. So keep the medium boring:
| Parameter | Value | Why |
|---|---|---|
utm_source |
google |
Keeps the source consistent with other Google traffic |
utm_medium |
organic |
Matches the Organic Search channel rule exactly |
utm_campaign |
gbp_listing |
The field you filter on to isolate profile clicks |
utm_content |
store code, for example tx-austin-014
|
Splits results by location |
GA4 treats values as case sensitive, and its help page recommends lowercase. Google and google become two rows.
A tagged URL looks like this:
https://example.com/locations/austin-south-lamar/?utm_source=google&utm_medium=organic&utm_campaign=gbp_listing&utm_content=tx-austin-014
Point each profile at its own location page, not the homepage. The Business Information API reference says websiteUri should, if possible, represent the individual location rather than the brand.
How do you tag hundreds of locations at once?
Use the Business Information API. It exposes locations.list for reading and locations.patch for writing, with an updateMask so you touch only websiteUri. Access requires an approved Google Cloud project, and Google's basic setup guide notes there is no sandbox. Use the validateOnly flag to test.
The script below:
- Lists locations for an account, reading only
name,title,storeCode, andwebsiteUri. - Builds a tagged URL while preserving any existing query parameters.
- Skips locations that are already tagged correctly.
- Sends each patch with
validateOnly=trueunless you pass--apply.
# tag_gbp_urls.py
# pip install google-auth requests
import argparse
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
from google.auth.transport.requests import AuthorizedSession
from google.oauth2.credentials import Credentials
SCOPES = ["https://www.googleapis.com/auth/business.manage"]
BASE = "https://mybusinessbusinessinformation.googleapis.com/v1"
UTM = {"utm_source": "google", "utm_medium": "organic", "utm_campaign": "gbp_listing"}
def tagged(url: str, store_code: str) -> str:
parts = urlsplit(url)
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query.update(UTM)
if store_code:
query["utm_content"] = store_code.lower()
return urlunsplit(parts._replace(query=urlencode(query)))
def list_locations(session, account):
params = {"readMask": "name,title,storeCode,websiteUri", "pageSize": 100}
while True:
resp = session.get(f"{BASE}/{account}/locations", params=params)
resp.raise_for_status()
data = resp.json()
yield from data.get("locations", [])
token = data.get("nextPageToken")
if not token:
break
params["pageToken"] = token
def main():
parser = argparse.ArgumentParser()
parser.add_argument("account", help="accounts/1234567890")
parser.add_argument("--apply", action="store_true", help="write changes")
args = parser.parse_args()
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
session = AuthorizedSession(creds)
for loc in list_locations(session, args.account):
current = loc.get("websiteUri")
if not current:
print(f"SKIP {loc['title']}: no website set")
continue
new = tagged(current, loc.get("storeCode", ""))
if new == current:
print(f"OK {loc['title']}")
continue
params = {"updateMask": "websiteUri"}
if not args.apply:
params["validateOnly"] = "true"
resp = session.patch(f"{BASE}/{loc['name']}", params=params, json={"websiteUri": new})
status = "APPLY" if args.apply else "CHECK"
print(f"{status} {loc['title']}: {resp.status_code} {new}")
if __name__ == "__main__":
main()
Run it without --apply first and read every line of output. Then run it on a small group of locations before the full account.
Should you worry about tagged URLs on a profile?
Google's Business Profile guidelines prohibit URLs that redirect or refer users to landing pages other than the actual business. A UTM parameter does not redirect anywhere. It is a query string on your own URL.
Two practical cautions:
- Make sure your site does not strip or redirect query strings in a way that drops the tags.
- If you use canonical tags, point them at the clean URL so the tagged version does not compete in search.
How do you find the traffic in GA4?
Open Reports, then Acquisition, then Traffic acquisition. Google's help page says campaign values show up in dimensions like Session source/medium and Session campaign.
For a location view, build an exploration:
- Add the dimension Session campaign and filter it to
gbp_listing. - Add the dimension Session manual ad content, which holds your
utm_contentstore codes. - Add metrics such as Sessions and Key events.
That gives you sessions and conversions per location from profile clicks alone.
How do you check the numbers against Google's own counts?
The Business Profile Performance API reports website clicks per location per day through locations.fetchMultiDailyMetricsTimeSeries. It is a good sanity check.
PERF = "https://businessprofileperformance.googleapis.com/v1"
def website_clicks(session, location_name, start, end):
params = {
"dailyMetrics": "WEBSITE_CLICKS",
"dailyRange.start_date.year": start.year,
"dailyRange.start_date.month": start.month,
"dailyRange.start_date.day": start.day,
"dailyRange.end_date.year": end.year,
"dailyRange.end_date.month": end.month,
"dailyRange.end_date.day": end.day,
}
resp = session.get(f"{PERF}/{location_name}:fetchMultiDailyMetricsTimeSeries", params=params)
resp.raise_for_status()
total = 0
for multi in resp.json().get("multiDailyMetricTimeSeries", []):
for series in multi.get("dailyMetricTimeSeries", []):
for point in series.get("timeSeries", {}).get("datedValues", []):
total += int(point.get("value", 0)) # value is omitted when zero
return total
Here location_name is the locations/{id} value from the listing call. Expect clicks and sessions to differ. A click is not a session, consent settings and ad blockers remove some sessions, and one person can click twice. Watch the ratio over time per location. A sudden drop in the ratio at one location often means the website link was changed or the tags were removed.
What breaks this setup?
- Someone edits the link by hand. A manager pastes a clean URL into the profile and the tags vanish. Rerun the script monthly as an audit.
- Listing tools overwrite the field. If a listing platform manages your website URL, set the tagged URL there instead, or the next sync undoes your work. Synup and similar platforms hold the website field in their location record, so that record becomes the place to store the tagged version.
-
Mixed case values.
GBP_Listingandgbp_listingsplit into two rows.
FAQ
Will UTM tags hurt my local rankings?
There is no Google documentation saying they do. The tags do not change the destination page. Use a canonical tag on the location page to keep search signals on the clean URL.
Why not use utm_medium=gbp?
GA4's default Organic Search rule looks for a search source or a medium of exactly organic. A custom medium can push traffic into Unassigned unless you create a custom channel group.
Can I tag appointment or menu links too?
Yes, the same scheme works for any URL you control. Use a different utm_content or campaign value so you can tell the links apart.
How often should I audit the tags?
Monthly is reasonable. Run the script in dry-run mode and look for locations reported as changed.
Top comments (0)