AnswerLine Start free

, Rank Tracking · SERP API · Google

Build a Google rank tracker: positions, depth, storage

A rank tracker records, for each keyword, market 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.

The rank tracking API page covers the job end to end; this post is the tracker itself. 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.

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 Price per observation Good for
About top 10 1 $0.00103 Money keywords, daily
About top 30 3 $0.00245 Most keyword sets
About top 50 5 $0.00387 Content you are actively pushing up
About top 100 10 $0.00742 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 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. The targeting is part of the observation’s identity, and changing it starts a new series.

For 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",
    "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", "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",
    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);

This costs $0.00103 + $0.00071 × 2 for the extra pages = $0.00245.

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,
  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, 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.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;

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; 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,
      pages: o.pages,
    },
    idempotencyKey: `rank:${o.keywordId}:${o.country}:${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 values 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

From the current Starter-tier prices: a Google Search task costs $0.00103, each page beyond the first adds $0.00071, and include.aioverview adds $0.00071 once when Google shows an overview; include.paaAioverview is free.

Setup Observations Price each Total
1,000 keywords, 1 market, pages: 1, daily 1,000 per day $0.00103 $30.90 per 30 days
1,000 keywords × 2 markets, pages: 3, weekly 2,000 per week $0.00245 $4.90 per week
200 keywords × 5 markets, pages: 1, with AI Overview, weekly 1,000 per week $0.00174 $1.74 per week
1,000 keywords, pages: 10, monthly 1,000 per month $0.00742 $7.42 per month

These are upper bounds: failed tasks are not charged, and page charges follow the pages returned. Validate matching and storage on a small keyword set before sizing a budget against the tiers on the pricing page; the 100 free requests new accounts get are enough for that first test. 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 $0.00071, so a 10-page Google Search request costs up to $0.00742, and you are charged only for pages returned.

Can I track rankings for a specific city?

Send location, a Google canonical name such as Austin,Texas,United States, or a uule, together with country or gl.

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.

How much does daily rank tracking cost?

At the Starter tier, a request costs $0.00103 for one page, plus $0.00071 per extra page and $0.00071 if you request the AI Overview and Google shows one. 1,000 keywords at one page per day is $1.03 per day.

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

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading