Local rank tracking by city: grids, locations and costs
Local rank tracking by city means running the same query as if searched from each place you care about and recording positions per place. With this API that is one Google Search request per keyword per location, where the location is a canonical name from Google’s geo targets list, such as Austin,Texas,United States or 78701,Texas,United States. The page you get back includes the organic results and, on desktop, the local pack with each business’s position. AI Mode takes location too, so the same grid runs there (step 5).
Definitions
- Location grid: the set of places a keyword is tracked from. In geo-grid tools that means coordinates; here it means named targets such as cities, postal codes or neighborhoods.
- Canonical location name: Google’s comma-separated name for a geo target, as listed in the Google Ads API geo targets file, for example
Chicago Loop,Illinois,United States. - Local pack: the map-backed block of local businesses Google shows on local-intent queries. In this API it is
result.localResults[]. - Local visibility: the share of grid points where a business appears in the local pack (or the top N organic results) for a keyword.
The request
The request is POST /v1/monitor/google with country and location:
curl -X POST https://api.answerline.dev/v1/monitor/google \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "emergency plumber", "country": "US", "location": "Austin,Texas,United States" }'
Rules from the OpenAPI reference:
locationis a Google canonical location name inCity,Region,Countryform. Use it alongsidecountry.uuleis the alternative: a pre-encoded Google location string. Sendlocationoruule, never both.- Neither
locationnoruulecan be combined with theurlrequest shape, and a URL carryinguuleis refused too.
The local pack is desktop only
localResults is documented as desktop only, and omitted from the result when no local pack appears, so treat it as optional rather than expecting an empty array. Each entry has position, title, placeId, rating, reviews, type, address, phone, hours, description and links (website, directions). localResultsMoreLink points to Google’s expanded list when the page renders one.
Step 1: build the grid from Google’s geo targets
Google publishes its geo targets as a CSV with the Google Ads API (geotargets, checked 2026-09-17). The file dated 2026-08-12 has 273,666 rows with columns Criteria ID, Name, Canonical Name, Parent ID, Country Code, Target Type and Status.
For the United States in that file:
| Target type | Rows |
|---|---|
| Postal Code | 33,371 |
| City | 19,676 |
| Neighborhood | 4,911 |
| County | 3,098 |
| Municipality | 1,947 |
Grid granularity is therefore the granularity of named targets: ZIP codes give the densest standard grid in most US metros, neighborhoods exist for some cities, and cities or municipalities cover suburbs.
Things we noticed in the file that affect grids:
- Postal codes and neighborhoods have states as parents, not cities.
78701,Texas,United StatesandChicago Loop,Illinois,United Statesboth point to their state. You cannot list a city’s ZIP codes by parent id; select them by prefix or by your own ZIP-to-metro mapping. - Status matters. 2,938 rows were marked
Removal Planned. Filter toActive. - Names are not always what you expect. Toronto appears as a
Districtand Manchester, England as aPost town. Look names up; do not construct them.
Before scheduling a grid on postal codes or neighborhoods, spot-check a few names with single synchronous calls and confirm the results look local.
Selecting a ZIP grid
import csv
def zip_grid(csv_path: str, prefixes: tuple[str, ...], state: str) -> list[str]:
with open(csv_path, encoding="utf-8") as f:
rows = csv.DictReader(f)
return sorted(
r["Canonical Name"]
for r in rows
if r["Country Code"] == "US"
and r["Target Type"] == "Postal Code"
and r["Status"] == "Active"
and r["Canonical Name"].startswith(prefixes)
and r["Canonical Name"].endswith(f",{state},United States")
)
austin = zip_grid("geotargets-2026-08-12.csv", ("787",), "Texas")
print(len(austin), austin[:3]) # 46 active 787xx targets in the 2026-08-12 file
46 postal codes is a full grid for that prefix. Most tracking does not need all of them. Sample by where customers are: the ZIPs around each location of a multi-location business, plus a ring of ZIPs at the edge of its service area.
Step 2: choose what to track per location
A grid multiplies every other choice, so decide them before you size it:
- Keywords. Local-intent keywords only. “Plumber”, “emergency plumber”, “water heater repair” belong on a grid; “how to fix a leaking tap” does not, because its SERP barely moves with location. Check this empirically: run a keyword at three distant grid points, and if the top 10 is the same, track it nationally instead.
- Depth. One results page (positions 1 to 10) is usually enough locally; each extra page adds cost.
- AI Overview. Add
include.aioverviewonly for keywords where you have seen one appear; it adds $0.00071 per request that shows one. - Frequency. Local packs change more slowly than news; weekly is a common default, daily for competitive categories or during a campaign.
Step 3: submit the grid as async batches
One task per keyword and location. POST /v1/async/task/batch takes 1 to 500 tasks per request; each is admitted independently.
import os
from datetime import date
import requests
API = "https://api.answerline.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['ANSWERLINE_API_KEY']}"}
def grid_tasks(keywords: dict[str, str], locations: list[str], run: str, webhook: str) -> list[dict]:
return [
{
"taskType": "GOOGLE",
"payload": {"query": text, "country": "US", "location": loc},
"idempotencyKey": f"local:{run}:{kid}:{loc}",
"webhook": {"url": webhook},
}
for kid, text in keywords.items()
for loc in locations
]
def submit(tasks: list[dict]) -> dict[str, str]:
ids = {}
for i in range(0, len(tasks), 500):
chunk = tasks[i:i + 500]
resp = requests.post(f"{API}/v1/async/task/batch", headers=HEADERS, json=chunk, timeout=60)
resp.raise_for_status()
for item in resp.json()["results"]:
key = chunk[item["index"]]["idempotencyKey"]
if item["success"]:
ids[key] = item["task"]["id"]
elif item["error"]["code"] != "RESOURCE_ALREADY_EXISTS":
print(key, item["error"]["code"])
return ids
keywords = {"plumber": "plumber", "emergency": "emergency plumber", "water-heater": "water heater repair"}
ids = submit(grid_tasks(keywords, austin[:20], date.today().isoformat(), "https://example.com/hooks/local"))
Keys built from run date, keyword id and location make the weekly submission safe to re-run. Keep the returned task ids so a reconciliation pass can fetch any result whose webhook never arrived. Rank tracking with async batches covers the collection and reconciliation side in full.
Step 4: turn results into local rankings
For each result, record your positions in the pack and in organic results:
from urllib.parse import urlparse
MY_PLACE_IDS = {"ChIJ...your-place-id"}
MY_HOST = "example-plumbing.com"
def host(url: str) -> str:
h = urlparse(url).netloc.lower()
return h[4:] if h.startswith("www.") else h
def local_row(keyword_id: str, location: str, result: dict) -> dict:
pack = result.get("localResults", [])
mine_pack = [p["position"] for p in pack if p.get("placeId") in MY_PLACE_IDS]
organic = result.get("organicResults", [])
mine_organic = [r["position"] for r in organic if host(r["link"]) == MY_HOST]
return {
"keyword": keyword_id,
"location": location,
"pack_present": bool(pack),
"pack_position": min(mine_pack) if mine_pack else None,
"organic_position": min(mine_organic) if mine_organic else None,
"pack_competitors": [p["title"] for p in pack if p.get("placeId") not in MY_PLACE_IDS],
}
Match businesses on placeId, not title. Names vary (“Acme Plumbing” vs “Acme Plumbing & Drain”), and franchises share names across locations.
Metrics per keyword
- Pack coverage: share of grid points where you are in the pack at all.
- Top-3 coverage: share of grid points with
pack_positionof 3 or better. - Average pack position where present: read it with coverage, never alone.
- Organic coverage: share of grid points with an organic position in the top 10.
- Competitor frequency: how many grid points each competitor appears at; high-frequency businesses are your local competitors.
Present coverage on a map: one dot per grid point, colored by position band. The pattern (strong near the office, missing across the river) is what clients and location managers act on.
Step 5: add AI answers per market
Local rankings now include AI surfaces. Two options in this API:
- AI Mode takes
locationtoo, so the same grid runs there. Tasks usetaskType: "AIMODE"withpromptinstead ofquery, at $0.00206 each. - ChatGPT, Gemini, Copilot, Perplexity and Grok take
country, not cities. ChatGPT and Gemini answer from 213 countries, the others from any country. Gemini also takes a USstate.
Local AI answers by country, state and city covers running those sweeps and separating geography from noise.
Cost math
Costs at the Starter tier: a Google Search request is $0.00103 for one page; each extra page adds $0.00071; include.aioverview adds $0.00071; an AI Mode request is $0.00206. Location does not change the price.
Formula: keywords × locations × runs per month × price per request.
| Program | Calculation | Cost per month (Starter prices) |
|---|---|---|
| Single-location business: 15 keywords, 10 ZIPs, weekly | 15 × 10 × 4 × $0.00103 | $0.618 |
| Same grid on AI Mode | 15 × 10 × 4 × $0.00206 | $1.24 |
| Franchise: 25 keywords, 40 ZIPs per location × 12 locations, weekly | 25 × 480 × 4 × $0.00103 | $49.44 |
| Same franchise, 20 core ZIPs per location | 25 × 240 × 4 × $0.00103 | $24.72 |
| Agency spot check: 50 keywords × 5 cities, once, with AI Overview | 50 × 5 × $0.00174 | $0.435 |
Grid density is the lever. Halving the grid halves the bill; a well-chosen 20-point grid usually tells the same story as 40 points. A new account’s 100 free requests cover 100 Google Search tasks; use them to test whether your keywords vary by location at a few points before you commit to a grid. Tier prices are on /pricing, and cost planning covers budgeting recurring runs.
Throughput
Async tasks wait in your queue (about 100,000 typical tasks per account) instead of failing, and there is no concurrency cap. A task that has not started within 4 hours of creation fails uncharged. GET /v1/async/status shows queued and processing counts while a grid runs; see rate limits.
Pitfalls
- Constructed location names. Use canonical names from the geo targets file, filtered to
Active. - Sending
locationwithuuleorurl. Both combinations are rejected. - Matching businesses by name. Use
placeId. - Grids on non-local keywords. Test variation across a few distant points before paying for a grid.
- Comparing grid points collected days apart. Run each grid in one window so differences are geographic.
- One run treated as truth. Local packs shift; trend coverage over several runs before reporting a change.
See the local rank tracking use case and the Google Search engine page to start.
Questions
How do I get Google rankings for a specific city with this API?
Send a Google Search request with country and a location set to Google's canonical location name, such as Austin,Texas,United States. AI Mode takes location the same way. location and uule are mutually exclusive, and neither can be combined with the url request shape.
Where do valid location strings come from?
From the Canonical Name column of Google's geo targets file, published with the Google Ads API. The file dated 2026-08-12 lists 273,666 targets, including cities, postal codes and neighborhoods.
Can I track rankings at latitude and longitude grid points?
This API has no coordinate parameter. Grids are built from named targets instead: cities, postal codes or neighborhoods from Google's geo targets list, or a pre-encoded uule string.
Does the local pack come back on mobile requests?
No. The localResults field is documented as desktop only, and it is omitted when no local pack appears.
How much does a city grid cost?
Each keyword at each location is one request. A Google Search request is $0.00103 on every tier, so 25 keywords across 40 ZIP codes weekly is 25 × 40 × $0.00103 = $1.03 a week. Location does not change the per-request price.