seojuice

I Built a Google Search Console Dashboard for $0. Here's the Code.

Vadim Kravcenko
Vadim Kravcenko
May 17, 2026 · 15 min read

TL;DR: Google Search Console gives away the query, page, position, and impression data that Ahrefs and SEMrush repackage into dashboards costing $129–$499/mo. The GSC API has a free 1,200-req/min quota, a 25,000-row payload limit, and four endpoints that cover the questions a solo founder actually asks about their own site. This walks through the service-account setup, those four endpoints, a runnable Python script, and three automation patterns I've used. If you'd rather not maintain the wiring, SEOJuice runs the same integration as a managed service for $29–$99/mo.

What GSC Already Gives You for Free (That Paid Tools Repackage)

I cancelled an Ahrefs subscription a while back and felt slightly guilty about it, like skipping a gym membership I'd convinced myself I used. Then I looked at what I actually opened the tool to check: top organic queries, which pages were gaining or losing clicks, and the occasional "why did this page drop." All three live in Search Console. For free. The rank tracking and the backlink graph, the parts I was really paying for, I almost never touched.

That's the honest version. Paid suites layer three things on top of GSC: rank tracking from rented proxies, a backlink graph scraped from web archives, and a UI that doesn't make you write code. For my own site the only one I missed was the UI, the cheapest of the three to rebuild.

Here's what the API exposes for any property you've verified ownership of:

  • 16 months of historical performance data: every query that brought a click or impression, with position, CTR, and date.
  • Page-level breakdown for the same window: which URLs ranked for which queries, and where the clicks landed.
  • Index coverage for any URL: last crawl date, indexing verdict, the canonical Google chose, mobile usability, structured-data parsing.
  • Sitemap state: which sitemaps Google fetched, when, and how many URLs were discovered versus indexed.
  • Device, country, and search-appearance segmentation on the same data.

What's missing is anything outside your own property: competitor rankings, backlink growth on rival domains, share-of-voice math. When I was pre-product-market-fit, those were mostly anxiety food, numbers I'd stare at that never changed what I shipped next. I wrote a longer piece on cutting an SEO stack to two tools, and the conclusion held up: GSC plus one writing surface was enough signal to act on.

"The data in Search Console comes directly from Google. It's the most accurate source available for understanding how your site performs in Google Search."

From Google's Search Console Help documentation.

Setting Up the GSC API With a Service Account

There are two viable auth flows: user-flow OAuth (browser redirect, refresh tokens) and service-account OAuth (server-to-server, no browser). For a founder running a cron on a single VPS, I'd reach for service accounts every time: no refresh-token expiration to babysit, no consent screen to keep alive. Provisioning took me ten minutes the first time:

  1. Open the Google Cloud Console and create a project (or reuse one). The free tier never bills.
  2. Enable the Google Search Console API in the API library.
  3. Under IAM & Admin → Service Accounts, create a service account named gsc-dashboard-reader. No project-level roles needed.
  4. Generate a JSON key on the account and download it. Treat it like an API key; don't commit it.
  5. In Search Console, under Settings → Users and permissions, add the service account's email (ending in @<project-id>.iam.gserviceaccount.com). Restricted permission is enough for reading.

That last step is the one I forgot. The service account is a Google identity, but until you explicitly grant it access to your property, the API hands you an authoritative-looking 403 user does not have sufficient permission for site error. I spent twenty minutes convinced my JSON key was malformed before realizing I just hadn't invited it to the party. Grant per-property; if you have several, repeat for each.

Diagram of the service-account OAuth flow: GCP project, service account, JSON key, Search Console permission grant
Service-account OAuth flow for GSC. The credentials file authenticates to Google; the per-property grant inside Search Console authorizes access to the data.

The Four Endpoints I Actually Use

Most APIs this useful are sprawling. This one isn't, and that's half of why I trust it. Four endpoints cover query data, sitemap state, per-URL inspection, and the list of properties you can read. I've never opened the rest.

EndpointWhat it returnsQuota costUseful for
searchanalytics.queryUp to 25,000 rows of clicks, impressions, position, CTR by query, page, date, device, country1 unit / requestThe core dashboard data; what queries drive traffic, which pages convert impressions to clicks
sitemaps.listAll submitted sitemaps with status, last-fetch time, URL counts1 unit / requestSitemap-health alerts; flagging dropped or partially-indexed sitemaps
urlInspection.index.inspectPer-URL: coverage state, last-crawl, canonical, mobile usability, AMP and structured-data verdicts2,000 / day (separate quota)Spot-checks on critical pages; automated indexing audits
sites.listAll properties the auth identity can read1 unit / requestMulti-property dashboards; iterating over a portfolio

The 1,200/min quota on the read endpoints is effectively unlimited for solo-founder use. The 2,000/day cap on URL Inspection is the only real ceiling, still enough for a daily audit of a 1,500-URL site.

A Real Python Pull: Top Queries and the Query-by-Page Matrix

The single most useful query I run is a query-by-page breakdown over the last 28 days. It tells you which page ranks for which query, and what CTR looks like at the intersection. High impressions, low CTR are your near-term targets: showing up, not getting clicked.

Install the dependencies:

pip install google-auth google-api-python-client

The minimum viable script: auth, query, print. Save your JSON key as gsc-credentials.json beside it:

from datetime import date, timedelta
from google.oauth2 import service_account
from googleapiclient.discovery import build

SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
SITE_URL = "sc-domain:example.com"  # or "https://example.com/"
KEY_FILE = "gsc-credentials.json"

creds = service_account.Credentials.from_service_account_file(
    KEY_FILE, scopes=SCOPES
)
service = build("searchconsole", "v1", credentials=creds)

end = date.today() - timedelta(days=2)  # GSC lags ~2 days
start = end - timedelta(days=27)

request = {
    "startDate": start.isoformat(),
    "endDate": end.isoformat(),
    "dimensions": ["query"],
    "rowLimit": 25,
    "orderBy": [{"field": "clicks", "descending": True}],
}
response = service.searchanalytics().query(
    siteUrl=SITE_URL, body=request
).execute()

for row in response.get("rows", []):
    q = row["keys"][0]
    print(f"{row['clicks']:>5}  {row['impressions']:>6}  "
          f"{row['ctr']*100:>5.1f}%  pos={row['position']:>5.1f}  {q}")

That's the entire integration. Run it and you'll see your top 25 queries over the last 28 days. Two notes. sc-domain:example.com is for domain properties, the type I'd recommend; use the full URL form only for URL-prefix properties. And GSC data lags about two days, which is why we end at today - 2. Query today and you get an empty response that fooled me into thinking the whole thing was broken.

The query-by-page matrix, the version that changes what I do on a given week, adds a second dimension and a higher row limit:

request = {
    "startDate": start.isoformat(),
    "endDate": end.isoformat(),
    "dimensions": ["query", "page"],
    "rowLimit": 5000,
    "orderBy": [{"field": "impressions", "descending": True}],
}
response = service.searchanalytics().query(
    siteUrl=SITE_URL, body=request
).execute()

opportunities = []
for row in response.get("rows", []):
    query, page = row["keys"]
    impressions = row["impressions"]
    ctr = row["ctr"]
    position = row["position"]
    # Cells with >500 impressions and CTR below 2% are CTR-leak candidates
    if impressions > 500 and ctr < 0.02 and position < 15:
        opportunities.append((impressions, query, page, position, ctr))

opportunities.sort(reverse=True)
for imp, q, p, pos, ctr in opportunities[:20]:
    print(f"{imp:>6} imp  pos={pos:>4.1f}  ctr={ctr*100:>4.1f}%  {q}  →  {p}")

Watch the dimension order here, because it ate an afternoon of mine. The keys in each row come back in the order you list them under dimensions, with no labels. I once had ["page", "query"] in the request but unpacked it as query, page = row["keys"], so every row in my "matrix" paired a URL with the wrong search term. The output looked plausible: real queries, real pages, sane CTRs. I shipped a title rewrite off it before noticing the page didn't even rank for the query I'd matched it to. Nothing errored. The API hands you a confidently mislabelled table, and the only tell is that the recommendations stop making sense.

The filter at the bottom (high impressions, low CTR, top-15 position) is the classic title-tag-rewrite candidate list. You're already ranking; the click-through is the leak. This is the same thing paid tools surface as "ranking opportunities", in six lines. The first time I ran it correctly against SEOJuice's own data it surfaced three pages I'd never have touched. Rewriting two of those titles moved real clicks. The third did nothing, and I still don't know why.

Sample output of the query-by-page matrix showing impressions, CTR, position, query and target URL columns
Query-by-page matrix output. Each row is a query × URL intersection; high-impression, low-CTR rows are the title-rewrite candidates.

What to Chart: The Four Visualizations Worth the Effort

Once you have the rows, the question is what to chart. Paid tools drown you in forty widgets; I only ever made weekly decisions off four.

  1. Daily clicks plus impressions line chart, 90-day window. The one I'd keep if I could keep only one. It tells you whether traffic is growing, flat, or decaying. Divergence (impressions up, clicks flat) is the AI Overview signature.
  2. Top 20 queries bar chart, position color-coded. Sorted by clicks descending. Green for positions 1–5, yellow for 6–10, red for 11+. Shows which queries you're winning and which deserve a refresh.
  3. Position decay scatter plot. X-axis: position four weeks ago. Y-axis: position today. The diagonal is "no change." Points above it dropped; below it improved. The red-dot cluster in the top-right is your decay watchlist; the content refresh strategy piece covers the workflow.
  4. AI Overview impact estimate. Plot impressions and clicks as two lines, normalized to overlap on day one. When they diverge (impressions holding, clicks dropping), the gap is, very roughly, AI Overview cannibalization. A smoke alarm, not a precision instrument.
Mockup of the four core GSC charts: daily clicks line, top queries bar, position decay scatter, AI Overview impact divergence
The four charts. Most paid dashboards show 40+ widgets; for a solo founder, these four covered the decisions I actually made.

Automation Patterns: Cron, Slack, Sheets, Django

Pulling manually is fine for a one-off. The dashboard becomes useful when it runs itself. Three patterns I've shipped, ranked by setup effort:

Pattern 1: Cron and Slack alerts. The cheapest and, honestly, the one I kept. A daily cron runs the script. If any of three conditions trigger (clicks dropped more than 20% week-over-week, a top-10 query fell out of the top 20, or a previously-indexed page lost indexing) it posts to Slack. Mine is under a hundred lines including the webhook, and runs in seconds on a cheap VPS. The dashboard does exactly one thing: shout at me when something changes. That was the only widget I needed.

import json, os, urllib.request

def post_slack(text):
    payload = {"text": text}
    req = urllib.request.Request(
        os.environ["SLACK_WEBHOOK_URL"],
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
    )
    urllib.request.urlopen(req, timeout=10).read()

# After computing wow_change from two consecutive 7-day GSC pulls:
if wow_change < -0.20:
    post_slack(
        f":warning: GSC clicks dropped {wow_change*100:.0f}% WoW "
        f"({last_week} → {this_week}). Top falling queries: {falling[:3]}"
    )

Pattern 2: Google Sheets sink. Pipe the rows into a Google Sheet via the Sheets API or the simpler gspread wrapper. The sheet becomes your dashboard: pivot tables, native charts, shareable with a non-technical co-founder. Maybe thirty lines on top of the GSC pull. The downside: refresh latency tracks your cron cadence, and Sheets drags past 20,000 rows.

Pattern 3: Lightweight Django view. One view that runs the GSC pull, caches in Redis for six hours, and renders the four charts inline via Chart.js. A few hundred lines, depending on how fancy the charts get. Worth it once a co-founder wants to glance mid-week. The cache is the part you can't skip. Without it, every pageview fires a fresh GSC call, and I once burned through the per-minute quota on a launch day because I'd left caching off "just for testing."

DIY vs SEMrush vs SEOJuice vs Ahrefs vs the Free GSC UI

The comparison most founders actually want isn't between two paid tools. It's between paying for one, building your own, or living with the free GSC UI. Here's how the five stacked up when I worked through them:

OptionMonthly costSetup timeMaintenance burdenMulti-propertyHistorical data
Free GSC UI$00 hrs (already there)NoneManual switching16 months, but slow exports
DIY GSC API + cron + Sheets$0 to ~$5 (VPS)4 to 8 hrs first time~30 min/quarter (auth rotations, occasional API change)Trivial loop16 months, queryable in seconds
SEOJuice$29 to $99~10 minNone (managed)Built-in16 months from GSC plus own crawl history
SEMrush~$140 to ~$500~30 minNone (managed)Project limits per plan2+ years on paid plans
Ahrefs$129 to $449~30 minNone (managed)Project limits per plan2+ years on paid plans

DIY wins on cost, ties on data depth inside your own property, and loses on competitor and backlink intel. If you're early-stage and your question is "what's working on my own site?", DIY is the right answer. If you're scaling or chasing backlinks, the paid tools earn their cost. SEOJuice's tools page sits in the middle.

What AI Overviews Get Wrong About GSC Data

Ask ChatGPT or Gemini how to interpret GSC data and you'll get plausible answers that are wrong in three specific ways. These errors propagate into every "AI-generated SEO report" tool on the market, and I'd internalized one myself before the dimension-order bug forced me to read the docs.

Wrong claim #1: "Position is the average rank you held for that query." It's the average of the highest position any of your URLs held, across impressions where the query triggered a result including your site. If two of your URLs ranked for the same query in one SERP, only the higher counts. This is why "position" moves when you publish a new article outranking an older one: the old URL's position doesn't change, but the query-level number does.

Wrong claim #2: "CTR is calculated per impression." CTR is clicks ÷ impressions at whatever aggregation level you queried. By date gives daily site-wide CTR; by query+page gives per-cell CTR. The numbers won't reconcile across levels, because the denominator changes.

Wrong claim #3: "If a query has impressions but no clicks, the page ranks poorly." Sometimes. But increasingly this is the signature of being cited in an AI Overview and not clicked through. Impressions held, clicks dropped is the canonical pattern. The page isn't ranking worse; the SERP changed shape. The AI Overview citations piece goes deeper on what to do about it.

Pitfalls to Avoid When You Build This Yourself

Five sharp edges that bit me, or that I watched bite first-time GSC API users:

  • The 25,000-row limit per request is hard. Past that, paginate with startRow. The google-api-python-client wrapper does not auto-paginate; loop until a response returns fewer than 25,000 rows.
  • Service-account keys don't expire, but Google revokes them if they leak. Treat the JSON file like a database password: secrets manager, never git.
  • The 2-day data lag is per-day. Today's data returns empty. Two days ago returns near-final numbers; yesterday is partial.
  • Domain (sc-domain:) and URL-prefix (https://...) properties report different numbers. Domain properties aggregate across all subdomains and protocols. If you have both registered, the data overlaps but isn't identical; pick one as your source of truth.
  • Low-volume queries get rolled into an (anonymized) bucket you can't recover. Google suppresses queries issued by only a handful of users, for privacy. How much of your tail this eats depends on traffic profile: negligible for a high-traffic site, but for a niche product I've seen the anonymized bucket end up as the single largest "query" in the report. Plan your top-queries work around the named ones.

None of these are showstoppers, but each cost me an hour. The official query reference documents them all, just not loudly.

FAQ

How long does the GSC API keep historical data? 16 months. Past that, the data is gone; there's no archive endpoint. For longer-range trends, snapshot the rolling window into your own storage.

Can I use the API to submit URLs for indexing? No. URL submission was removed in 2020 after spam abuse. The Indexing API still exists but only works for job postings and livestream events; using it for regular content is against Google's policy.

What's the difference between impressions and clicks? Impressions count every time your URL was shown on a SERP. Clicks count user clicks. CTR is the ratio. The AI Overview era widens the gap because users read the Overview answer and don't click the cited sources.

Is there a free tier above the standard quota? Quota is the same for everyone: 1,200 requests/min per project on analytics endpoints, 2,000/day on URL Inspection. No paid upgrade path; bumps are requestable through the Cloud Console but rarely needed.

Can I delegate access without sharing my Google account? Yes, that's what the service account is for. Create one per integration, grant Restricted access to the property, and revoke it later without touching your own login.

What language bindings does the API support? Officially: Python, Node, Java, PHP, Ruby, Go, .NET. Unofficially: anything that can hit a REST endpoint with a Bearer token. The JSON shapes are identical across languages.

Side-by-side comparison of free GSC UI, DIY GSC API dashboard, SEOJuice, SEMrush, and Ahrefs across cost, setup, maintenance
The five options for a founder's SEO dashboard, across cost and setup time. The DIY route is free in dollars but eats engineering hours.

When DIY Stops Being Worth It

The DIY dashboard pays for itself as long as the data you need lives inside your own property. The moment the question becomes "why is my competitor outranking me?" or "who links to them but not to me?", you've hit the ceiling of what GSC exposes.

In my experience the cutoff sits between $500/mo and $5K/mo MRR, though that's a judgment call. Below the lower end, build it yourself; engineering time is cheaper than the subscription. Above the upper end, your time is too expensive for auth rotations and quota retries. In between, it's a coin flip.

The path I'd suggest: build the cron plus Slack version this weekend, run it for a month, and watch what you check. If the only alert you read is "top queries dropping," you've learned you needed one widget, not forty. The affordable SEO strategies piece covers the budget-tier playbook. And if you'd rather skip the weekend, SEOJuice connects to your Search Console in about ten minutes and runs these same pulls for you, dimension order and all.

<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [ { "@type": "Question", "name": "How long does the GSC API keep historical data?", "acceptedAnswer": { "@type": "Answer", "text": "16 months. Past that, the data is gone; there is no archive endpoint. If you want longer-range trend data, run a daily cron that snapshots the rolling window into your own storage." } }, { "@type": "Question", "name": "Can I use the GSC API to submit URLs for indexing?", "acceptedAnswer": { "@type": "Answer", "text": "No. URL submission was removed in 2020 after spam abuse. The Indexing API exists but only supports job postings and livestream events; using it for regular content is against Google policy." } }, { "@type": "Question", "name": "What is the difference between impressions and clicks in GSC?", "acceptedAnswer": { "@type": "Answer", "text": "Impressions count every time a URL was shown on a SERP. Clicks count user clicks on the URL. CTR is the ratio. In the AI Overview era the gap is widening because users see the Overview answer and do not click through." } }, { "@type": "Question", "name": "Is there a free tier above the standard GSC API quota?", "acceptedAnswer": { "@type": "Answer", "text": "The quota is the same for everyone: 1,200 requests/min per project on the analytics endpoints, 2,000/day on URL Inspection. There is no paid upgrade path; quota increases can be requested via the Google Cloud Console but are rarely needed for solo-founder workloads." } }, { "@type": "Question", "name": "Can I delegate GSC access without sharing my Google account?", "acceptedAnswer": { "@type": "Answer", "text": "Yes. That is what service accounts are for. Create one per integration, grant it Restricted access to the property, and revoke its access without touching your own login." } }, { "@type": "Question", "name": "What language bindings does the GSC API support?", "acceptedAnswer": { "@type": "Answer", "text": "Officially: Python, Node, Java, PHP, Ruby, Go, .NET. Unofficially: anything that can hit a REST endpoint with a Bearer token. JSON request shapes are identical across languages." } } ] } </script>