Guide · REST API
Build a geo-grid rank tracker in ~20 lines (Node & Python)
A Google Maps ranking is not one number. A plumber can be #1 on their own street and invisible two kilometres away, because Google Maps ranks listings close to the searcher first. A geo-grid fixes that: search the same keyword from every point of a grid around the business and record the rank at each point.
GeoGridAPI does the searching: at each point, the keyword is searched on Google Maps from those coordinates, as a desktop search with no history, and the listing's position among the top 20 places is recorded. Sponsored places are not counted.
In this tutorial you'll build a small local rank tracker with the GeoGridAPI REST API:
- run a grid with a free test key (
gga_test_…, mock data, 0 credits), - wait for the result by polling, or receive it by signed webhook,
- print the rank matrix as a colour heatmap in the terminal and save the PNG map,
- switch to a live key (
gga_live_…) to measure a real listing.
You need
A GeoGridAPI account (500 free credits at sign-up, no card), a test key from the console, and Node.js 20 or later (built-in fetch) or Python 3 with requests. The scripts below were run on Node.js 20, 22 and 24 and Python 3.9.
How the API works (30 seconds)
| Step | Call | Notes |
|---|---|---|
| Find the listing (optional) | GET /v1/places/search?q=… | Free (needs a balance above zero). Returns place_id. |
| Check the price (optional) | POST /v1/grids/estimate | Free. Same body as a grid; returns points, credits, balance_after. With a test key, credits is 0. |
| Run a grid | POST /v1/grids | Answers 202 right away with the grid id and status "pending". |
| Read it | GET /v1/grids/{id} | status goes pending → running → completed or failed, in a few seconds to about 2 minutes depending on size. |
| Or get it pushed | webhook_url in the body | grid.completed or grid.failed, signed with HMAC-SHA256. |
| Map image | GET /v1/grids/{id}/image | Free PNG, 1200 × 1092 (30 per minute, 500 per day per account). |
Base URL https://api.geogridapi.io/v1, auth header Authorization: Bearer <key>. 1 credit = 1 grid point: a 7 × 7 grid costs 49 credits, a 9 × 9 costs 81. A failed grid is refunded automatically.
Step 1: Get a free test key
In the console, create a key that starts with gga_test_. Test keys return free, deterministic mock grids, so you can write and debug your whole integration without spending a credit.
- A test grid goes pending → running → completed like a live one, with mode "test" and
points_charged0. - It keeps your query as the listing name and your keyword, but its ranks are synthetic: the same listing, keyword and size always give the same map, centred on the center you send (otherwise on Paris), with example competitors.
- Its image has a Test badge and no map background, and it fires your webhook like a live grid.
export GEOGRIDAPI_API_KEY=gga_test_…Step 2: The tracker (~20 lines)
The script starts a 7 × 7 grid around Joe's Pizza in New York, polls it every 5 seconds, then prints the rank at each point in colour: green for 1–3, amber for 4–10, red for 11–20 and grey when the listing is not in the top 20, the buckets of the GeoGridAPI map legend.
const API = "https://api.geogridapi.io/v1";
const headers = { Authorization: `Bearer ${process.env.GEOGRIDAPI_API_KEY}`, "Content-Type": "application/json" };
async function call(path, init = {}) {
const res = await fetch(API + path, { headers, ...init });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${body.error?.code ?? res.statusText}: ${body.error?.message ?? ""}`);
return body;
}
// 1. Start a 7 × 7 grid (49 points): the API answers 202 with status "pending"
let grid = await call("/grids", {
method: "POST",
body: JSON.stringify({ query: "Joe's Pizza, New York", keyword: "pizza", language: "en", size: 7, radius_km: 3 }),
});
// 2. Poll every 5 s until the grid is completed or failed
while (grid.status === "pending" || grid.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 5000));
grid = await call(`/grids/${grid.id}`);
}
if (grid.status === "failed") throw new Error(`${grid.error.code}: ${grid.error.message}`);
// 3. Print the heatmap: row 0 = north, column 0 = west, null = not in the top 20
const color = (rank) => (rank === null ? 90 : rank <= 3 ? 32 : rank <= 10 ? 33 : 31);
for (const row of grid.ranks) {
console.log(row.map((rank) => `\x1b[${color(rank)}m${String(rank ?? "--").padStart(3)}\x1b[0m`).join(" "));
}
console.log(grid.place.name, grid.mode, grid.points_charged, JSON.stringify(grid.summary));import os, time, requests
API = "https://api.geogridapi.io/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['GEOGRIDAPI_API_KEY']}"
def call(method, path, **kwargs):
res = session.request(method, API + path, timeout=30, **kwargs)
if not res.ok:
error = res.json().get("error", {}) if "json" in res.headers.get("content-type", "") else {}
raise RuntimeError(f"{res.status_code} {error.get('code', res.reason)}: {error.get('message', '')}")
return res.json()
# 1. Start a 7 x 7 grid (49 points): the API answers 202 with status "pending"
grid = call("POST", "/grids", json={"query": "Joe's Pizza, New York", "keyword": "pizza", "language": "en", "size": 7, "radius_km": 3})
# 2. Poll every 5 s until the grid is completed or failed
while grid["status"] in ("pending", "running"):
time.sleep(5)
grid = call("GET", f"/grids/{grid['id']}")
if grid["status"] == "failed":
raise RuntimeError(f"{grid['error']['code']}: {grid['error']['message']}")
# 3. Print the heatmap: row 0 = north, column 0 = west, None = not in the top 20
def color(rank):
return 90 if rank is None else 32 if rank <= 3 else 33 if rank <= 10 else 31
for row in grid["ranks"]:
print(" ".join(f"\x1b[{color(r)}m{'--' if r is None else r:>3}\x1b[0m" for r in row))
print(grid["place"]["name"], grid["mode"], grid["points_charged"], grid["summary"])export GEOGRIDAPI_API_KEY=gga_test_…
node tracker.mjsexport GEOGRIDAPI_API_KEY=gga_test_…
pip install requests
python3 tracker.pyWith the test key, the Node script prints this mock grid (colours removed; the Python script prints the same summary as a dict):
17 17 17 16 17 17 17
16 8 9 8 9 9 17
16 9 4 4 3 8 17
17 8 3 2 3 9 16
16 9 3 3 3 9 17
16 8 8 9 8 9 16
17 16 17 17 16 16 17
Joe's Pizza, New York test 0 {"average_rank":11.49,"average_rank_with_misses":11.49,"top3_points":7,"found_points":49}- ranks: one row per grid line, row 0 = north, column 0 = west; null = not in the top 20.
average_rank: mean over the points where the listing appears.average_rank_with_misses: same, with misses counted as 21.top3_points/found_points: points in the top 3 / points where the listing is found at all.
Why poll every 5 seconds? The API allows 60 requests per minute per key and the GeoGridAPI console polls at the same pace. Every documented endpoint answers errors as JSON { "error": { "code", "message" } }: 429 rate_limited comes with Retry-After: 60, and 503 engine_unavailable with Retry-After: 30. The call() helper still reads the body defensively, because a wrong path or method, or a platform timeout, gets a non-JSON answer.
Step 3: Save the PNG map for a report
Every completed grid has a ready-to-share image (1200 × 1092: the map, the rank dots, the keyword, the listing, the date and the three key figures). It is free, limited to 30 images per minute and 500 per day per account. A grid that is still running returns 409 grid_not_ready.
// Add to tracker.mjs
import { writeFile } from "node:fs/promises";
const png = await fetch(`${API}/grids/${grid.id}/image?lang=en`, { headers });
if (!png.ok) throw new Error(`${png.status} ${(await png.json()).error.code}`);
await writeFile("map.png", Buffer.from(await png.arrayBuffer()));# Add to tracker.py
png = session.get(f"{API}/grids/{grid['id']}/image", params={"lang": "en"}, timeout=30)
png.raise_for_status()
with open("map.png", "wb") as file:
file.write(png.content)curl "https://api.geogridapi.io/v1/grids/$GRID_ID/image?lang=en" \
-H "Authorization: Bearer $GEOGRIDAPI_API_KEY" -o map.pngA live grid is drawn on the map; a test grid gets a plain background and a Test badge.
Step 4: Who is beating you, point by point
A completed grid also tells you who ranks ahead, which is usually the most useful part for an SEO report:
- points[]: every measured point with lat, lng, rank and the top 3 places there.
- competitors[]: up to 10 listings that appear in the top 3 across the map, with
top3_points,first_pointsandahead_points(points where they rank ahead of your listing, or where your listing is missing). - previous: the change since the previous grid of the same map (same listing, keyword, format, radius and center), otherwise null. A negative changes.
average_rankis better.
for (const competitor of grid.competitors ?? []) {
console.log(`${competitor.name}: ahead on ${competitor.ahead_points} points, #1 on ${competitor.first_points}`);
}for competitor in grid["competitors"] or []:
print(f"{competitor['name']}: ahead on {competitor['ahead_points']} points, #1 on {competitor['first_points']}")SIMÒ PIZZA - MIDTOWN: ahead on 7 points, #1 on 4
ANDIAMO PIZZA: ahead on 4 points, #1 on 3
NY Pizza Suprema: ahead on 4 points, #1 on 0Step 5: Use a webhook instead of polling
Add webhook_url (a public HTTPS URL) to the POST /v1/grids body. When the grid ends, GeoGridAPI sends a JSON POST with type grid.completed or grid.failed and data = the grid object, previous included. It tries three times (immediately, after 1 minute, then 10 minutes later); answer with a 2xx status within 10 seconds. A redirect counts as a failure, and the GeoGridAPI-Event-Id header stays the same on every try, so you can ignore duplicates.
{
"query": "Joe's Pizza, New York",
"keyword": "pizza",
"language": "en",
"size": 7,
"radius_km": 3,
"webhook_url": "https://example.com/webhooks/geogridapi"
}{
"id": "evt_d5f25c8b-e68e-48ed-ba4d-0a81f8e94908",
"object": "event",
"type": "grid.completed",
"created_at": "2026-10-20T09:55:26.517+00:00",
"data": { "id": "d5f25c8b-e68e-48ed-ba4d-0a81f8e94908", "status": "completed", "mode": "live", "summary": { "average_rank": 1, "top3_points": 9, "found_points": 9 } }
}Every call is signed: header GeoGridAPI-Signature: t=<timestamp>,v1=<signature>, where the signature is an HMAC-SHA256 of t.body (the timestamp, a dot, the raw body) with your whsec_ secret (console, Webhook section). Verify it against the raw body and reject old timestamps.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (string or Buffer)
export function verifyGeoGridSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((pair) => pair.split("=", 2)));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const received = Buffer.from(parts.v1 ?? "");
return received.length === expected.length && timingSafeEqual(Buffer.from(expected), received);
}import hashlib, hmac, time
def verify_geogrid_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(pair.split("=", 1) for pair in header.split(",") if "=" in pair)
timestamp = int(parts.get("t", "0") or 0)
if not timestamp or abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Wiring it into Express 5 (npm install express), with the body kept raw:
import express from "express";
import { verifyGeoGridSignature } from "./verify.mjs";
const app = express();
// express.raw keeps the body exactly as signed
app.post("/webhooks/geogridapi", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("GeoGridAPI-Signature") ?? "";
if (!verifyGeoGridSignature(req.body.toString("utf8"), signature, process.env.GEOGRIDAPI_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "grid.completed") console.log(event.data.id, event.data.summary);
res.sendStatus(200);
});
app.listen(3000);Test grids fire the webhook too, so you can build this part for free. To receive it on your laptop, expose the local port with a tunnel and use its URL as webhook_url:
node server.mjs
cloudflared tunnel --url http://localhost:3000 # prints a public https:// URLStep 6: Go live
- (Optional) Find the exact listing:
GET /v1/places/search?q=Joe's%20Pizza%20New%20Yorkreturns Joe's Pizza Broadway, Joe's Pizza on Carmine Street and more, each with aplace_id. Sendplace_idinstead of query (one or the other, never both); with query, the first match is measured. - (Optional) Check the price:
POST /v1/grids/estimatewith the same body. - Swap
gga_test_…forgga_live_…. A 7 × 7 now costs 49 credits (about €0.10 on the €20 / 10,000-credit pack). - Add an
Idempotency-Keyheader toPOST /v1/gridsif your job can retry: the same key returns the grid already created, with no new charge.
With a live key, the same script measured the real listing on 20 October 2026:
-- -- -- -- -- -- --
1 1 1 1 2 1 1
1 1 1 1 1 1 1
1 1 1 1 1 1 1
1 1 1 1 1 1 1
1 1 1 1 1 1 1
-- -- -- -- -- -- --
Joe's Pizza Broadway live 49 {"average_rank":1.03,"average_rank_with_misses":6.73,"top3_points":35,"found_points":35}Joe's Pizza Broadway ranks #1 almost everywhere within 2 km, and drops out of the top 20 on the northern and southern rows, 3 km away. The search language defaults to your account's; the script sets language: "en" so the result does not depend on it.
Want to track it over time? POST /v1/schedules reruns a grid every week or month (around 7 am Paris time, each run spends its credits), and GET /v1/history returns the ranking trend of the same map, for free.
Other formats: shape can be square (with size 3, 5, 7 or 9) or circle, diamond, hexagon, cross, ellipse, rectangle (with density low, medium or high, 13 to 49 points). radius_km goes from 0.5 to 30 km (1 to 30 km for non-square shapes). Points that fall in the sea are neither measured nor charged.
Why not scrape 49 Google Maps searches yourself?
You can build a geo-grid from a generic SERP or Maps API. For one 7 × 7 grid that means:
- computing 49 coordinates around the business (and dropping the ones in the sea yourself),
- sending 49 separate Maps searches, one per coordinate, and tracking 49 jobs,
- matching your listing in each result list (by place id or name) and handling “not found”,
- aggregating the matrix, the average rank, the top-3 coverage and the competitors ahead,
- storing previous runs to compute the change, rendering a heatmap, scheduling reruns, retrying failures.
With a geo-grid API this is one POST /v1/grids and one read (or one webhook): the response already contains ranks, summary, points with the top 3 at each point, competitors, previous and a PNG map. You pay per grid point (1 credit = 1 point), and a failed grid is refunded.
| Task | Build it on a SERP/Maps API | GeoGridAPI |
|---|---|---|
| Requests per 7 × 7 grid | 49 searches + your orchestration | 1 POST + 1 GET (or webhook) |
| Listing matching, aggregation | You write it | ranks, summary, competitors |
| Change since last run | You store and diff | previous included |
| Heatmap | You render it | GET /v1/grids/{id}/image |
| Repeat weekly/monthly | Your cron | POST /v1/schedules |
| Free sandbox | Depends on provider | gga_test_ mock grids |
Using an SDK or an AI agent instead
npm install geogridapi
pip install "git+https://github.com/geogridapi/geogridapi-python"- Node.js SDK (npm geogridapi)grids.run() starts a grid and waits for it, and rides out 429 and 5xx errors.
- Python SDK (geogridapi-python)Sync and async client. Until it is published on PyPI, install it from GitHub.
- MCP server for AI agentsThe same grids in Claude, ChatGPT and Cursor, at
https://api.geogridapi.io/mcp. - No codeThe n8n node, the Apify Actor, RapidAPI, and Make or Zapier through the OpenAPI spec.
Recap
POST /v1/grids → 202, status "pending"
GET /v1/grids/{id} → poll until "completed" | "failed" (or webhook_url)
GET /v1/grids/{id}/image → PNG map for the reportStart with a gga_test_ key (free mock grids), then switch to gga_live_ (500 free credits at sign-up, no card).