Docs

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:

  1. run a grid with a free test key (gga_test_…, mock data, 0 credits),
  2. wait for the result by polling, or receive it by signed webhook,
  3. print the rank matrix as a colour heatmap in the terminal and save the PNG map,
  4. 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)

The GeoGridAPI calls used in this tutorial
StepCallNotes
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/estimateFree. Same body as a grid; returns points, credits, balance_after. With a test key, credits is 0.
Run a gridPOST /v1/gridsAnswers 202 right away with the grid id and status "pending".
Read itGET /v1/grids/{id}status goes pending → running → completed or failed, in a few seconds to about 2 minutes depending on size.
Or get it pushedwebhook_url in the bodygrid.completed or grid.failed, signed with HMAC-SHA256.
Map imageGET /v1/grids/{id}/imageFree 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_charged 0.
  • 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.
Terminal
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.

tracker.mjs
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));
Terminal
export GEOGRIDAPI_API_KEY=gga_test_…
node tracker.mjs

With the test key, the Node script prints this mock grid (colours removed; the Python script prints the same summary as a dict):

Output, test key
 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.

tracker.mjs
// 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()));

A 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_points and ahead_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_rank is better.
tracker.mjs
for (const competitor of grid.competitors ?? []) {
  console.log(`${competitor.name}: ahead on ${competitor.ahead_points} points, #1 on ${competitor.first_points}`);
}
Output, live key, 20 October 2026
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 0

Step 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.

POST /v1/grids body
{
  "query": "Joe's Pizza, New York",
  "keyword": "pizza",
  "language": "en",
  "size": 7,
  "radius_km": 3,
  "webhook_url": "https://example.com/webhooks/geogridapi"
}
Webhook event (data shortened)
{
  "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.

verify.mjs
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);
}

Wiring it into Express 5 (npm install express), with the body kept raw:

server.mjs
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:

Terminal
node server.mjs
cloudflared tunnel --url http://localhost:3000   # prints a public https:// URL

Step 6: Go live

  1. (Optional) Find the exact listing: GET /v1/places/search?q=Joe's%20Pizza%20New%20York returns Joe's Pizza Broadway, Joe's Pizza on Carmine Street and more, each with a place_id. Send place_id instead of query (one or the other, never both); with query, the first match is measured.
  2. (Optional) Check the price: POST /v1/grids/estimate with the same body.
  3. Swap gga_test_… for gga_live_…. A 7 × 7 now costs 49 credits (about €0.10 on the €20 / 10,000-credit pack).
  4. Add an Idempotency-Key header to POST /v1/grids if 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:

Output, live key, 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.

Building a geo-grid on a SERP or Maps API compared with GeoGridAPI
TaskBuild it on a SERP/Maps APIGeoGridAPI
Requests per 7 × 7 grid49 searches + your orchestration1 POST + 1 GET (or webhook)
Listing matching, aggregationYou write itranks, summary, competitors
Change since last runYou store and diffprevious included
HeatmapYou render itGET /v1/grids/{id}/image
Repeat weekly/monthlyYour cronPOST /v1/schedules
Free sandboxDepends on providergga_test_ mock grids

Using an SDK or an AI agent instead

Install
npm install geogridapi
pip install "git+https://github.com/geogridapi/geogridapi-python"

Recap

The whole flow
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 report

Start with a gga_test_ key (free mock grids), then switch to gga_live_ (500 free credits at sign-up, no card).

Run your first grid.

500 free credits, no card, test keys for free.