AnswerLine Start free

, SERP API · Google · Rank Tracking

Track SERP feature changes alongside rankings

A keyword can hold position 3 for months while the page around it changes completely: an AI Overview appears above the results, the local pack vanishes, a row of shopping cards pushes organic listings down, ads move from the bottom block to the top. A position-only tracker reports no change through all of it.

SERP feature tracking records which non-organic blocks a results page shows, per keyword, market and day, and treats changes in that set as events. Below: the features the Google Search endpoint returns, reducing a response to a compact snapshot, diffing snapshots without alerting on noise, and which transitions deserve action.

What the response exposes

POST /v1/monitor/google (or a GOOGLE task) returns each block as its own field under result. The fields and how absence is represented:

Feature Field When absent Useful to snapshot
Organic results organicResults[] Empty or missing Your position, whether your result has sitelinks
AI Overview aioverview null if requested but not available; missing if not requested Present, cited domains from sources[], ads inside it
People Also Ask peopleAlsoAsk[] Empty or missing Count, count of type: "AIOVERVIEW" items, your domain in link
Local pack localResults[] Field omitted Present, count, your business’s position
Ads ads[] Empty or missing Count per blockPosition (top, bottom, middle, rhs), count of type: "SHOPPING_CARD"
Shopping grid shoppingCards[] Field omitted Present, count, category values
Knowledge panel knowledgeGraph Field omitted Present, title, kgmid
Discussions module peopleAreSaying[] Field omitted Present, domains of link
Related searches relatedSearches[] Empty or missing Count, query values

One of these needs care: aioverview appears only when you send include.aioverview (for example {} or {"markdown": true}), and costs $0.00071 extra when Google shows one. The Google Search engine page and API reference list every nested field.

Step 1: reduce a response to a snapshot

A snapshot keeps the features as small, comparable values. Store the raw response too, but diff the snapshot:

import os
from urllib.parse import urlsplit
import requests

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

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

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

def snapshot(result: dict, domain: str) -> dict:
    organic = result.get("organicResults") or []
    mine = next((r for r in organic if on(r.get("link"), domain)), None)
    aio = result.get("aioverview")
    ads = result.get("ads") or []
    paa = result.get("peopleAlsoAsk") or []
    return {
        "organic_rank": mine["position"] if mine else None,
        "organic_sitelinks": bool(mine and mine.get("sitelinks")),
        "aio": aio is not None,
        "aio_cites_us": bool(aio) and any(on(s.get("url"), domain) for s in aio.get("sources") or []),
        "paa": len(paa),
        "paa_aio": sum(1 for q in paa if q.get("type") == "AIOVERVIEW"),
        "local_pack": "localResults" in result,
        "local_rank": next((p["position"] for p in result.get("localResults") or []
                            if on((p.get("links") or {}).get("website"), domain)), None),
        "ads_top": sum(1 for a in ads if a.get("blockPosition") == "top"),
        "ads_shopping": sum(1 for a in ads if a.get("type") == "SHOPPING_CARD"),
        "shopping_grid": "shoppingCards" in result,
        "knowledge_panel": "knowledgeGraph" in result,
        "discussions": "peopleAreSaying" in result,
    }

res = requests.post(
    f"{API}/v1/monitor/google",
    json={"query": "emergency plumber austin", "country": "US", "include": {"aioverview": {}}},
    headers=HEADERS,
    timeout=(10, 330),
)
res.raise_for_status()
print(snapshot(res.json()["result"], "example-plumbing.com"))

Matching your local listing by links.website works on service-style packs, where Google renders action links; restaurant-style packs often have no links, so match by title or placeId there. Counts beat booleans where the size of a block matters, as with ads and PAA; booleans beat counts where only presence changes the page, as with a knowledge panel.

Step 2: diff snapshots into events

BOOLEAN = ["aio", "aio_cites_us", "local_pack", "shopping_grid", "knowledge_panel", "discussions", "organic_sitelinks"]
COUNTS = ["paa", "paa_aio", "ads_top", "ads_shopping"]
RANKS = ["organic_rank", "local_rank"]

def events(prev: dict, curr: dict) -> list[tuple]:
    out = []
    for f in BOOLEAN:
        if prev[f] != curr[f]:
            out.append((f, "appeared" if curr[f] else "disappeared"))
    for f in COUNTS:
        if (prev[f] == 0) != (curr[f] == 0):
            out.append((f, "appeared" if curr[f] else "disappeared"))
    for f in RANKS:
        if (prev[f] is None) != (curr[f] is None):
            out.append((f, "entered" if curr[f] is not None else "lost"))
    return out

def confirmed(history: list[dict]) -> list[tuple]:
    """Changes first seen in the previous run that still hold in the latest run."""
    if len(history) < 3:
        return []
    before, first, second = history[-3], history[-2], history[-1]
    return [e for e in events(before, first) if e in events(before, second)]

confirmed only reports a change that held for two consecutive runs. Google pages vary between fetches, and an AI Overview in particular can be present on one run and not the next, so a single-run flip is a poor reason to page anyone. Tune the count to your cadence: with weekly runs, two weeks may be too slow for money keywords, so run those daily. AI answer volatility covers the same problem for assistants.

Step 3: read the transitions

The response tells you what changed; the table below is a starting checklist for what it means. Confirm impact with your own click data.

Transition What it tells you What to check
aio appeared A generated answer now sits above the organic results Whether aio_cites_us is true; which domains it cites instead; see monitoring AI Overviews
aio_cites_us lost, rank unchanged Organic rank held but the overview stopped citing you The cited pages that replaced you, and what they answer that yours does not
local_pack appeared Google reads the query as local Whether a location page or business profile can compete; a national page cannot enter the pack
paa appeared or paa_aio grew New questions attached to the query Question targets for content, as in PAA keyword research
ads_top appeared Advertisers now bid above the organic block Click-through for the keyword in your own analytics; whether to bid
ads_shopping or shopping_grid appeared Commercial product units on the page Product feed and merchant listings as well as the landing page
knowledge_panel appeared Google resolved the query to an entity Whether the entity is yours, via knowledgeGraph.title and website
discussions appeared Forum and community threads are featured Which threads, via peopleAreSaying[].link; see why AI engines cite Reddit

Alert on transitions, not on values. A report that says “12 of your 40 money keywords gained an AI Overview this month, and it cites you on 3” tells you what to fix; a chart of PAA counts per day rarely does.

Step 4: run it as a batch

Submit the matrix of keywords × markets as GOOGLE tasks in batches of up to 500, with keys derived from what each task means:

import { createHash } from "node:crypto";
import { Client, MAX_BATCH, type TaskInput } from "@answerline/sdk";

const client = new Client({ apiKey: process.env.ANSWERLINE_API_KEY! });
const h = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 16);

export async function submitSnapshots(keywords: string[], countries: string[], day: string, webhookUrl: string) {
  const tasks: TaskInput[] = keywords.flatMap((query) =>
    countries.map((country) => ({
      taskType: "GOOGLE",
      payload: { query, country, include: { aioverview: {} } },
      idempotencyKey: `serp:${h(query)}:${country}:${day}`,
      webhook: { url: webhookUrl },
    })),
  );
  for (let i = 0; i < tasks.length; i += MAX_BATCH) {
    const { summary, results } = await client.createBatch(tasks.slice(i, i + MAX_BATCH));
    const refused = results.filter((r) => !r.success && r.error.code !== "RESOURCE_ALREADY_EXISTS");
    if (refused.length) console.warn(summary, refused);
  }
}

Collect the results by signed webhook and reconcile missed deliveries by polling. The rank-tracking pipeline implements the receiver and reconciliation pass, and tracking AI Overviews with async batches shows the same pattern for Google tasks. If you track organic rank already, add the snapshot to the same task: the response that carries organicResults carries every feature above, so there is no second request.

Storage

One table of snapshots is enough for most reporting:

create table serp_snapshots (
  keyword_id text not null,
  country text not null,
  location text not null default '',
  day date not null,
  task_id text not null unique,
  snapshot jsonb not null,
  primary key (keyword_id, country, location, day)
);

The composite key mirrors the idempotency key, so a duplicate webhook delivery or a re-run cannot write a second row for the same observation. Keep the raw response in object storage keyed by task_id if you want to add snapshot fields later without paying for the pages again.

Cost

At the Starter tier a GOOGLE task costs $0.00103, include.aioverview adds $0.00071, and each results page after the first adds $0.00071.

Setup Tasks Price per task Cost
500 keywords × 2 markets, weekly, with AI Overview 1,000 per week $0.00174 $1.74 per week
Same without AI Overview 1,000 per week $0.00103 $1.03 per week
50 money keywords × 2 markets, daily, with AI Overview 100 per day $0.00174 $5.22 per 30 days
500 keywords, 1 market, pages: 3, with AI Overview 500 $0.00103 + $0.00142 + $0.00071 = $0.00316 up to $1.58

Page charges depend on the pages returned and never exceed the reservation, and failed tasks cost nothing. Tier prices are on the pricing page.

Pitfalls

  1. Comparing across markets or locations. Runs of the same keyword in different places are different pages; the local pack alone guarantees a false “disappeared”. Keep country, and the location when you set one, in the series key.
  2. Reading an omitted field as an error. localResults, shoppingCards, knowledgeGraph and peopleAreSaying are omitted when the block is absent. Test for the key, not for an empty array.
  3. Toggling include.aioverview between runs. Without it aioverview is missing, which your diff will read as “disappeared”. Keep the request shape fixed per series.
  4. Diffing failed tasks. A FAILED task has no result. Skip it rather than snapshotting an empty page.
  5. Alerting on one run. Require confirmation.
  6. Tracking only your own rank. An overview appearing matters even when your position does not move.

For the organic side of the same data, see the rank tracking use case; for the full field shapes, the Google Search engine page.

Questions

What is SERP feature tracking?

It is recording, per keyword, market and date, which non-organic blocks a Google results page shows, such as an AI Overview, local pack, People Also Ask or ads, and alerting when that set changes. It complements position tracking, which only follows organic rank.

Which SERP features does the Google Search endpoint return as fields?

organicResults, peopleAlsoAsk, relatedSearches, ads, peopleAreSaying, localResults, shoppingCards, knowledgeGraph, and aioverview when include.aioverview is requested. Several of these are omitted from result when the block is not on the page, so treat a missing field as absent.

Can I track local packs per city?

Google Search requests take a country, and location or uule for a city. Record localResults per market, and when you target cities, keep the location in the series key so a change of place is never read as a change of page.

How much does a weekly feature snapshot cost?

A Google Search task costs $0.00103 on every tier, plus $0.00071 when you request the AI Overview and Google shows one. 500 keywords in 2 markets weekly with the AI Overview is 1,000 tasks at $0.00174, $1.74 per week.

How do I avoid alerting on noise?

Alert on a transition only after the new state holds for two consecutive runs, compare runs with the same market and request shape, and keep failed tasks out of the comparison.

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