Google offers no documented public endpoint that gives the live organic position of any domain for any keyword. You can still build a useful reporting workflow. Below are a working Search Console example, real results from PolymathDesk, a first-page competitor lookup and a cost comparison with the assumptions stated.

Choose the source for your question

  • Your own search performance: use Search Console API for clicks, impressions and average position on a property your account can access. The result describes searches that happened during a period.
  • A competitor or an arbitrary keyword: use a third-party SERP API to observe results for a specified location, language and device. Your application must match the domain or URL and store the observation.
  • An existing list of tracked keywords: check the API of your rank tracker before building another collector. It may already have the history, scheduled checks and keyword settings you need.

For the first route, run the GSC example. For an outside view, use the competitor lookup. If cost is the deciding factor, compare the same workload before selecting a plan.

Custom Search JSON API is a separate search product. Google says it is closed to new customers and existing customers must move to an alternative by January 1, 2027. It searches a Programmable Search Engine rather than providing a general rank-checking feed. See the current Google overview before maintaining an older integration.

Get your own keyword performance with Search Console

Use an account with permission to the property. A normal API key does not authorize access to its performance data. The example uses desktop OAuth and the read-only scope documented in Google’s authorization guide.

  1. Enable Search Console API in a Google Cloud project, configure the consent screen and create an OAuth client of type Desktop app. If the app is in testing, add your Google account as a test user.
  2. Download the client JSON as oauth-client.json in your private working folder. Google’s desktop OAuth guide describes the browser and loopback authorization flow.
  3. Install the packages below. Save the script as gsc-example.py, set SITE to the exact property identifier returned by your account, and replace PAGE, dates, country and device with the segment you want.
  4. Run python gsc-example.py and sign in when the browser opens. A Domain property uses sc-domain:example.com; a URL-prefix property uses its exact URL, including the trailing slash. The PolymathDesk values shown here require our account permissions.
python -m pip install google-auth google-auth-oauthlib requests
Open the complete Search Console Python example
import json
from urllib.parse import quote
from google.auth.transport.requests import AuthorizedSession
from google_auth_oauthlib.flow import InstalledAppFlow

SITE = 'sc-domain:polymathdesk.com'
PAGE = ('https://polymathdesk.com/seo/'
        'google-ranking-api-in-2026-why-rank-tracking-is-harder-than-it-looks/')
BODY = {
    'startDate': '2026-08-22', 'endDate': '2026-09-18',
    'dimensions': ['query'], 'type': 'web', 'dataState': 'final',
    'aggregationType': 'auto', 'rowLimit': 25000, 'startRow': 0,
    'dimensionFilterGroups': [{'groupType': 'and', 'filters': [
        {'dimension': 'page', 'operator': 'equals', 'expression': PAGE},
        {'dimension': 'country', 'operator': 'equals', 'expression': 'usa'},
        {'dimension': 'device', 'operator': 'equals', 'expression': 'DESKTOP'}
    ]}]
}

def fetch_rows(credentials, site=SITE, body=None):
    body = BODY if body is None else body
    endpoint = ('https://www.googleapis.com/webmasters/v3/sites/'
                + quote(site, safe='') + '/searchAnalytics/query')
    with AuthorizedSession(credentials) as session:
        response = session.post(endpoint, json=body, timeout=45)
        response.raise_for_status()
        return response.json()

if __name__ == '__main__':
    flow = InstalledAppFlow.from_client_secrets_file(
        'oauth-client.json',
        ['https://www.googleapis.com/auth/webmasters.readonly'],
        autogenerate_code_verifier=True)
    credentials = flow.run_local_server(host='127.0.0.1', port=0,
                                        timeout_seconds=180)
    data = fetch_rows(credentials)
    rows = sorted(data.get('rows', []),
                  key=lambda row: row['impressions'], reverse=True)
    for row in rows:
        print(f"{row['keys'][0]} | {row['impressions']:.0f} impressions | "
              f"{row['clicks']:.0f} clicks | avg {row['position']:.2f}")
    if not rows:
        print('No returned performance rows for this segment.')

The script opens a temporary local authorization listener, then sends an authenticated request. It keeps credentials in memory for this run and prints only performance rows. For scheduled use, add protected token storage and refresh handling rather than putting credentials in a public page, source repository or report.

This request groups one page’s finalized Web Search data by query and holds country and device constant. Dates are inclusive and use Pacific Time. aggregationType: auto allows page aggregation for the page filter. The request asks for up to 25,000 rows; larger exports need startRow pagination, but Google does not guarantee every underlying row.

What our PolymathDesk request actually returned

On October 18, 2026, we executed the request in this example against sc-domain:polymathdesk.com. It covers this article’s canonical URL from August 22 through September 18, 2026, Web Search, United States, desktop and finalized data. The API returned 15 query rows. These four selected rows illustrate the result; positions are rounded to two decimals.

google rank tracker api
57 impressions · 0 clicks · average position 91.88

google keyword ranking api
40 impressions · 0 clicks · average position 71.95

google rank tracking api
39 impressions · 0 clicks · average position 88.92

google ranking api
26 impressions · 0 clicks · average position 56.50

For google ranking api, this is the actual row from the response, rather than a synthetic demonstration:

{
  "keys": [
    "google ranking api"
  ],
  "clicks": 0,
  "impressions": 26,
  "ctr": 0,
  "position": 56.5
}

We then repeated the request for that exact query, page and period while removing the country and device filters. It returned 100 impressions and average position 51.69, compared with 26 impressions and 56.50 for USA desktop. The 4.81-position difference comes from changing the population being summarized. It is not a before-and-after ranking loss.

The practical decision is to keep a consistent segment when investigating a change. These rows show historical exposure and no clicks in the chosen segment. They do not tell us our live position today, identify the competitors above us or explain why Google selected a result. For a traffic review, keep clicks and impressions beside position; a position figure alone leaves the outcome unanswered.

A row can be absent because the segment has no returned performance data. Check dates, property, exact query, canonical URL and privacy limits before treating absence as a loss. Our guide to query rows and chart totals explains why the disclosed queries need not reconstruct the full total.

Find a competitor in a defined search result

A SERP provider observes a different event: a search conducted under stated conditions. The example below uses SerpApi’s Google Search endpoint and its documented organic result fields. It checks the first results page for project management software in Austin, on desktop, in English, and looks for asana.com. Replace the keyword, bare target domain and conditions with your own.

Install requests, store the provider key in an environment variable named SERPAPI_KEY, save the script as serp-example.py and run python serp-example.py. It requests a fresh response with no_cache=true, which can incur a paid search. An environment variable keeps the key out of the example’s source and printed result.

Open the first-page competitor lookup
import json
import os
from datetime import datetime, timezone
from urllib.parse import urlsplit
import requests

def find_domain(data, target_domain):
    domain = target_domain.lower().rstrip('.')
    if data.get('error') or data.get('search_metadata', {}).get('status') != 'Success':
        return {'state': 'unknown', 'reason': 'provider_error'}
    if 'organic_results' not in data:
        return {'state': 'unknown', 'reason': 'missing_organic_results'}
    results = data['organic_results']
    for item in results:
        host = (urlsplit(item.get('link', '')).hostname or '').lower().rstrip('.')
        if host == domain or host.endswith('.' + domain):
            return {'state': 'found', 'page_position': item['position'],
                    'url': item['link'], 'returned_organic_results': len(results)}
    return {'state': 'not_found_in_response',
            'returned_organic_results': len(results)}

def check_domain(keyword, target_domain, api_key):
    conditions = {'engine': 'google', 'q': keyword, 'google_domain': 'google.com',
                  'location': 'Austin, Texas, United States',
                  'gl': 'us', 'hl': 'en', 'device': 'desktop',
                  'start': 0, 'no_cache': 'true'}
    try:
        response = requests.get('https://serpapi.com/search.json',
                                params={**conditions, 'api_key': api_key}, timeout=45)
        if response.status_code != 200:
            result = {'state': 'unknown', 'reason': f'HTTP {response.status_code}'}
        else:
            result = find_domain(response.json(), target_domain)
    except (requests.RequestException, ValueError):
        result = {'state': 'unknown', 'reason': 'network_or_json_error'}
    return {**result, 'checked_at_utc': datetime.now(timezone.utc).isoformat(),
            'conditions': conditions}

if __name__ == '__main__':
    print(json.dumps(check_domain('project management software', 'asana.com',
                                 os.environ['SERPAPI_KEY']), indent=2))

The hostname test accepts the named domain and its subdomains. It rejects lookalikes such as notexample.com when the target is example.com. If you need one particular page, compare the intended canonical URL instead of treating every page on a domain as equivalent.

  • found includes the provider’s organic position on the first page and the matching URL.
  • not_found_in_response records how many organic results were returned. It does not mean “outside the top 100.” This script checks one page and does not invent a deeper rank.
  • unknown covers HTTP, network, JSON, provider-status or missing-payload problems. Retain the previous successful observation instead of writing a ranking loss.

To search further, follow documented pagination and preserve each page’s offset and observed results. Budget the extra requests. For vendors that expose both organic-group and all-element positions, keep those fields separate: DataForSEO, for example, documents rank_group and rank_absolute as different counts. Neither is a measure of pixel distance down the screen.

Testing note: we executed the GSC request against our authorized property. The competitor example was checked against documented response fields and offline fixtures for domain matching, absence and errors. We did not run a paid live-provider benchmark or measure vendor accuracy or latency.

Use the history you already pay to collect

An established rank tracker can be the shorter route when your team already has projects, keywords and historical observations. Its API may let you retrieve those stored checks without rebuilding schedules, location settings and persistence. Confirm that API access is included in your plan and distinguish the time of the underlying check from the time you downloaded it.

Before choosing this route, verify the keyword identifier, target URL, market, language, device, check timestamp, checked depth, result features and how much history the endpoint actually exposes. An endpoint returning a stored daily position is useful for a report, but it does not become an on-demand fresh check just because you call it again.

For a new collector, store a record for each keyword, location and device alongside the source, timestamp, returned depth, match mode and success state. Make reruns idempotent so a retry updates the same observation instead of creating an apparent second measurement. Preserve query-level Search Console metrics in a separate series from SERP observations.

Compare tracking costs on the same workload

Consider 100 keywords checked daily across two devices and two markets for 30 days. That is 12,000 observations. The comparison below assumes one billable first-page search per observation, no extra pagination, no cache savings and no add-ons. It is our calculation from public prices checked October 18, 2026, rather than a measured equivalence of vendor coverage.

  • Search Console: Google charges no API usage fee. It provides your own performance data, so it cannot replace those 12,000 competitor observations. Hosting and development remain your costs.
  • Serper Starter: the published package is $50 for 50,000 credits, with credits valid for six months and a listed rate of $1 per 1,000 queries. At one credit per observation, 12,000 queries consume $12 of that package. The upfront purchase is $50; $12 is allocated consumption, not the amount you can necessarily pay to start.
  • SerpApi Production: the published plan is $150 per month for 15,000 searches. The assumed 12,000 fresh first-page searches fit the allowance. That is 80% of its quota; unused allowance does not reduce the monthly bill.

Taxes, plan conditions and prices can change. Check the live pricing page before buying, then test whether the requested device, geography, result features and depth meet your use case. The lowest consumed-credit estimate is not automatically the lowest initial cash outlay or the best data source.

Depth can change the bill more than keyword count. If a provider bills each additional result page as another search, ten pages for each of the 12,000 observations would require 120,000 searches. That is a conditional workload calculation, not a claim that every provider exposes exactly ten organic results or uses the same pagination pricing. For a daily report, retrieve each saved result once instead of rechecking whenever someone opens the dashboard.

Read missing results before changing the website

Keep three states distinct in the report. An empty GSC segment contains no returned historical performance row. A successful SERP response without the domain establishes absence only within that response. A failed collection establishes no position at all. None of those observations, by itself, proves that a URL is excluded from Google’s index.

Use URL Inspection when the question is indexing. Check crawl state, indexing directives and Google’s selected canonical alongside the status. If an initial response contains an exclusion directive, our analysis of noindex and rendered HTML shows why checking only the browser’s final DOM can mislead. This is a separate diagnosis from position reporting.

AI citations and linked visibility introduce further reporting questions. Keep that work separate from the ordinary organic-position field; our analysis of AI visibility and citations discusses that distinction. A rank collector should record only the result features its source actually returns.

Google’s machine-generated traffic policy also matters when evaluating collection methods. A commercial API subscription is not proof that the underlying automated access has Google’s permission. Review the provider’s method and applicable terms as part of the choice.

For your first implementation, keep the keyword set small enough to inspect the underlying observations. The useful deliverable is a report whose reader can identify the source, segment, collection time and missing-data state, then decide whether the next action belongs in the website, the reporting code or the collector.