• Oct 13, 2026
  • 5 min read

Google Search Console API with a Service Account, Step by Step

My sites got 1 click from Google in 90 days. Bing sent about a hundred times more. To find out why, I wanted Search Console’s data where I could query it: from a script, and later from a weekly summary on my phone.

This is the setup, step by step, written so I can repeat it on the next site. All of it is free.

What you end up with

  • Search Console domain properties for each site (one covers every subdomain);
  • a Google Cloud service account with a JSON key on your machine;
  • that service account added as a user of each property;
  • a few lines of Python that list properties, submit sitemaps, pull clicks and impressions, and ask Google whether each URL is indexed.

Step 1: verify your domains

In Search Console: Add property → Domain (not “URL prefix”) → example.com.

A domain property covers www., blog., shop. and every other subdomain in one place. Verification is a DNS TXT record. If your DNS is on Cloudflare, Google offers “Verify with Cloudflare” and adds the record itself after you log in. Otherwise copy the TXT record into your DNS and click Verify.

Once verified you also get the history from before verification, up to 16 months, so you don’t start from zero.

Step 2: enable the API in a Google Cloud project

In Google Cloud Console, create or pick a project → APIs & Services → Library → search “Google Search Console API” → Enable.

Step 3: create a service account

IAM & Admin → Service Accounts → Create service account. Give it a name (mine is the brand name). It needs no project roles: skip that step. Its access to Search Console is granted in Search Console, not in Cloud.

The result is an email like [email protected].

Step 4: create its key (not an “API key”)

This is where I hesitated. APIs & Services → Credentials offers “Create credentials → API key”, and that’s the wrong button. An API key can’t read Search Console; the next post explains why.

Instead: click the service account’s email → Keys tab → Add key → Create new key → JSON → Create. A .json file downloads. Treat it like a password:

mkdir -p ~/.config/leads-hub
mv ~/Downloads/project-id-*.json ~/.config/leads-hub/gsc-service-account.json
chmod 600 ~/.config/leads-hub/gsc-service-account.json

Never paste its contents into a chat or commit it to a repository.

Step 5: give the service account access to each property

In Search Console, pick the property in the dropdown at the top left → Settings (bottom of the left menu) → Users and permissions → Add user → the service account’s email → permission Full → Add. (In Spanish: Configuración → Usuarios y permisos → Añadir usuario → Completo.)

Repeat for each property. Full lets it submit sitemaps; it still can’t delete the property or manage owners.

Until this step is done the key works but sees nothing. That was my first result: token ok; properties visible: none yet.

Step 6: query it from Python

google-auth handles the signed-token exchange; plain urllib does the rest:

import json, os, urllib.parse, urllib.request
from google.oauth2 import service_account
from google.auth.transport.requests import Request

creds = service_account.Credentials.from_service_account_file(
    os.path.expanduser("~/.config/leads-hub/gsc-service-account.json"),
    scopes=["https://www.googleapis.com/auth/webmasters"],
)

def call(method, url, body=None):
    if not creds.valid:
        creds.refresh(Request())
    req = urllib.request.Request(
        url, method=method,
        data=json.dumps(body).encode() if body is not None else None,
        headers={"Authorization": f"Bearer {creds.token}", "Content-Type": "application/json"},
    )
    text = urllib.request.urlopen(req, timeout=60).read().decode()
    return json.loads(text) if text else {}

W = "https://www.googleapis.com/webmasters/v3"
q = lambda s: urllib.parse.quote(s, safe="")

List the properties it can see:

for s in call("GET", f"{W}/sites").get("siteEntry", []):
    print(s["siteUrl"], s["permissionLevel"])   # sc-domain:example.com siteFullUser

Submit a sitemap (an empty PUT):

call("PUT", f"{W}/sites/{q('sc-domain:example.com')}/sitemaps/{q('https://www.example.com/sitemap-index.xml')}")

Clicks, impressions and position for the last 90 days, by query or by page:

body = {"startDate": "2026-07-10", "endDate": "2026-10-08",
        "dimensions": ["query"], "rowLimit": 10, "type": "web"}
rows = call("POST", f"{W}/sites/{q('sc-domain:example.com')}/searchAnalytics/query", body).get("rows", [])
for r in rows:
    print(int(r["impressions"]), int(r["clicks"]), round(r["position"], 1), r["keys"][0])

Data is about two days behind, so end the range two days ago.

Is this URL indexed, and why not? (URL Inspection API, ~2,000 checks per day per property):

r = call("POST", "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect",
         {"inspectionUrl": "https://www.example.com/contact/", "siteUrl": "sc-domain:example.com"})
s = r["inspectionResult"]["indexStatusResult"]
print(s["verdict"], s["coverageState"], s.get("googleCanonical"), s.get("userCanonical"))

Run that over every URL in your sitemap and count the coverageState values. It’s the fastest way to see what Google does and doesn’t know about you.

One gotcha: if your script downloads your own sitemap first, send a User-Agent header. Cloudflare’s bot protection blocks Python’s default one with error 1010, the same trap that once blocked my homelab alerts.

What it found on my sites

URLs checkedIndexedUnknown to Google
Main site, consulting, ebooks, audit19766120
Product site24321

Two causes, both mine:

  1. No sitemap had ever been submitted for two of my three domains, and the third had last been read seven months earlier. Most pages had simply never been seen. Six sitemaps, submitted with the PUT above.
  2. Every canonical URL on the main site pointed at a redirect. Astro was set to trailingSlash: "never", so canonical tags and the sitemap said /contact. Cloudflare Pages serves /contact/ and redirects /contact there. URL Inspection showed it directly: userCanonical was /contact, googleCanonical was /contact/. Switching to trailingSlash: "always" and fixing eight menu links made the two agree.

Neither shows up in a browser. Both show up in the first API call.

Takeaways

  • Domain property, service account, JSON key, add as user. That’s the whole setup; the step people miss is the last one.
  • “API key” is the wrong credential for Search Console.
  • Submit your sitemaps. Having one on the site isn’t the same as Google reading it.
  • Compare userCanonical with googleCanonical. When they differ, Google is ignoring what you told it.