Local Rank Tracking API
A search for a plumber in Austin and one in Boston return different result pages, and AI answers vary by market too. Location is a request parameter: AI Mode takes a city today, and as city targeting reaches Google Search and US-state targeting the assistants, the pipeline that tracks nationally tracks per market with no new integration.
Who it's for
Local SEO platforms, multi-location and franchise brands, and agencies reporting per-market rankings for clients.
What you get
- Country targeting on ChatGPT in 213 countries, with US-state targeting coming soon
- City-level AI Mode answers, with location as City,Region,Country or a uule string
- Local pack results with names, ratings and positions, per country today and per city soon
- Adding an AI engine to a local grid changes only the payload
- Batches of up to 500 tasks, so a location grid is queued in a few calls
Try it
curl -X POST "https://api.answerline.dev/v1/monitor/chatgpt" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt":"Who is the best emergency plumber in Austin, Texas?","country":"US"}'
Engines for this workload
The local measurement problem
A national rank averages hundreds of local result pages that no user sees. For a business that serves customers in a place, the unit of measurement is a keyword searched from a specific location, and the question is whether the business appears in the local pack, in the organic results beneath it, and in the AI answers for that market.
Local tracking multiplies rows: 40 keywords across 25 cities is 1,000 result pages per run. Design the grid, the identifiers and the metrics before sending anything.
Metrics
| Metric | Definition |
|---|---|
| Local pack rank | position of the business within localResults for one keyword × location. Null when the pack appeared without it; “no pack” when the field is absent. |
| Pack presence rate | Keyword × location cells whose page showed a local pack ÷ cells collected. |
| Pack share | Cells where the business is in the pack ÷ cells that showed a pack. |
| Organic local rank | Rank of the business’s domain in organicResults for the same cell (ordering rule in rank tracking). |
| Market coverage | Locations where the business is in the pack for at least one keyword ÷ locations tracked. |
| Share of local voice | Pack appearances of the business ÷ pack slots across all cells. Compute it for competitors from the same data. |
| AI local mention rate | AI answers for a location whose place entries or text name the business ÷ AI answers collected for that location. |
| Rating gap | Business rating minus the mean rating of other pack members in the same cell. |
Keep pack share and presence rate separate. A keyword that stops triggering a local pack is a different event from a competitor taking your slot.
Architecture
locations × keywords × engines ──> weekly scheduler ──> batches (≤500)
│
grid reports <── pack_slots / ai_places <── webhook receiver
Submission, signature checks, deduplication and reconciliation follow the AI visibility tracking guide, async tasks and webhooks. A grid of 1,000 cells on two engines is four batch calls.
Location design
- Use Google’s canonical names.
locationon Google Search and AI Mode expects the “City,Region,Country” form from Google’s geotarget list, for exampleAustin,Texas,United States. Store the exact string you checked and give it an id. - Send
countrywithlocation. Country is required; the location adds city-level precision. - Use
uuleonly if you already generate it. It replaceslocationand cannot be combined with it. - Record each city’s state for the other engines. ChatGPT and Gemini are targeted by
country(213 countries), so their grid is country-level; Gemini also takes the USstate.
Keyword design
Local keywords come in three shapes that behave differently:
- Implicit local, such as “emergency plumber”: the location parameter decides the pack.
- Explicit local, such as “emergency plumber austin”: the text decides, so running it from other cities tells you little.
- Near-me, such as “plumber near me”: behaves like implicit local.
Track implicit and near-me keywords across the grid, and explicit keywords only from their own city. For AI engines, phrase prompts as a user would, both with the city (“Who is a reliable emergency plumber? I’m in Austin.”) and without it, relying on the market targeting.
Schedule and identity
Start weekly, because cost scales with cells, and move to daily only for cells that change frequently. Run each location at the same hour. The idempotency key:
local:{engine}:{keyword_id}:{location_id}:{run_date}
Storage schema
create table locations (
location_id int primary key,
google_name text not null unique, -- "Austin,Texas,United States"
country text not null,
us_state text -- "TX", null outside the US
);
create table cells (
task_id uuid primary key,
engine text not null,
keyword_id int not null,
location_id int not null references locations,
run_date date not null,
status text not null default 'QUEUED',
has_pack boolean,
unique (engine, keyword_id, location_id, run_date)
);
create table pack_slots (
task_id uuid references cells,
position int not null,
place_id text,
title text not null,
rating numeric,
reviews int, -- parsed from strings like "26K"
category text,
primary key (task_id, position)
);
create table ai_places (
task_id uuid references cells,
source text not null, -- 'aimode.places', 'chatgpt.map', 'gemini.places', 'text'
place_key text not null, -- placeId, or provider:id, or normalized name
name text not null,
position int,
primary key (task_id, source, place_key)
);
create table tracked_businesses (
place_key text primary key, -- your locations and competitors
brand text not null,
is_ours boolean not null
);
has_pack null means the task has not completed; false means it completed and the page had no local pack.
Change detection
Compare each cell with its previous run:
- Lost pack slot: a tracked
place_idin last run’spack_slotsand not in this run’s. - New entrant: a
place_idin the pack that was absent last run. New entrants in several neighboring cities at once usually mean a new competitor location. - Pack vanished:
has_packwent from true to false. - Rating drift: a competitor’s
ratingor review count moved more than your threshold.
End-to-end Python
Build the grid
AI Mode and Google Search take the city; ChatGPT and Gemini run per country.
import datetime as dt
HOOK = {"url": "https://hooks.example.com/local"}
def cell_payload(engine, keyword, loc):
if engine == "AIMODE":
return {"prompt": keyword, "country": loc["country"], "location": loc["google_name"]}
if engine == "GOOGLE":
return {"query": keyword, "country": loc["country"], "location": loc["google_name"]}
return {"prompt": keyword, "country": loc["country"]} # CHATGPT, GEMINI
def grid_tasks(keywords, locations, engines, run_date=None):
run_date = run_date or dt.date.today().isoformat()
return [
{"taskType": e, "payload": cell_payload(e, kw["text"], loc),
"idempotencyKey": f"local:{e}:{kw['id']}:{loc['location_id']}:{run_date}", "webhook": HOOK}
for e in engines for kw in keywords for loc in locations
]
Submit in chunks of 500 and insert a cells row per accepted task, splitting the idempotency key back into engine, keyword, location and date.
For ChatGPT and Gemini, which are targeted by country, cities in the same country produce identical requests. Build one task per keyword × country for those engines and join it to every city in that country when reporting.
Parse a delivery
apply runs after signature verification, for webhook deliveries and reconciler fetches alike.
import re
def parse_count(text):
"""Review counts are display strings: "1,204", "26K", "1.2M"."""
m = re.fullmatch(r"\(?([\d.,]+)\s*([KkMm]?)\)?", (text or "").strip())
if not m:
return None
n = float(m.group(1).replace(",", ""))
return int(n * {"": 1, "k": 1_000, "m": 1_000_000}[m.group(2).lower()])
def apply(db, d):
task = d["task"]
with db.transaction():
done = db.execute("update cells set status = %s where task_id = %s and status <> 'COMPLETED' returning engine",
(task["status"], task["id"])).fetchone()
if not done or task["status"] != "COMPLETED":
return
r = d["response"]["result"]
engine = done[0]
if engine == "GOOGLE":
pack = r.get("localResults")
db.execute("update cells set has_pack = %s where task_id = %s", (pack is not None, task["id"]))
for p in pack or []:
db.execute("insert into pack_slots values (%s,%s,%s,%s,%s,%s,%s) on conflict do nothing",
(task["id"], p["position"], p.get("placeId"), p["title"], p.get("rating"),
parse_count(p.get("reviews")), p.get("type")))
elif engine == "AIMODE":
for p in r.get("places", []):
key = p.get("placeId") or p.get("title", "").lower()
db.execute("insert into ai_places values (%s,'aimode.places',%s,%s,%s) on conflict do nothing",
(task["id"], key, p.get("title", ""), p.get("index")))
elif engine == "CHATGPT":
for p in r.get("map", []):
key = f"{p.get('provider')}:{p.get('id')}" if p.get("id") else p.get("name", "").lower()
db.execute("insert into ai_places values (%s,'chatgpt.map',%s,%s,%s) on conflict do nothing",
(task["id"], key, p.get("name", ""), p.get("position")))
elif engine == "GEMINI":
for p in r.get("places", []):
db.execute("insert into ai_places values (%s,'gemini.places',%s,%s,%s) on conflict do nothing",
(task["id"], p.get("title", "").lower(), p.get("title", ""), p.get("index")))
text = r.get("text", "")
for (place_key, brand) in db.execute("select place_key, brand from tracked_businesses").fetchall():
if re.search(rf"\b{re.escape(brand)}\b", text, re.I):
db.execute("insert into ai_places values (%s,'text',%s,%s,null) on conflict do nothing",
(task["id"], place_key, brand))
The update where status <> 'COMPLETED' returns no row for a repeated delivery, so nothing is parsed twice.
Compute the grid metrics
-- Pack share and presence per location for our businesses, one run
select l.google_name,
avg(c.has_pack::int) as pack_presence_rate,
avg((exists (select 1 from pack_slots s join tracked_businesses b on b.place_key = s.place_id
where s.task_id = c.task_id and b.is_ours))::int)
filter (where c.has_pack) as pack_share,
min((select min(s.position) from pack_slots s join tracked_businesses b on b.place_key = s.place_id
where s.task_id = c.task_id and b.is_ours)) as best_pack_position
from cells c join locations l using (location_id)
where c.engine = 'GOOGLE' and c.run_date = '2026-09-14' and c.status = 'COMPLETED'
group by l.google_name
order by pack_share nulls first;
As a keyword-by-city heatmap, this is the report most local teams want: the gaps show where to invest in reviews, pages or listings. The same view over ai_places gives the city breakdown from AI Mode.
Cost planning
Price per async task at the Starter tier: Google Search $0.00078 for one page; AI Mode $0.00156, plus $0.00156 per expanded product cluster (up to 6) only with expandProducts; Gemini $0.0005; ChatGPT $0.00062. Async tasks carry no synchronous surcharge. Higher tiers, reached by 30-day spend, cost less. See balance and pricing.
Worked example: a 12-location franchise tracks 40 implicit-local keywords across its 12 cities plus 13 competitor cities, weekly.
| Component | Tasks per run | Cost per task | Cost per run |
|---|---|---|---|
| AI Mode by city | 40 × 25 = 1,000 | $0.00156 | $1.56 |
| Google Search, 1 page, country level (US) | 40 | $0.00078 | $0.0312 |
| ChatGPT, country level (US) | 40 | $0.00062 | $0.0248 |
| Total | 1,080 | $1.62 |
Four runs a month is $6.46; a month with five weekly runs is $8.08. Running AI Mode only in the 12 own cities saves 40 × 13 × $0.00156 = $0.8112 per run. Running Google Search on the city grid too makes its row 40 × 25 × $0.00078 = $0.78 per run.
A new account’s 100 free requests work on every engine on this page. Tiers are on pricing.
Pitfalls
Unchecked location names. Several places share a city name, and a hand-typed string may not be the canonical name you meant. Copy names from Google’s geotarget list, run one task per location, and check that the local pack and organic results look local before scheduling the full grid.
Counting reviews as numbers. reviews in the local pack is a display string such as “26K”. Parse it and keep the raw string for audit.
Name matching instead of ids. Business names change and collide (“Joe’s Plumbing”). Key Google and AI Mode places by placeId; fall back to normalized names only for text mentions, and review those matches.
Country-level AI answers reported as city-level. country is the finest targeting on ChatGPT, and a US state on Gemini. Label those cells “United States” (or the state) in reports, not “Austin”.
Prompts that restate the city. Adding the city to the prompt and to the targeting is fine, but then you are measuring the prompt, not the location. Keep one convention per series.
Uneven runs. Failed cells shrink a location’s denominator. Report completed cells per location, and resubmit tasks that failed with QUEUE_WAIT_EXCEEDED.
Which engines fit
| Engine | Local targeting | What it adds |
|---|---|---|
| Google Search | location or uule, country |
Local pack with placeId, rating, reviews, category, address; organic results beneath |
| AI Mode | location or uule, country |
Conversational answer with places and map entries carrying placeId |
| ChatGPT | country; US state coming soon |
map entries with provider ids, ratings and addresses when the answer includes businesses |
| Gemini | country; US state |
places with ratings and reviews on local-intent prompts |
Run AI Mode and Google Search on the city grid, and ChatGPT and Gemini per country (Gemini also per US state). Local AI answers covers the AI side, and the quickstart gets the first call running.
Questions
How do I target a city on Google Search?
Pass location as a Google canonical location name in City,Region,Country form, for example Austin,Texas,United States, together with country, or a pre-encoded uule string; location and uule are mutually exclusive. AI Mode takes the same fields.
Can AI engines be targeted below country level?
AI Mode can, with location or uule, and Gemini takes a US state. ChatGPT is targeted by country, in 213 countries.
Why is localResults missing from some responses?
localResults is omitted when no local pack appeared on the result page. Treat the field as optional and record its absence as a result.
How should I identify a business across runs?
Use placeId from Google's local pack and AI Mode places, which is stable across runs, rather than the displayed business name. ChatGPT map entries carry an id and a provider, so key them by both.
How much does a location grid cost?
Keywords times locations times runs times the per-task price. An AI Mode task is $0.00156, so 40 keywords across 25 cities weekly on AI Mode costs $1.56 per run. A one-page Google Search task is $0.00078.
More use cases
AI Visibility API: brand mentions and citations in AI
The GEO measurement layer: how ChatGPT, Gemini and Copilot describe a brand, with mentions, citations and the wording users see.
Rank Tracking API
Google positions, SERP features and the AI Overview for any keyword, as structured JSON from one call.
Competitor Analysis API
See which competitors ChatGPT, Gemini and Copilot recommend and cite for any prompt set, as JSON.