Get Google AI Overviews citations in Node.js
One POST from Node's built-in fetch returns a Google AI Overview as data: the answer text plus a sources[] array of { title, url, position } for every reference Google attached. There is no SDK to install and none exists — Node 18+ ships the entire transport. This page stays in one language the whole way: the minimal call and the JSON it returns, a production-shaped script that sets gl and hl explicitly, keeps the two counters this surface demands — overviews present across all queries, references among the present — and appends every run to JSON Lines, then the locale behaviour and the failure modes that corrupt the numbers first.
The whole task is one request: POST https://api.agentgeo.org/v1/fetches with {"query": "...", "surfaces": ["google_ai_overview"]} and a Bearer ag_live_ key, then read answers[0].sources[] — each reference arrives as { title, url, position }, in the order Google attached it. What earns this page its length is everything around that call: country and language actually mean something on this surface, an overview can be absent from the SERP entirely, and both facts have to survive into whatever file your future diffs read.
The minimal request
Save this as aio-citations.mjs — ESM, so top-level await works without ceremony — and run node aio-citations.mjs. Both locale fields are in the body on purpose: this is the surface where they do something.
// Node 18+ - built-in fetch, nothing to install. Run: node aio-citations.mjs
const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
method: "POST",
headers: {
"Authorization": "Bearer ag_live_your_key_here",
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "best payroll software for restaurants",
surfaces: ["google_ai_overview"],
country: "US", // Google's gl= - which market's SERP this is
language: "en", // Google's hl= - the only surface that reads language
}),
signal: AbortSignal.timeout(200_000), // the API can hold a request up to 180s
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
const { answers } = await resp.json();
for (const src of answers[0].sources ?? []) {
console.log(`${String(src.position).padStart(2)}. ${src.title}`);
console.log(` ${src.url}`);
}If the SERP carried an overview, the response is one JSON document with the reference list already ordered:
{
"id": "run_b81d44c9e2f7",
"query": "best payroll software for restaurants",
"surfaces": ["google_ai_overview"],
"status": "completed",
"recordsDelivered": 1,
"creditsCharged": 1,
"answers": [
{
"surfaceKey": "google_ai_overview",
"answerText": "Payroll software for restaurants needs to handle tip credits, split shifts and ...",
"sources": [
{ "title": "Restaurant Payroll: A Complete Guide", "url": "https://example.com/restaurant-payroll-guide", "position": 1 },
{ "title": "Tip Pooling Rules by State", "url": "https://example.org/tip-pooling-rules", "position": 2 }
],
"fetchedAt": "2026-07-27T09:03:18Z"
}
]
}One record delivered, one credit charged. position starts at 1 and preserves the order Google attached the references — the field a copy-paste from the results page loses first. If instead Google showed no AI Overview for the query, the record comes back status: "failed" at zero credits. That outcome is common enough on this surface that the production script below gives it its own counter rather than an error branch.
A tracker that keeps its denominators separate
The scheduled version has a different job: not "what did this overview cite" but "across our query set, how often does an overview exist at all, and who gets cited when it does." Those are two ratios with two denominators — presence across every query asked, citations among present overviews only — and the script keeps them as separate counters because no single number answers both. Around that core: new URL() normalization so one page counts once, a Map for host counts, and one JSON line per run via node:fs/promises so next month has something to diff.
// track-aio-citations.mjs - Node 18+, ESM, zero dependencies.
// Runs a query set against google_ai_overview, keeps the two counters this
// surface needs, and appends one JSON line per run for future diffs.
import { appendFile } from "node:fs/promises";
const KEY = process.env.AGENTGEO_KEY ?? "ag_live_your_key_here";
const LOG = "aio-citations.jsonl";
const COUNTRY = "US"; // Google's gl=
const LANGUAGE = "en"; // Google's hl= - stamp both into every row
const QUERIES = [
"best payroll software for restaurants",
"restaurant payroll with tip pooling",
"how much does restaurant payroll cost per employee",
"payroll rules for tipped employees",
];
// One page, one identity: lowercase host without www., path without the
// trailing slash, query string gone. Normalize here, not in the report.
function page(raw) {
try {
const u = new URL(raw);
return {
host: u.hostname.toLowerCase().replace(/^www\./, ""),
path: u.pathname.replace(/\/+$/, "") || "/",
};
} catch {
return null; // a malformed source URL costs one row, not the run
}
}
async function fetchOverview(query) {
const resp = await fetch("https://api.agentgeo.org/v1/fetches", {
method: "POST",
headers: {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query,
surfaces: ["google_ai_overview"],
country: COUNTRY,
language: LANGUAGE,
}),
signal: AbortSignal.timeout(200_000),
});
if (!resp.ok) throw new Error(`AgentGEO ${resp.status}: ${await resp.text()}`);
return resp.json();
}
const cited = new Map(); // host -> citations across present overviews
let asked = 0; // denominator 1: every query that got a reading
let present = 0; // overviews Google actually showed
let withRefs = 0; // among present overviews, how many cited anything
for (const query of QUERIES) {
let run;
try {
run = await fetchOverview(query);
} catch (err) {
console.error(`skipped "${query}": ${err.message}`);
continue; // a transport error is not an absent overview - keep them apart
}
asked += 1;
for (const answer of run.answers) {
if (answer.status === "failed") {
// No AI Overview on this SERP. Zero credits, and a row worth writing:
// absence moving is usually the first signal in this dataset.
await appendFile(LOG, JSON.stringify({
fetchedAt: new Date().toISOString(),
query,
country: COUNTRY,
language: LANGUAGE,
aioPresent: false,
}) + "\n");
continue;
}
present += 1;
const sources = [...(answer.sources ?? [])].sort((a, b) => a.position - b.position);
if (sources.length > 0) withRefs += 1;
for (const src of sources) {
const p = page(src.url);
if (!p) continue;
cited.set(p.host, (cited.get(p.host) ?? 0) + 1);
}
await appendFile(LOG, JSON.stringify({
fetchedAt: answer.fetchedAt,
query,
country: COUNTRY,
language: LANGUAGE,
aioPresent: true,
referenceCount: sources.length,
sources: sources.map(({ position, url, title }) => ({ position, url, title })),
}) + "\n");
}
}
console.log(`overview present: ${present}/${asked} queries`);
console.log(`carried references: ${withRefs}/${present} present overviews`);
console.log("most-cited hosts (present overviews only)");
const ranked = [...cited.entries()].sort((a, b) => b[1] - a[1]);
for (const [host, n] of ranked.slice(0, 10)) {
console.log(` ${String(n).padStart(2)}x ${host}`);
}Three choices are doing quiet work. Transport errors don't increment asked — a query the API never answered belongs in neither denominator, and folding it into either one skews the rate. Absent overviews get a full row with aioPresent: false, because a host that disappears when its overview disappears is a different event from a host dropped out of a living overview. And country and language are stamped into every line, which is what makes the file splittable by market after the fact.
| Counter | Ratio it feeds | Question it answers |
|---|---|---|
present | present / asked — presence across all queries | Does Google answer this query set inline at all? This is the number that moves when overviews appear on or vanish from SERPs. |
withRefs | withRefs / present — citation rate among present overviews | When an overview exists, does it cite? On this surface it runs near 1 — a dip means the surface changed, not your content. |
cited (per host) | host's share of references across present overviews | Who supplies the answers? Only meaningful against the second denominator, never against all queries. |
A single blended ratio is how this dataset lies. "We're cited in 40% of AI Overview queries" could mean overviews exist everywhere and rarely cite you, or exist on half the queries and cite you nearly every time — opposite problems with opposite fixes. Report present / asked and cited-among-present as two numbers, always; the JSONL rows carry enough to recompute both for any date range.
Four queries against one surface costs at most four credits, and absent overviews cost nothing. Get a free key and point the script at your own query set — free tier, no credit card.
Start for freegl, hl and the overview that isn't there
google_ai_overview is the one surface where locale is a real parameter rather than dead weight. country maps to Google's gl — which market's SERP you get — and language to hl, and no other surface of the six reads language at all. Both change the outcome twice over: whether an overview appears for the query, and which pages it cites when it does. A US-English run and a German run are two different datasets, which is why the script writes the market into every row — a log you can't split by market is a log you can't trust by market.
The other behaviour to plan for is absence. Google doesn't produce an overview for every query, and when there is none the record comes back status: "failed" at zero credits — not an error to retry in a tight loop, just the SERP as it stood. Delivered overviews, by contrast, essentially always carry references, because the block is assembled from a search. That's why withRefs / present hovers near 1 and why it's still worth keeping: it's a canary, not a vanity metric. One practical upside of riding the SERP rather than a chat session: responses tend to come back in well under a minute. Keep the 200-second timeout anyway — the contract allows a hold of up to 180, and your client should never be the party that gives up first.
What the reference positions mean and how to act on them — the task-level story rather than the Node one — lives at How to pull citations from Google AI Overviews. This page's job ends at clean rows.
Where the numbers go wrong
- Treating
failedas a scrape problem. On this surface a failed record means the SERP had no AI Overview — zero credits, nothing to fix on your side. Log it asaioPresent: falseand move on; retrying in a loop re-asks a question Google already answered with a no. Ask again on the next scheduled run instead — presence is volatile, and its rate over time is the point of tracking it. - One log file, two markets. Change
countryorlanguagemid-quarter and every diff across the boundary is fiction — hosts "drop out" because the market changed, not the citations. Either stamp both fields into every row, as the script does, or keep one file per market. Mixed rows are unrecoverable after the fact. - A client timeout tuned to the average, not the contract. Overviews usually return fast, so a 30-second
AbortSignallooks safe — until the one slow scrape the API is still holding gets cancelled at your end. The server budget is 180 seconds;AbortSignal.timeout(200_000)stays above it, and the fast case never notices the ceiling. - Counting URLs instead of pages.
?utm_source=, trailing slashes andwww.split one page's count into fragments before anyone notices. Normalize withnew URL()— lowercase host, stripwww., drop the query string — before theMap, and keep the path in the rows so you still know which page earned the reference. - Copying this request body to another surface.
languageis honoured here and nowhere else;countrysurvives only oncopilot. Fan the query out tochatgptorperplexitywith these fields still attached and nothing errors — but the body now documents an assumption that's false, and someone will eventually tune a parameter the surface never read. Build the body per surface; the six-engine fan-out lives on the Node.js API page.
Summary
The minimal version is a dozen lines: POST with "surfaces": ["google_ai_overview"], country and language set, then read answers[0].sources[]. The version worth scheduling adds the two counters — overviews present out of queries asked, references among the present — plus normalized hosts and a JSONL row per run with the market stamped in. Keep the absent rows; on this surface presence moves before positions do. The Python and cURL versions of this page hit the same endpoint with the same fields, so nothing about the data changes with the language.
Start free and pull your first overview → · Full endpoint reference → — Free tier, no credit card required.
Start for freeFrequently asked questions
- How do I get Google AI Overviews citations in Node.js?
- POST to
https://api.agentgeo.org/v1/fetcheswith Node 18's built-infetch, aBearer ag_live_key and{"query": "...", "surfaces": ["google_ai_overview"]}, then readanswers[0].sources[]— each reference carriestitle,urlandpositionin the order Google attached it. No SDK exists in any language and none is needed; the integration is standard library end to end. - Why does my google_ai_overview request come back with status failed?
- Almost always because the SERP had no AI Overview for that query — Google doesn't answer every search inline. The record costs zero credits and there is nothing to fix on your side. Log it as an absent overview rather than an empty citation list: the two mean different things, and blending them makes every host in your data look like it's losing citations.
- How do I set the country and language for an AI Overview fetch?
- Send
"country"and"language"in the request body — they map to Google'sglandhlparameters.google_ai_overviewis the only surface that honourslanguageat all, and locale changes both whether an overview appears and which pages it cites. Stamp both values into every stored row, or keep one log per market, so the dataset stays splittable later. - Do Google AI Overviews always have citations?
- Delivered overviews essentially always carry references, because the block is assembled from a live search. What goes missing is the overview itself: on queries Google doesn't answer inline, the record returns
failedat zero credits. That's why the script tracks two ratios separately — presence across all queries, and citations among present overviews. The second runs near 1; the first is the one that moves. - Is there an npm package or SDK for the AgentGEO API?
- No SDK — built-in
fetchis the whole client, which is one less dependency to patch. The only npm package in the picture isagentgeo-mcp, the MCP server:claude mcp add agentgeo -- npx -y agentgeo-mcp --key ag_live_...gives Claude Code or any MCP client the same records, for when the consumer is an agent rather than a cron job.
Keep reading