AnswerLineStart free

, Local SEO · Rank Tracking · Google

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

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:

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:

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. Device. Desktop for the local pack. Add mobile for organic rankings if your traffic is mostly mobile.
  3. Depth. One results page (positions 1 to 10) is usually enough locally; each extra page adds cost.
  4. AI Overview. Add include.aioverview only for keywords where you have seen one appear; it adds 2 credits per request.
  5. 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

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:

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

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.

Try it on your own prompts

500 free credits a month, no card. One POST returns the answer, sources and citations as JSON.

Keep reading