AnswerLine Start free

, updated , Google · Brand Monitoring · Tutorials

How to track Google AI Overviews for your keywords

An AI Overview above the organic results changes what an organic ranking is worth, depending on whether it appears and whether it cites you. To track it, request the overview with the Google results for a fixed keyword list, per market, on a schedule, and trend three numbers: how often an overview appears, how often it cites you, and where it skips pages that already rank.

This is the how-to. For the request and response of the AI Overview endpoint compared with other vendors, see AI Overview API; for ready-made platforms, see AI Overview tracking tools. The field summary is on the AI Overview feature page.

Definitions

An AI Overview is the AI-generated summary Google shows at the top of some results pages, with links to supporting sources. Google says it shows one when “our systems determine that generative AI can be especially helpful” (AI Overviews in Google Search help, checked 2026-09-17).

Step 1: request the overview with the results

{
  "query": "project management software",
  "country": "US",
  "include": { "aioverview": { "markdown": true } }
}

Send it to POST /v1/monitor/google, or as the payload of a task with "taskType": "GOOGLE" for keyword lists.

Field Notes
query The search query. Required.
country or gl Result geography (ISO 3166-1 alpha-2). Sending both with different values is a 400. GET /v1/countries?model=aioverview lists the countries where the overview can be requested.
location or uule City-level targeting: a Google canonical City,Region,Country name, or a pre-encoded UULE. Mutually exclusive.
pages 1 to 10 result pages; each page after the first is charged. AI Overview metrics need only page 1.
include.aioverview An object; its presence requests the overview. {"markdown": true} also returns it as Markdown, useful for diffing wording.
include.paaAioverview Fills AI-Overview-type People Also Ask items with their Markdown and sources. Free, but slower.

A one-off check:

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",
    "include": { "aioverview": { "markdown": true } }
  }'

Step 2: read the response

result.aioverview is null when the results page had no AI Overview. Otherwise it holds:

Field Meaning
text The overview as plain text.
markdown The overview as Markdown, when include.aioverview.markdown is true.
links[] Each link in the overview’s text, in order, with the words it shows.
sources[] The source rail: position (1-based), url, label, description, and redirectLink when Google served the link as a redirect.
citationPills[] The inline citation chips, one entry per chip-and-source pair: citationPillId, position (into aioverview.sources), url, domain, label, optional description and redirectLink. Empty when there are no chips.
relatedLinks[] Links in a chip’s “related links” flyout that are not in the source rail. They share the chip’s citationPillId and have no position.
videos[] Videos in the overview: url, title, thumbnail, source, platform, date, duration.
ads[] Sponsored ads inside the overview: position, title, url, type, plus domain and description for text ads or price, old_price and store for shopping ads.

A chip citing several sources appears once per source with a shared citationPillId; group by it to rebuild the chip. Related links also appear inside markdown, so the Markdown can contain more URLs than sources and citationPills.

Next to it, result.organicResults lists organic results with position, title, link, displayedLink, snippet, date, page and sitelinks. With include.paaAioverview, result.peopleAlsoAsk items of type AIOVERVIEW also carry markdown and sources: smaller overviews for the follow-up questions, useful for People Also Ask research.

Wherever a redirectLink appears, the sibling url or link already holds the resolved destination. To resolve one yourself, POST /v1/monitor/google/goto with {"url": "<redirectLink>"}; it is free.

Step 3: submit the keyword list as batches

Each task is one keyword in one market on one day, and that identity is its idempotency key. Hash the keyword text so keys stay short, and keep the readable query in your own keyword table:

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

const client = new Client({ apiKey: process.env.ANSWERLINE_API_KEY! });

type Market = { country: string };

const kwId = (query: string) => createHash("sha256").update(query.trim().toLowerCase()).digest("hex").slice(0, 16);

function buildTasks(keywords: string[], markets: Market[], day: string, webhookUrl: string): TaskInput[] {
  return keywords.flatMap((query) =>
    markets.map((m) => ({
      taskType: "GOOGLE" as const,
      payload: { query, ...m, include: { aioverview: { markdown: true } } },
      idempotencyKey: `aio:${kwId(query)}:${m.country}:${day}`,
      webhook: { url: webhookUrl },
    })),
  );
}

export async function submitDay(keywords: string[], markets: Market[], day: string) {
  const tasks = buildTasks(keywords, markets, day, "https://your-app.example/hooks/aioverview");
  const created: Array<{ key: string; taskId: string }> = [];
  for (let i = 0; i < tasks.length; i += MAX_BATCH) {
    const chunk = tasks.slice(i, i + MAX_BATCH);
    const batch = await client.createBatch(chunk);
    for (const item of batch.results) {
      const key = chunk[item.index].idempotencyKey!;
      if (item.success) created.push({ key, taskId: item.task.id });
      else if (item.error.code === "INSUFFICIENT_CREDITS") throw new Error(`balance ran out at ${key}`);
      else if (item.error.code !== "RESOURCE_ALREADY_EXISTS") console.warn(key, item.error.code, item.error.message);
    }
    await saveCreated(created); // persist per chunk: the reconciliation pass in step 6 needs these ids
  }
  return created;
}

The same submission in Python:

import hashlib, os, requests

API = "https://api.answerline.dev"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['ANSWERLINE_API_KEY']}"

def kw_id(query: str) -> str:
    return hashlib.sha256(query.strip().lower().encode()).hexdigest()[:16]

def submit(keywords: list, markets: list, day: str, webhook_url: str) -> list:
    tasks = [
        {
            "taskType": "GOOGLE",
            "payload": {"query": q, **m, "include": {"aioverview": {"markdown": True}}},
            "idempotencyKey": f"aio:{kw_id(q)}:{m['country']}:{day}",
            "webhook": {"url": webhook_url},
        }
        for q in keywords
        for m in markets
    ]
    created = []
    for i in range(0, len(tasks), 500):
        chunk = tasks[i:i + 500]
        res = session.post(f"{API}/v1/async/task/batch", json=chunk, timeout=120)
        res.raise_for_status()
        for item in res.json()["results"]:
            key = chunk[item["index"]]["idempotencyKey"]
            if item["success"]:
                created.append((key, item["task"]["id"]))
            elif item["error"]["code"] == "INSUFFICIENT_CREDITS":
                raise RuntimeError(f"balance ran out at {key}")
    return created

A batch holds 1 to 500 tasks. Each item in results answers by its index: a VALIDATION_ERROR on one keyword does not cost you the rest, and RESOURCE_ALREADY_EXISTS means a previous run already created that task, which makes the function safe to re-run. Only exceeding your pending-task limit rejects a whole batch, with 429 QUEUE_LIMIT_EXCEEDED and details.remainingCapacity. Tasks wait in the queue and run as capacity frees up, so a large batch takes longer instead of failing.

Step 4: receive deliveries

Each finished task is POSTed to the webhook with the same JSON GET /v1/async/task/{taskId} returns. Verify the signature on the raw bytes, skip dashboard test deliveries, store by task.id, and answer within 15 seconds:

import express from "express";
import { verifyWebhook } from "@answerline/sdk";

const app = express();

app.post("/hooks/aioverview", express.raw({ type: "application/json" }), async (req, res) => {
  if (!(await verifyWebhook(req.body, req.get("webhook-signature"), process.env.WEBHOOK_SECRET!))) {
    return res.sendStatus(400);
  }
  const delivery = JSON.parse(req.body.toString("utf8"));
  if (delivery.test) return res.sendStatus(204);
  // insert ... on conflict (task_id) do nothing: deliveries can repeat and arrive out of order
  await rawDeliveries.insertIgnore(delivery.task.id, delivery.task.idempotencyKey, req.body);
  res.sendStatus(204);
});

Store the raw body and parse it in a separate job, so you can re-derive tables when your parsing rules change. The webhooks guide covers secret rotation and the delivery log; verifying webhook signatures has a Python verifier.

Step 5: parse into observation and citation rows

One observation row per task and one citation row per cited source:

type Observation = {
  key: string; hasOverview: boolean; organicRank: number | null;
  citations: Array<{ domain: string; url: string; position: number; chips: number[] }>;
  relatedDomains: string[]; adsInOverview: number;
};

const hostOf = (url: string) => new URL(url).hostname.toLowerCase();
const onDomain = (url: string, domain: string) => hostOf(url) === domain || hostOf(url).endsWith(`.${domain}`);

export function parse(delivery: any, ourDomain: string): Observation | null {
  if (delivery.task.status !== "COMPLETED") return null; // FAILED: not charged, keep it as a coverage gap
  const { organicResults = [], aioverview } = delivery.response.result;
  const ours = organicResults.find((r: any) => onDomain(r.link, ourDomain));
  const base = { key: delivery.task.idempotencyKey, organicRank: ours?.position ?? null };
  if (!aioverview) return { ...base, hasOverview: false, citations: [], relatedDomains: [], adsInOverview: 0 };

  const chipsBySource = new Map<number, number[]>();
  for (const pill of aioverview.citationPills ?? []) {
    chipsBySource.set(pill.position, [...(chipsBySource.get(pill.position) ?? []), pill.citationPillId]);
  }
  return {
    ...base,
    hasOverview: true,
    citations: (aioverview.sources ?? []).map((s: any) => ({
      domain: hostOf(s.url).replace(/^www\./, ""), url: s.url, position: s.position, chips: chipsBySource.get(s.position) ?? [],
    })),
    relatedDomains: [...new Set((aioverview.relatedLinks ?? []).map((l: any) => l.domain))],
    adsInOverview: (aioverview.ads ?? []).length,
  };
}

chips separates a source that is only in the rail from one Google attached to a sentence in the answer. Keep relatedDomains apart from citations, since related links are not in the source rail.

Step 6: reconcile missed deliveries

A delivery gets up to 10 attempts over roughly 21 to 43 minutes, then it is marked dead. After each run, compare the task ids saved in step 3 with the ids stored in step 4, and read the difference with client.getTask(id) (or GET /v1/async/task/{taskId}). File the ones whose task.status is COMPLETED or FAILED through the same parser and leave the rest for the next pass. Only those two statuses are final; a task can move from PROCESSING back to QUEUED.

Step 7: trend it

Illustrative keyword data for one market:

Keyword Overview Cited position Organic position Reading
A yes 2 4 Cited and ranking
B yes none 3 Rank gap: fix first
C no none 1 No overview to win
D yes none none Neither: content gap

Presence rate is 3/4 = 75%, citation share is 1/3 (A among A, B, D), and the rank gap list is B. For how other SERP blocks shift around the overview, see tracking SERP feature changes.

What it costs

Per billing, at the Starter tier:

Request Price
1 page, no overview shown $0.0015
1 page with an overview shown $0.00221
1 page with include.aioverview and include.paaAioverview $0.00221
3 pages with an overview shown $0.00363

The overview add-on is $0.00071, charged once and only when Google shows an overview; paaAioverview is free; each extra page adds $0.00071. Pages are a maximum: the charge depends on the pages Google returned and never exceeds the reserved creditsToCharge. Failed tasks cost nothing.

300 keywords in the US and GB, page 1, is 600 tasks and at most 600 × $0.00221 = $1.33 per run: about $5.70 a month weekly, $39.78 daily. Money keywords daily and the long tail weekly keeps cost in check; AI answer volatility covers choosing a cadence and cost planning the budget. Tier prices are on the pricing page.

Pitfalls

For Google’s conversational surface, see AI Mode; for every Google Search field, the Google Search engine page and the async tasks docs.

Questions

How much does a Google request with the AI Overview cost?

One results page with include.aioverview costs $0.00221 at the Starter tier when Google shows an overview, and $0.0015 when it does not. Each page after the first adds $0.00071, so a 3-page task with an overview costs $0.00363.

What is the rank gap on a keyword?

Keywords where your domain ranks organically but the overview does not cite it. Compare organicResults positions with the domains the overview cites for the same keyword and market; that split shows where an existing ranking stops earning visibility.

Does include.paaAioverview cost extra on top of include.aioverview?

No. include.paaAioverview is free; include.aioverview adds $0.00071 once when Google shows an overview. Each hydrated People Also Ask item makes the response take longer.

Can I track AI Overviews for a specific city?

Yes. Send location, a Google canonical name such as Austin,Texas,United States, or a uule with the Google Search request, and treat the location as a dimension in every metric.

How should a tracker record keywords where no overview appeared?

As a row for that keyword, market and day with the overview recorded as absent. Presence over time is the series you are trending, so a day without an overview and a day whose result never arrived have to stay distinguishable in storage.

How do I rebuild Google's citation chips from the response?

Group aioverview.citationPills by citationPillId. Each entry is one source of one chip, and relatedLinks entries with the same citationPillId are that chip's extra links that are not in the sources rail.

How do I catch results whose webhook never arrived?

After each run, compare the task ids you submitted with the ids you stored and read the difference with GET /v1/async/task/{taskId}. A delivery gets up to 10 attempts over roughly 21 to 43 minutes before it is dead, and only COMPLETED and FAILED are final, so file those and leave the rest for the next pass.

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