AnswerLine Start free

, Local SEO · Rank Tracking · Google

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

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:

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:

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:

  1. 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.
  2. Depth. One results page (positions 1 to 10) is usually enough locally; each extra page adds cost.
  3. AI Overview. Add include.aioverview only for keywords where you have seen one appear; it adds $0.00071 per request that shows one.
  4. 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

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:

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

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.

Try it on your own prompts

100 free requests on any engine, no card. One POST returns the answer, sources and citations as JSON.

Keep reading