AnswerLineStart free

, Rank Tracking · SERP API · Google

Build Google rank tracking on a SERP API: positions, pages, locations and storage

A rank tracker records, for each keyword, market, device and day, where a domain’s pages appear in Google’s organic results. On this API that means one Google Search request per observation, reading organicResults[].position for the first result on your domain, and storing it with the targeting that produced it. The work is in the details: which position you mean, how deep you look, where the search is run from, and how you decide that a returned URL is yours.

Batch submission, the webhook receiver and reconciliation are covered in building a rank-tracking pipeline with async batches and not repeated here.

Definitions

Which position you are tracking

Organic position, as returned

A Google Search response lists organic listings in result.organicResults[]. Each item has position (1-indexed), title, link, displayedLink, snippet, date, page and, when Google shows them, sitelinks.inline[]. When you request several pages, organic results from all pages are listed in one array and position keeps counting across them: the first result on page 2 continues from the last position on page 1 and carries page: 2. Sitelinks sit inside their parent result and do not take positions of their own.

It is the number to compare from day to day.

Why absolute rank cannot be derived exactly

Everything else on the page comes back as its own array with its own numbering:

Block Field Numbering
Organic results organicResults[] position across all requested pages, plus page
Ads ads[] position within its block, plus blockPosition (top, bottom, middle, rhs) and page
Local pack localResults[] position within the pack (desktop only)
People Also Ask peopleAlsoAsk[] array order
AI Overview aioverview one object, when requested with include.aioverview
Shopping grid shoppingCards[] position within the grid
Discussions peopleAreSaying[] position within the module

The response does not say where the local pack sat relative to organic result 2, or whether People Also Ask came after result 3 or result 5, and it carries no pixel offsets. So a “pixel rank” or an “absolute rank including features” cannot be computed exactly from the structured fields. You can approximate: count top ads (blockPosition: "top") and, if aioverview is present, one block for it, and add them to the organic position. Label that number as an estimate in reports. If you need the real layout, request include.html and read the raw page from the URLs returned in result.html[].

Google’s own Search Console uses a third definition. Its help page says position is calculated “from top to bottom on the primary side of the page, then top to bottom on the secondary side”, that each element such as a carousel occupies a single position, and that an AI Overview “occupies a single position in search results, and all links in the AI Overview are assigned that same position” (Search Console: impressions, position and clicks, checked 2026-09-17). The same page notes that a position is recorded only when a result gets an impression, so a result on page 3 gets no Search Console position for a search where the user only viewed page 1. Expect Search Console average position and a tracker’s organic position to differ, and do not reconcile them line by line.

Track SERP features next to organic rank rather than folding them into it. SERP feature change tracking shows how to snapshot them from the same response.

Depth: the pages field

The standard request takes pages from 1 to 10 (default 1). There is no num or start field in that shape. The API treats a page as 10 results when it converts a URL’s num to pages, so pages: 10 is the equivalent of looking about 100 results deep; the exact count per page is whatever Google rendered.

Rank trackers used to fetch 100 results in one Google request with the &num=100 URL parameter. A Botify write-up dated 2025-10-15 reports that Google removed it between September 8 and 10, 2025, “with no warning and no supporting documentation” (Botify, checked 2026-09-17). The API handles depth as whole pages instead: if you send a complete Google URL in the alternative url shape, its num is read as result depth, rounded up to whole pages of 10 and capped at 10 pages. You cannot combine url with pages.

How deep to go is a cost decision:

Depth pages Async credits per observation Good for
About top 10 1 3 Money keywords, daily
About top 30 3 7 Most keyword sets
About top 50 5 11 Content you are actively pushing up
About top 100 10 21 Monthly baselines, new sites

A tiered schedule keeps cost proportional to value: every keyword weekly at pages: 3, the keywords currently ranking in the top 10 daily at pages: 1, and a monthly pages: 10 sweep to catch pages entering from far down. You are charged for pages actually returned, so a query with few results costs less than its reservation.

Where the search runs from

Rankings depend on where and how the search is made. Every targeting field below is part of the observation’s identity, and changing any of them starts a new series.

Desktop and mobile are different pages. localResults is returned for desktop searches only, so a mobile series never includes it. For city-level tracking and map-pack positions see local rank tracking; for the general use case see rank tracking.

One observation, three ways

A synchronous call is the simplest way to see the shape. Use it for spot checks and on-demand lookups, and use async tasks for scheduled runs.

curl -X POST https://api.answerline.dev/v1/monitor/google \
  -H "Authorization: Bearer $ANSWERLINE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "project management software",
    "country": "US",
    "hl": "en",
    "location": "Austin,Texas,United States",
    "device": "desktop",
    "pages": 3
  }'
import os
import requests

API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['ANSWERLINE_API_KEY']}"}

res = requests.post(
    f"{API}/v1/monitor/google",
    json={"query": "project management software", "country": "US", "hl": "en",
          "location": "Austin,Texas,United States", "device": "desktop", "pages": 3},
    headers=HEADERS,
    timeout=(10, 330),
)
res.raise_for_status()
for r in res.json()["result"]["organicResults"]:
    print(r["position"], r["page"], r["link"])
const res = await fetch("https://api.answerline.dev/v1/monitor/google", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ANSWERLINE_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query: "project management software",
    country: "US",
    hl: "en",
    location: "Austin,Texas,United States",
    device: "desktop",
    pages: 3,
  }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const { result } = await res.json();
for (const r of result.organicResults) console.log(r.position, r.page, r.link);

As a synchronous call this costs 3 + 2 × 2 + 2 = 9 credits; the same payload as an async GOOGLE task costs 7.

Matching URLs: where trackers go wrong

Deciding whether a returned link is “yours” causes more wrong ranks than anything Google does. Decide on two levels.

Domain match answers “does the site rank”. Compare hostnames, not strings:

  1. Parse link and take the hostname; lowercase it.
  2. Strip a leading www..
  3. Accept the domain itself and its subdomains (blog.example.com counts for example.com), unless you track subdomains as separate properties.
  4. Never match with in on the raw URL: example.com is a substring of notexample.com and of example.com.evil.test.

Page match answers “does this specific URL rank”. Normalize both sides before comparing: drop the fragment, drop tracking parameters such as utm_*, treat a trailing slash as insignificant, and compare scheme-less. Keep other query parameters unless you know they are cosmetic.

Other pitfalls:

from urllib.parse import urlsplit, parse_qsl, urlencode

def host(url: str) -> str:
    return (urlsplit(url or "").hostname or "").lower().removeprefix("www.")

def on_domain(url: str, domain: str) -> bool:
    h = host(url)
    return h == domain or h.endswith("." + domain)

def page_key(url: str) -> str:
    p = urlsplit(url)
    query = urlencode([(k, v) for k, v in parse_qsl(p.query) if not k.startswith("utm_")])
    path = p.path.rstrip("/") or "/"
    return f"{host(url)}{path}" + (f"?{query}" if query else "")

def ranks(result: dict, domain: str) -> list[dict]:
    return [
        {"position": r["position"], "page": r["page"], "url": r["link"], "page_key": page_key(r["link"])}
        for r in result.get("organicResults") or []
        if on_domain(r.get("link"), domain)
    ]

Storage schema

Store one row per observation and one row per ranking URL on the domains you track. Keep the raw response in object storage keyed by task id, so new metrics can be computed later without paying for the pages again.

create table keywords (
  keyword_id bigserial primary key,
  query text not null,
  unique (query)
);

create table observations (
  observation_id bigserial primary key,
  keyword_id bigint not null references keywords,
  country text not null,
  hl text not null default '',
  location text not null default '',
  device text not null,
  pages smallint not null,
  day date not null,
  task_id text not null unique,
  status text not null,               -- COMPLETED or FAILED
  organic_count smallint,             -- results returned, to spot short pages
  has_aioverview boolean,
  top_ads smallint,
  unique (keyword_id, country, hl, location, device, day)
);

create table rankings (
  observation_id bigint not null references observations,
  domain text not null,
  position smallint not null,
  page smallint not null,
  url text not null,
  page_key text not null,
  primary key (observation_id, domain, position)
);

-- best rank per domain per day
create view best_rank as
select o.keyword_id, o.country, o.location, o.device, o.day, r.domain, min(r.position) as position
from observations o
join rankings r using (observation_id)
where o.status = 'COMPLETED'
group by 1, 2, 3, 4, 5, 6;

Design notes:

Scheduling with async batches and webhooks

For anything beyond a few dozen keywords, submit GOOGLE tasks to POST /v1/async/task/batch (1 to 500 tasks per request) with an idempotencyKey derived from the observation and a webhook: {"url": ...}. Each finished task is POSTed to the webhook, signed with Webhook-Signature; GET /v1/async/task/{taskId} returns the same body for reconciliation.

type Obs = { keywordId: string; query: string; country: string; location?: string; device: string; pages: number };

export async function submitDay(obs: Obs[], day: string, webhookUrl: string) {
  const tasks = obs.map((o) => ({
    taskType: "GOOGLE",
    payload: {
      query: o.query,
      country: o.country,
      device: o.device,
      pages: o.pages,
      ...(o.location ? { location: o.location } : {}),
    },
    idempotencyKey: `rank:${o.keywordId}:${o.country}:${o.location ?? ""}:${o.device}:${day}`,
    webhook: { url: webhookUrl },
  }));
  for (let i = 0; i < tasks.length; i += 500) {
    const res = await fetch("https://api.answerline.dev/v1/async/task/batch", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.ANSWERLINE_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(tasks.slice(i, i + 500)),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
    const { results } = await res.json();
    for (const r of results) {
      if (!r.success && r.error.code !== "RESOURCE_ALREADY_EXISTS") console.warn(r.index, r.error);
    }
  }
}

Keep keys short; hash long location names if needed. The receiver, signature check, reconciliation sweep and failure table are in the async batch pipeline, and the delivery rules are on webhooks and async tasks. Run the submission at the same time each day, so time of day is held constant across the series.

Cost in credits

From the current credit prices: a Google Search task costs 3 credits, each page beyond the first adds 2, and include.aioverview (or include.paaAioverview) adds 2 once. Synchronous calls add 2 more; async tasks do not.

Setup Observations Credits each (async) Total
1,000 keywords, 1 market, desktop, pages: 1, daily 1,000 per day 3 90,000 per 30 days
1,000 keywords, desktop + mobile, pages: 3, weekly 2,000 per week 7 14,000 per week
200 keywords × 5 cities, pages: 1, with AI Overview, weekly 1,000 per week 5 5,000 per week
1,000 keywords, pages: 10, monthly 1,000 per month 21 21,000 per month

These are upper bounds: failed tasks are not charged, and page charges follow the pages returned. The free tier’s 500 monthly credits cover about 160 single-page async observations, enough to validate matching and storage before sizing a plan on the pricing page. For sizing method, see estimating your monitoring bill.

Reporting ranks without misleading anyone

Other data in the same response

The same response carries data that sits next to rank: AI Overview sources (what AI Overviews cite), People Also Ask questions (PAA for content strategy), and related searches (people also search for). For an overview of every block on the page, read what is a SERP. For the AI-assistant equivalent of rank, citation rank, see the async batch pipeline and GEO vs SEO metrics.

Field shapes for every block are on the Google Search engine page.

Questions

What does position mean in the Google Search response?

organicResults[].position is the 1-indexed rank of an organic result, counted across every page you requested, and organicResults[].page says which results page it appeared on. Ads, the local pack, People Also Ask and the AI Overview are separate arrays and are not counted in it.

How deep can I track a keyword?

Set pages from 1 to 10 in the request. Each page beyond the first adds 2 credits, so a 10-page Google Search async task costs up to 21 credits, and you are charged only for pages actually returned.

Can I track rankings for a specific city?

Yes. Pass location with a Google canonical name such as "Austin,Texas,United States", or a pre-encoded uule string, together with country or gl. location and uule are mutually exclusive.

Can I compute an absolute or pixel rank that includes ads and SERP features?

Not exactly. The response returns each block as its own array with its own numbering, and does not return pixel offsets or the order in which blocks were laid out, so an absolute rank can only be approximated. Request include.html if you need the raw page.

How much does daily rank tracking cost?

A Google Search async task costs 3 credits for one page, plus 2 per extra page and 2 if you request the AI Overview. Synchronous calls add 2 more. 1,000 keywords at one page per day is 3,000 credits per day as async tasks.

Why does my page show as not ranking when it clearly appears in Google?

Usually the URL match is too strict: a www or subdomain difference, a trailing slash, query parameters, a redirect, or a different URL from the same site ranking instead. Match on registrable domain first, then decide how strict the page match should be.

Try it on your own prompts

500 free credits a month, no card. One POST returns the answer, sources and citations as JSON.

Keep reading