Local rank tracking by city: location grids, location strings and credit math
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.
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
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", "device": "desktop" }'
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; putuulein the URL’s query string if you use that shape. devicedefaults todesktop.mobile,iosandandroidreturn the mobile page.
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. If mobile rankings matter, track organic positions on mobile and the pack on desktop, and report them separately.
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.
- Device. Desktop for the local pack. Add mobile for organic rankings if your traffic is mostly mobile.
- 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 2 credits per request. - 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, location and device. 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, "device": "desktop"},
"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, coloured 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
locationlike Google Search, so the same grid applies. Tasks usetaskType: "AIMODE"withpromptinstead ofquery. - ChatGPT, Perplexity, Gemini, Copilot and Grok take
countryand, for the US, a two-letterstate, not cities.
Local AI answers by country, state and city covers running those sweeps and separating geography from noise.
Credit math
Credit costs: a Google Search async task is 3 credits for one page; each extra page adds 2; include.aioverview adds 2; a synchronous call adds 2. Location does not change the price.
Formula: keywords × locations × devices × runs per month × credits per request.
| Program | Calculation | Credits per month |
|---|---|---|
| Single-location business: 15 keywords, 10 ZIPs, desktop, weekly | 15 × 10 × 1 × 4 × 3 | 1,800 |
| Same, adding mobile organic | 15 × 10 × 2 × 4 × 3 | 3,600 |
| Franchise: 25 keywords, 40 ZIPs per location × 12 locations, desktop, weekly | 25 × 480 × 4 × 3 | 144,000 |
| Same franchise, 20 core ZIPs per location | 25 × 240 × 4 × 3 | 72,000 |
| Agency spot check: 50 keywords × 5 cities, once, with AI Overview | 50 × 5 × 5 | 1,250 |
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. The free tier’s 500 credits cover 166 async location checks, enough to test whether your keywords vary by location before you commit to a grid. Plan prices are on /pricing, and cost planning covers budgeting recurring runs.
Throughput
Async tasks wait in your queue for a concurrency slot instead of failing, so a large grid takes longer on a plan with fewer slots. A task that has not started within 72 hours of creation fails uncharged, which only matters if you submit far more than your concurrency can run in that window. GET /v1/async/status shows queued and processing counts while a grid runs; see rate limits.
Pitfalls
- Tracking the pack on mobile.
localResultsis desktop only. - 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. 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. Track local pack positions with device set to desktop.
How much does a city grid cost?
Each keyword at each location is one request. A Google Search async task is 3 credits, so 25 keywords across 40 ZIP codes weekly is 25 × 40 × 3 = 3,000 credits a week. Location does not change the per-request price.