Competitor SEO tracking: positions, SERP features, ads and AI Overview citations over time
Competitor SEO tracking is rank tracking run for every domain on the page instead of only yours: for a fixed keyword set, record each competitor’s organic positions, the ads they run, the SERP features they hold and the AI Overview citations they earn, then diff the snapshots over time. The unit of storage is one results page per keyword, market, device and run. The headline metric is share of SERP, which counts all of those placements alongside blue links.
What this does not include: traffic, clicks, backlinks, search volume or keyword difficulty. A results page does not contain them and this API does not return them. Everything below is observed placement.
What to collect per results page
One Google Search request (POST /v1/monitor/google, or a GOOGLE async task) returns every placement on the page as its own field under result:
| Placement | Field | Owner key | Notes |
|---|---|---|---|
| Organic result | organicResults[]: position, page, link, title |
Host of link |
Sort by page, then position, and number the list yourself |
| Text ad | ads[] with type RESULT: domain, blockPosition, position, title, description |
domain |
blockPosition is top, bottom, middle or rhs |
| Sponsored shopping card | ads[] with type SHOPPING_CARD: store, price |
store |
url is a Google redirect, so do not key on it |
| AI Overview citation | aioverview.citationPills[]: domain, url, position |
domain |
Needs include.aioverview; aioverview is null when none shown |
| People Also Ask answer | peopleAlsoAsk[] with type LINK: link |
Host of link |
|
| Local pack entry | localResults[]: title, position |
Business name | Desktop only; needs location for city results |
| Shopping card | shoppingCards[]: store, position |
store |
Omitted when not shown |
| Knowledge panel | knowledgeGraph.website |
Host of website |
Omitted when not shown |
Keep the raw result JSON too. When you add a competitor or fix an alias, you recompute from snapshots instead of re-collecting history, which you cannot do.
Data model
Five tables cover it. Competitors carry every domain, store name and business name they use.
create table competitor_keys (
competitor text not null,
kind text not null check (kind in ('domain', 'store', 'business')),
value text not null, -- 'billfox.com', 'BillFox Store', 'BillFox Austin'
primary key (kind, value)
);
create table keywords (
keyword_id bigint primary key,
text text not null,
country text not null,
location text, -- 'Austin,Texas,United States' or null
device text not null default 'desktop',
grp text not null -- 'category', 'comparison', 'brand', 'problem'
);
create table snapshots (
task_id uuid primary key,
keyword_id bigint not null references keywords,
run_date date not null,
sample int not null default 0,
status text not null,
result jsonb,
unique (keyword_id, run_date, sample)
);
create table placements (
task_id uuid not null references snapshots,
kind text not null, -- 'organic', 'ad_top', 'ad_other', 'shopping_ad', 'aio_cite', 'paa', 'local', 'shopping'
rank int not null, -- order within its kind
owner_kind text not null, -- 'domain', 'store', 'business'
owner text not null,
url text,
primary key (task_id, kind, rank)
);
create table slot_weights (
kind text not null,
rank int not null, -- 0 = any rank
weight numeric not null,
primary key (kind, rank)
);
placements is derived from snapshots.result and can be rebuilt at any time. sample lets you take several readings a day for ads without duplicating organic data.
Share of SERP
Organic share of voice weights organic results by a click curve. That misses a competitor who is cited in the AI Overview and owns the top ad while ranking seventh. Share of SERP counts every placement type with a weight you set:
share_of_SERP(D) = Σ weight(placements owned by D) ÷ Σ weight(all placements)
over one keyword set, market, device and period. Two rules make it comparable over time:
- The denominator includes every owner, tracked or not. A new entrant should shrink everyone’s share instead of being invisible.
- Weights are fixed for the life of a series. Change them and you start a new series.
A starting weight table, to be replaced with your own judgement or click data:
| Kind | Rank | Weight |
|---|---|---|
| organic | 1 | 1.00 |
| organic | 2 | 0.60 |
| organic | 3 | 0.45 |
| organic | 4–10 | 0.30 down to 0.10 |
| aio_cite | any | 0.50 |
| ad_top | 1 | 0.50 |
| ad_top | 2+ | 0.25 |
| ad_other | any | 0.05 |
| paa | any | 0.15 |
| local | 1–3 | 0.40 |
These weights are illustrative, not measured click-through rates. Use the same weights every run.
-- Share of SERP per owner for one week, sample 0 for organic features, all samples for ads
with p as (
select pl.*, s.sample
from placements pl
join snapshots s using (task_id)
join keywords k using (keyword_id)
where s.status = 'COMPLETED'
and s.run_date between '2026-09-07' and '2026-09-13'
and k.grp in ('category', 'comparison')
and (pl.kind in ('ad_top', 'ad_other', 'shopping_ad') or s.sample = 0)
),
w as (
select coalesce(c.competitor, 'other:' || p.owner) as owner,
coalesce(sw.weight, sw_any.weight, 0) as weight
from p
left join slot_weights sw on sw.kind = p.kind and sw.rank = p.rank
left join slot_weights sw_any on sw_any.kind = p.kind and sw_any.rank = 0
left join competitor_keys c on c.kind = p.owner_kind and c.value = p.owner
)
select owner, sum(weight) / sum(sum(weight)) over () as share_of_serp
from w group by owner order by share_of_serp desc limit 20;
Report share of SERP beside its components (organic share, ad presence rate, AI Overview citation rate). A rising total driven only by ads means something different from one driven by citations.
Scheduling with async batches and webhooks
Submit each run as async tasks: no synchronous surcharge, up to 500 tasks per POST /v1/async/task/batch, results pushed to your webhook. The idempotency key encodes the slot, so a crashed scheduler can resubmit safely and duplicates come back as RESOURCE_ALREADY_EXISTS.
const API = "https://api.answerline.dev";
const headers = { Authorization: `Bearer ${process.env.API_KEY}`, "Content-Type": "application/json" };
type Keyword = { keyword_id: number; text: string; country: string; location: string | null; device: string };
export async function submitRun(keywords: Keyword[], runDate: string, sample: number, withAio: boolean) {
const tasks = keywords.map((k) => ({
taskType: "GOOGLE",
idempotencyKey: `cseo:${k.keyword_id}:${runDate}:${sample}`,
webhook: { url: "https://seo.example.com/hooks/serp" },
payload: {
query: k.text,
country: k.country,
device: k.device,
...(k.location ? { location: k.location } : {}),
...(withAio ? { include: { aioverview: { markdown: false } } } : {}),
},
}));
const accepted: { taskId: string; keywordId: number }[] = [];
for (let i = 0; i < tasks.length; i += 500) {
const chunk = tasks.slice(i, i + 500);
const res = await fetch(`${API}/v1/async/task/batch`, { method: "POST", headers, body: JSON.stringify(chunk) });
if (!res.ok) throw new Error(`batch at ${i}: HTTP ${res.status}`);
const body = await res.json();
for (const r of body.results) {
if (r.success) accepted.push({ taskId: r.task.id, keywordId: keywords[i + r.index].keyword_id });
else if (r.error.code !== "RESOURCE_ALREADY_EXISTS") console.warn(chunk[r.index].idempotencyKey, r.error.code);
}
}
return accepted; // insert into snapshots with status 'QUEUED'
}
A typical schedule:
| Series | Cadence | Sample | AI Overview |
|---|---|---|---|
| Category and comparison keywords, organic and features | Daily or weekly | 0 | Yes |
| Brand and competitor-brand keywords, ads | 3 times a day | 0, 1, 2 | Only on sample 0 |
| Problem keywords | Weekly | 0 | Yes |
The webhook body is {task, credits, response}, with the Google result in response.result. Verify the Webhook-Signature header against the raw body, skip deliveries with "test": true, and treat task.id as the deduplication key because deliveries can repeat. Verifying webhook signatures has the code; async and webhooks have the rules.
Turning a result into placements
const host = (u?: string) => {
try { return new URL(u ?? "").hostname.replace(/^www\./, ""); } catch { return ""; }
};
export function placements(result: any) {
const rows: { kind: string; rank: number; owner_kind: string; owner: string; url?: string }[] = [];
const organic = [...(result.organicResults ?? [])].sort(
(a, b) => (a.page ?? 1) - (b.page ?? 1) || (a.position ?? 0) - (b.position ?? 0));
organic.forEach((o, i) => rows.push({ kind: "organic", rank: i + 1, owner_kind: "domain", owner: host(o.link), url: o.link }));
const counters: Record<string, number> = {};
for (const a of result.ads ?? []) {
const shopping = a.type === "SHOPPING_CARD";
const kind = shopping ? "shopping_ad" : a.blockPosition === "top" ? "ad_top" : "ad_other";
counters[kind] = (counters[kind] ?? 0) + 1;
rows.push({ kind, rank: counters[kind], owner_kind: shopping ? "store" : "domain",
owner: shopping ? a.store ?? "" : a.domain ?? host(a.url), url: shopping ? undefined : a.url });
}
const seen = new Set<string>();
for (const c of result.aioverview?.citationPills ?? []) {
if (seen.has(c.url)) continue;
seen.add(c.url);
rows.push({ kind: "aio_cite", rank: seen.size, owner_kind: "domain", owner: c.domain, url: c.url });
}
(result.peopleAlsoAsk ?? []).filter((q: any) => q.type === "LINK" && q.link)
.forEach((q: any, i: number) => rows.push({ kind: "paa", rank: i + 1, owner_kind: "domain", owner: host(q.link), url: q.link }));
(result.localResults ?? []).forEach((l: any) =>
rows.push({ kind: "local", rank: l.position, owner_kind: "business", owner: l.title }));
(result.shoppingCards ?? []).forEach((s: any, i: number) =>
rows.push({ kind: "shopping", rank: i + 1, owner_kind: "store", owner: s.store ?? "" }));
return rows;
}
Citation pills can repeat a URL across pills, so the loop counts each cited URL once per page.
Change detection
Diff each completed snapshot against the previous run for the same keyword, market, device and sample index. Events worth storing:
| Event | Rule | Typical meaning |
|---|---|---|
| Entered top 10 | Competitor absent from organic ranks 1–10 last run, present now | New or refreshed page gaining |
| Dropped from top 10 | Reverse | Lost relevance, or displaced |
| Crossed top 3 | Best rank moved across 3 in either direction | Material visibility change |
| Best URL changed | Different link holds their best rank |
Consolidation, new page, or cannibalisation |
| New AI Overview citation | Their domain in citationPills now, not last run |
Page picked up as a source |
| Lost AI Overview citation | Reverse | Replaced as a source |
| Started bidding | First ads[] observation for their domain on this keyword in 30 days |
New campaign |
| New ad copy | Unseen hash of title + description for their domain |
Testing or new offer |
| New owner in top 10 | A domain not seen on this keyword in 30 days | Unknown entrant |
Result pages fluctuate. Two rules keep the feed readable:
- Persistence. Emit an alert when the new state holds for two consecutive runs; store single-run flips but do not page anyone.
- Aggregation. A competitor entering the top 10 on 40 category keywords the same week is one alert with 40 keywords, not 40 alerts.
-- Competitors that entered the top 10 on two consecutive runs after being absent
with ranks as (
select s.keyword_id, s.run_date, c.competitor, min(p.rank) as best
from snapshots s
join placements p using (task_id)
join competitor_keys c on c.kind = 'domain' and c.value = p.owner
where p.kind = 'organic' and s.sample = 0 and s.status = 'COMPLETED'
group by 1, 2, 3
),
runs as (
select distinct keyword_id, run_date,
lag(run_date, 1) over (partition by keyword_id order by run_date) as prev1,
lag(run_date, 2) over (partition by keyword_id order by run_date) as prev2
from snapshots where sample = 0 and status = 'COMPLETED'
)
select r.keyword_id, now_.competitor, r.run_date
from runs r
join ranks now_ on now_.keyword_id = r.keyword_id and now_.run_date = r.run_date and now_.best <= 10
join ranks p1 on p1.keyword_id = r.keyword_id and p1.run_date = r.prev1 and p1.competitor = now_.competitor and p1.best <= 10
left join ranks p2 on p2.keyword_id = r.keyword_id and p2.run_date = r.prev2 and p2.competitor = now_.competitor and p2.best <= 10
where p2.keyword_id is null;
Feature-level changes that are not about a competitor, such as an AI Overview appearing on a keyword at all, are covered in SERP features change tracking.
Cost from credits
Credits per async task: Google Search 3 for one page, plus 2 per extra page, plus 2 once when include.aioverview is set. Synchronous calls add 2. A failed request is charged nothing (credits).
Worked example: a B2B software company tracks six competitors in the US:
| Series | Volume | Credits per task | Credits per month |
|---|---|---|---|
| 600 category and comparison keywords, daily, 1 page + AI Overview | 600 × 30 | 5 | 90,000 |
| 60 brand and competitor-brand keywords, 2 extra ad samples a day | 60 × 2 × 30 | 3 | 10,800 |
| 200 problem keywords, weekly, 2 pages + AI Overview | 200 × 4 | 7 | 5,600 |
| Total | 106,400 |
Moving the 600 category keywords from daily to weekly cuts the first line to 12,000 credits and the total to 28,400. Decide per series whether you act on a change within a day; if not, weekly is enough. Ads on brand terms are the series where repeated daily samples matter most, because advertisers can differ from one search to the next. Plan allowances are on pricing, and AI monitoring cost planning covers budgeting when AI answers join the same account.
Pitfalls
- Keying shopping ads by URL. Sponsored shopping cards carry a redirect in
url; usestore. - Competitors with many domains. Blogs, docs subdomains and country sites belong in
competitor_keys, or their share is understated. - Mixing devices or locations in one series. Mobile and desktop pages differ, and
localResultsis desktop only. - Organic counted once per ad sample. Restrict organic and feature placements to sample 0.
- Treating share of SERP as traffic share. It is weighted placement, not clicks. Keep traffic claims out of reports built on it.
- Reporting before the run completes. Reconcile tasks still
QUEUEDorPROCESSINGwithGET /v1/async/task/{taskId}before computing the day’s metrics.
The per-domain rank model is detailed on rank tracking, and the metric set for rivals, including AI answer citation share, on competitor analysis. To send a first request, use the quickstart.
Questions
What is competitor SEO tracking?
Recording, on a schedule, where each competitor appears on the Google results pages for a fixed keyword set: organic positions, ads, SERP features and AI Overview citations. Stored as snapshots, it shows who gains and loses visibility and when.
What is share of SERP?
The share of weighted slots on a set of result pages owned by one domain, counting organic results, ads, AI Overview citations and other features. Unlike organic-only share of voice, it credits a competitor for being cited or advertising above the organic results.
Does this include competitor traffic or backlinks?
No. Result pages show positions, ads, features and citations at the time of the request. The API returns no traffic estimates, backlink data, search volume or keyword difficulty; join those from other sources if you need them.
How much does tracking 1,000 keywords daily cost?
A one-page Google Search async task costs 3 credits, or 5 with the AI Overview. 1,000 keywords daily with the AI Overview is 5,000 credits a day, about 150,000 in a 30-day month.
How do I avoid false alerts when a competitor's position changes?
Alert only on transitions that hold for two consecutive runs, compare runs from the same market and device, and ignore moves within positions that do not cross a boundary you care about, such as the top 3 or page one.