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).
- Presence rate: the share of tracked keywords whose results page carried an AI Overview.
- Citation share: among keywords with an overview, the share that cite a URL on your domain.
- Rank gap: keywords where your domain ranks organically but the overview does not cite it.
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
- Presence rate per keyword and market over the last 30 runs: how often
hasOverviewis true. Each country is a separate results page. - Citation share per domain: citations of that domain divided by observations with an overview, per keyword cluster.
- Chip share: the same, counting only citations with at least one chip.
- Rank vs citation quadrants: ranked and cited, ranked but not cited (the rank gap, fix these first), cited but not ranked, neither.
- Cited hosts: count
aioverview.sourceshosts across keywords. The top hosts are the competitors for the overview, which are not always the top organic results; competitor analysis covers reporting on this. - Change events: a keyword whose overview appeared or disappeared, or whose cited domain set changed. Require the change to persist for two runs before alerting.
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
- Forgetting
include.aioverview. Without it every keyword is recorded as “no overview”. - Counting
nullas an error. A nullaioverviewis a result: no overview for that page. - Keys without the country. US and GB runs of the same keyword collide and the second is skipped as
RESOURCE_ALREADY_EXISTS. - Merging markets. Aggregate only within one country or location.
- Matching domains with substring checks.
example.comshould not matchnotexample.com; compare hostnames. - Counting related links or URLs in
markdownas citations. UsesourcesandcitationPills. - Parsing inside the webhook handler. Store, answer 2xx, parse later.
- Single snapshots. Overviews change between runs; trend them.
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.