---
name: search-console-ga4
description: Give an AI agent Search Console, GA4 and Tag Manager through one Google service account. Setup order, permissions, scripts, and the API gotchas (final vs fresh data, query rows vs totals, sitemap counts, URL Inspection limits). Load before setting up analytics on a site or pulling data from these APIs.
---

# Search Console, GA4 and Tag Manager through APIs

Source: Max Kiriienko, https://mxkeey.com/playbooks/search-console-ga4-claude-code/ — built on
real setups and mistakes, Aug–Oct 2026. API facts checked against Google's documentation in
October 2026. Free to use and change.

Core principle: **the agent works through APIs and checks the result on the live page.** A setting
in a dashboard is a claim. A request leaving the browser with the right ID is a fact.

## The standard

- **Tag Manager on every site, always.** One container per site. GA4 and every other tag live
  inside the container and are added through the API. No direct gtag snippet in templates.
- **One identity = one set of accounts.** One GA4 account and one Tag Manager account per identity
  (personal, client, company); inside them, one property and one container per site. Never mix
  identities.
- **One Google Cloud project and one service account per identity, not per site.** The Cloud
  project only holds the switched-on APIs, the service account and the quota. Access to data is
  granted inside each product. A new site costs no Cloud work: add the same service-account
  email to its Search Console, GA4 and Tag Manager.
- APIs to switch on: Google Search Console API, Google Analytics Admin API, Google Analytics Data
  API, Tag Manager API.

## Permissions for the service account

| Product | Role | What it allows |
|---|---|---|
| Search Console | Full user (Settings → Users and permissions → Add user) | performance data, URL Inspection, submit sitemaps. Owner only if the agent must add users |
| GA4 | Editor on the account | create properties and streams. Viewer is enough to read reports |
| Tag Manager | Administrator on the account | create containers. Publish on one container is enough to change and publish it |

Search Console: prefer a Domain property (covers http, https and subdomains). You verify it
yourself in the UI, then add the service account as a user.

## The key

- Never paste the key into chat, never print it, never commit it, never write it to a temp file.
  Keep it in a password manager or a file outside every repository; the agent reads it in code.
- If your password manager's CLI stores one line, save the key JSON base64-encoded: the private
  key has line breaks, and a raw paste can be saved truncated.
- Add key-file name patterns to `.gitignore` before the first commit.

## Order for a new site

1. GA4 property and web stream through the Admin API → take the measurement ID **from the API
   response**. Never type an ID by hand.
2. Tag Manager container through the API.
3. Container snippet into the site template (head script + noscript after body). The container
   ID comes from site config or env, not hard-coded.
4. Google tag inside the container through the API, on the built-in All Pages trigger → create a
   version → publish.
5. Check on the live page: in the browser's Network tab you see `gtm.js` with your container ID
   and a `collect` request with `en=page_view` and your measurement ID. Then GA4 Realtime.
   Only then report "done".
6. Write the IDs into the project's notes (a passport file), not into chat.

Every change is a script: dry run by default (prints the plan), `--apply` to change, idempotent
(finds objects by name and reuses them). A form filled in a dashboard is not written down
anywhere; a script can be re-read and re-run.

## Search Console API rules

1. **Always send `dataState: "all"`.** Without it the API returns only final data. Final data is
   usually 2–3 days behind; the dashboard shows fresh data. On a new site the default can show
   almost nothing. Fresh data can still change a little before it becomes final.
2. **Totals come from requests without query grouping** (site or page level). Rows grouped by
   query leave out rare, anonymized queries, so they don't add up to the total. Use query rows
   as a guide to what people search, not for sums.
3. **Don't read indexing from the sitemap entry.** Its `indexed` count is marked deprecated in the
   API reference and can show 0 while pages are indexed. Ask URL Inspection on a sample of URLs.
4. **URL Inspection** shows the indexed version only — no live test — and is limited to 2,000
   requests a day and 600 a minute per property (October 2026). Sample; don't loop over the whole
   site daily. Limits change: when a number matters, read Google's current usage-limits page.
5. **Not in the API:** Request indexing, crawl stats. Those are clicks for a human in the UI.
6. Up to 25,000 rows per request (`rowLimit`), page with `startRow`. The API exposes at most
   50,000 rows per day per search type.
7. Sitemaps: submit with `PUT .../sites/{site}/sitemaps/{feed}` (both URL-encoded), then list them
   and check `errors`, `warnings` and `isPending`.

## GA4 and Tag Manager rules

1. A new GA4 property can answer 503 for a minute or two after it is created. Retry 5xx with a pause;
   fail loudly on everything else.
2. Key events count from the moment an event is marked as key. For history, use the event count
   filtered by event name.
3. GA4 data freshness: Realtime in minutes, intraday a few hours, processing up to 24–48 hours.
   Don't judge yesterday's numbers this morning.
4. Tag Manager answers **404 "Not found or permission denied"** when the service account lacks
   rights. Check permissions before you look for a typo.
5. An event in the dataLayer is not an event in GA4. The container needs a trigger and a tag for
   it, and a published version. Check the `collect` request, not the dataLayer.

## Client accounts

- Read access by default (Viewer in GA4).
- Changes in Tag Manager or GA4 only on an explicit request, through a script with a dry run.
- After publishing: check the events in Realtime from real visitors and save a copy of the
  container version.
- Ad accounts stay manual.

If a project deliberately runs its own tracking instead of Google's, skip GA4 and write that down
in the project notes.

## Code (Python, no Google SDK)

`google_api.py` — a token for the service account:

```python
"""pip install requests cryptography
The key JSON comes from GOOGLE_SA_KEY_CMD (a command that prints it, e.g. your password
manager's CLI; plain JSON or base64) or GOOGLE_SA_KEY_FILE (a file outside any repository).
It stays in memory: never printed, never written to disk."""
import base64, json, os, subprocess, time
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding


def _key():
    cmd = os.environ.get('GOOGLE_SA_KEY_CMD')
    if cmd:
        raw = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=True).stdout.strip()
    else:
        with open(os.path.expanduser(os.environ['GOOGLE_SA_KEY_FILE'])) as f:
            raw = f.read().strip()
    if not raw.startswith('{'):
        raw = base64.b64decode(raw).decode()
    return json.loads(raw)


def token(scopes):
    sa = _key()
    now = int(time.time())
    b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
    header = b64(json.dumps({'alg': 'RS256', 'typ': 'JWT'}).encode())
    claims = b64(json.dumps({'iss': sa['client_email'], 'scope': ' '.join(scopes),
                             'aud': 'https://oauth2.googleapis.com/token',
                             'iat': now, 'exp': now + 3600}).encode())
    key = serialization.load_pem_private_key(sa['private_key'].encode(), password=None)
    sig = b64(key.sign(header + b'.' + claims, padding.PKCS1v15(), hashes.SHA256()))
    r = requests.post('https://oauth2.googleapis.com/token', timeout=30, data={
        'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        'assertion': (header + b'.' + claims + b'.' + sig).decode()})
    r.raise_for_status()
    return r.json()['access_token']


def session(scopes):
    s = requests.Session()
    s.headers['Authorization'] = 'Bearer ' + token(scopes)
    return s
```

`setup_analytics.py` — GA4 property, stream, container and Google tag for one site:

```python
"""Dry run by default: prints the plan. --apply creates what is missing and publishes.
Idempotent: finds existing objects by name and reuses them."""
import sys, time
from google_api import session

GA4_ACCOUNT = 'accounts/YOUR_GA4_ACCOUNT_ID'   # one GA4 account per identity
GTM_ACCOUNT = 'YOUR_GTM_ACCOUNT_ID'            # one GTM account per identity
SITE_NAME = 'example.com'
SITE_URL = 'https://example.com'
TIME_ZONE = 'Europe/Berlin'                    # IANA name
CURRENCY = 'USD'
ALL_PAGES_TRIGGER = '2147479553'  # Google's built-in "All Pages" trigger; it worked on a container created seconds earlier
APPLY = '--apply' in sys.argv

s = session(['https://www.googleapis.com/auth/analytics.edit',
             'https://www.googleapis.com/auth/tagmanager.edit.containers',
             'https://www.googleapis.com/auth/tagmanager.edit.containerversions',
             'https://www.googleapis.com/auth/tagmanager.publish'])
GA = 'https://analyticsadmin.googleapis.com/v1beta'
GTM = 'https://tagmanager.googleapis.com/tagmanager/v2'


def call(method, url, **kw):
    # A brand-new GA4 property can answer 503 for a minute or two. Retry 5xx, fail loudly on the rest.
    for attempt in range(6):
        r = s.request(method, url, timeout=60, **kw)
        if r.status_code < 500 or attempt == 5:
            break
        time.sleep(20)
    if r.status_code >= 300:
        hint = ''
        if 'tagmanager' in url and r.status_code == 404:
            hint = '\nHint: GTM says "not found" when it means "no permission". Give the service account Administrator on the GTM account.'
        raise SystemExit(f'{method} {url} -> {r.status_code}: {r.text[:300]}{hint}')
    return r.json() if r.text else {}


props = call('GET', f'{GA}/properties', params={'filter': f'parent:{GA4_ACCOUNT}'}).get('properties', [])
prop = next((p for p in props if p['displayName'] == SITE_NAME), None)
if prop:
    print('GA4 property exists:', prop['name'])
elif APPLY:
    prop = call('POST', f'{GA}/properties', json={'parent': GA4_ACCOUNT, 'displayName': SITE_NAME,
                                                  'timeZone': TIME_ZONE, 'currencyCode': CURRENCY})
    print('GA4 property created:', prop['name'])
else:
    print('[dry run] would create GA4 property', SITE_NAME)

measurement_id = None
if prop:
    streams = call('GET', f"{GA}/{prop['name']}/dataStreams").get('dataStreams', [])
    stream = next((x for x in streams if x.get('type') == 'WEB_DATA_STREAM'), None)
    if not stream and APPLY:
        stream = call('POST', f"{GA}/{prop['name']}/dataStreams", json={
            'type': 'WEB_DATA_STREAM', 'displayName': SITE_NAME, 'webStreamData': {'defaultUri': SITE_URL}})
        print('GA4 web stream created')
    if stream:
        measurement_id = stream['webStreamData']['measurementId']  # from the API, never typed by hand
        print('GA4 measurement ID:', measurement_id)

containers = call('GET', f'{GTM}/accounts/{GTM_ACCOUNT}/containers').get('container', [])
cont = next((c for c in containers if c['name'] == SITE_NAME), None)
if cont:
    print('GTM container exists:', cont['publicId'])
elif APPLY:
    cont = call('POST', f'{GTM}/accounts/{GTM_ACCOUNT}/containers', json={'name': SITE_NAME, 'usageContext': ['web']})
    print('GTM container created:', cont['publicId'])
else:
    print('[dry run] would create GTM container', SITE_NAME)

if cont and measurement_id:
    base = f"{GTM}/accounts/{GTM_ACCOUNT}/containers/{cont['containerId']}"
    ws = call('GET', f'{base}/workspaces')['workspace'][0]
    tags = call('GET', f"{base}/workspaces/{ws['workspaceId']}/tags").get('tag', [])
    tag = next((t for t in tags if t.get('type') == 'googtag'), None)
    if tag:
        print('Google tag exists in container:', tag['name'])
    elif APPLY:
        call('POST', f"{base}/workspaces/{ws['workspaceId']}/tags", json={
            'name': 'GA4 - Google tag', 'type': 'googtag',
            'parameter': [{'type': 'template', 'key': 'tagId', 'value': measurement_id}],
            'firingTriggerId': [ALL_PAGES_TRIGGER]})
        v = call('POST', f"{base}/workspaces/{ws['workspaceId']}:create_version",
                 json={'name': 'GA4 via Google tag', 'notes': 'Created by setup_analytics.py'})
        ver = v['containerVersion']
        call('POST', f"{base}/versions/{ver['containerVersionId']}:publish")
        print('Google tag created and published, version', ver['containerVersionId'])
    else:
        print('[dry run] would add the Google tag', measurement_id, 'and publish')

if cont:
    print('\nPut the container ID into the site config (not hard-coded in templates):', cont['publicId'])
```

Reading Search Console:

```python
from urllib.parse import quote
from google_api import session

SITE = 'sc-domain:example.com'
s = session(['https://www.googleapis.com/auth/webmasters.readonly'])
perf = s.post(f'https://www.googleapis.com/webmasters/v3/sites/{quote(SITE, safe="")}/searchAnalytics/query',
              json={'startDate': '2026-09-01', 'endDate': '2026-09-28', 'dimensions': ['page'],
                    'rowLimit': 25000, 'dataState': 'all'}).json()
idx = s.post('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect',
             json={'inspectionUrl': 'https://example.com/', 'siteUrl': SITE}).json()
print(idx['inspectionResult']['indexStatusResult'].get('coverageState'))
```
