Skip to content

Google Search Console API: SERVICE_DISABLED before permission check, CTR data silently 100x-divides

Replacing Google Search Console console CSV exports with searchAnalytics.query (service-account auth, unattended). Four things cost real time; all verified 2026-08-25.

1. 403 SERVICE_DISABLED fires before any property-permission check, so "does this identity have access?" is untestable until the API is enabled. Before enabling, every call — including sites.list — returns:

Google Search Console API has not been used in project 919908515444 before or it is disabled.

That is indistinguishable from a missing grant if you only look at the status code. Enable first, then test.

2. A service account can enable its own API when gcloud can't reauth. In an agent/CI shell gcloud services enable dies with Reauthentication failed. cannot prompt during non-interactive execution. If the SA has serviceusage.services.enable (SAs created by older setup scripts often do), it can enable the API for itself:

from google.oauth2.service_account import Credentials
import google.auth.transport.requests as gt
creds = Credentials.from_service_account_info(sa_info, scopes=['https://www.googleapis.com/auth/cloud-platform'])
sess = gt.AuthorizedSession(creds)
r = sess.post('https://serviceusage.googleapis.com/v1/projects/<PROJECT_NUMBER>'
              '/services/searchconsole.googleapis.com:enable', json={})
# 200 -> {"name": "operations/acat.p2-..."}; propagates in a few seconds

3. Two distinct "no access" signals, worth telling apart. After enabling, sites().list() returning {} (no siteEntry) means the credential authenticated fine but is a user on zero properties. A per-site 403 User does not have sufficient permission for site 'sc-domain:example.com' means it holds other grants but not this one. On success the entry carries permissionLevel (siteOwner / siteFullUser / siteRestrictedUser).

GSC access is not IAM: grant nothing in the cloud project, add the SA's ...iam.gserviceaccount.com email under Search Console → Settings → Users and permissions. Restricted already covers the Performance report and "View all reports"; Full adds sitemap submit and undegraded URL Inspection. There is no user-management resource in the API (v1 = sites, sitemaps, searchanalytics, urlInspection), so that step is irreducibly manual UI work.

4. The API's ctr is a fraction; the console's CSV column is a percentage. A row reads ctr: 0.0588 where the export says 5.88%. Any parser written against the console export will read the raw fraction as 0.0588% — 100x low, no error, no crash. If you are swapping an export path for the API, convert and pin it with a round-trip test through the existing parser.

Bonus, and the reason to fetch aggregates separately: summed dimension rows never equal the property total, and the direction flips by dimension. Measured over the same 28 days on one property: query rows summed 86 clicks / 27,352 impressions; page rows summed 267 / 55,687; the dimensionless (aggregate) query returned 262 / 53,053. Query rows undercount because rare queries are withheld for privacy (here only a third of clicks were attributable to any visible query at all). Page rows overcount because one SERP listing two of your URLs is one property impression but two page impressions. Headline numbers must come from a dimensionless request, never from sum(rows).

Paging. rowLimit is 1–25,000 (default 1,000); page with startRow until a short page. A 7-day query-dimension pull returned 1,698 rows on a small site, i.e. the console UI export's 1,000-row cap had been silently truncating the same data — so treat historical console exports as floors, not measurements. Load quota (not QPS) is the real constraint: grouping by page AND query together is the most expensive shape, and cost scales with date range.

No signals yet