Documentation

A REST API and an MCP server for local rank maps. 1 credit = 1 grid point.

Quickstart

1. Create an account and a test key (gga_test_) in the console. 2. Send a Google listing, a keyword and the map shape to POST /v1/grids. 3. Read the result with GET /v1/grids/{id} or receive it by webhook.

Create a grid
curl https://api.geogridapi.io/v1/grids \
  -H "Authorization: Bearer gga_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: plombier-vauban-2026-10" \
  -d '{
    "place_id": "ChIJ…",
    "keyword": "plombier",
    "size": 7,
    "radius_km": 3,
    "webhook_url": "https://example.com/webhooks/geogridapi"
  }'

The API answers right away with 202, the grid id and status pending. Measuring takes from 20 seconds to 2 minutes depending on size.

Read a grid
curl https://api.geogridapi.io/v1/grids/3f2c…  -H "Authorization: Bearer gga_test_…"

Authentication

Every request carries a key in the Authorization: Bearer header. gga_live_ keys measure real grids and spend your credits (1 credit = 1 grid point). gga_test_ keys return free, deterministic mock grids so you can integrate without paying. A key is shown only once, when created; a revoked key stops working immediately.

Create a grid

POST /v1/grids. Send either place_id or query (name and city), never both.

ParameterType
place_idstringGoogle id of the listing (ChIJ…). Find it with GET /v1/places/search.
querystringListing name and city; the first match is measured.
center{ lat, lng }Optional grid center. Skips resolving the listing.
keywordstringKeyword searched on Google Maps (1 to 120 characters).
shapestringGrid shape: square (default), circle, diamond, hexagon, cross, ellipse or rectangle.
size3 | 5 | 7 | 9Square only: side of the grid, defaults to 7. Cost: size × size points. Not allowed with another shape.
densitylow | medium | highOther shapes only: lattice density, defaults to medium. Points: circle 13 / 29 / 49, diamond 13 / 25 / 41, hexagon 17 / 27 / 47, cross 21 / 33 / 45, ellipse 19 / 35 / 45, rectangle 15 / 35 / 45. Not allowed with the square.
radius_kmnumberDistance from center to edge: 0.5 to 30 km for the square (defaults 1.5 / 2.5 / 3.5 / 4.5 km by size), 1 to 30 km for other shapes (defaults to half the largest lattice dimension, in km).
languagestringSearch language (2-letter code). Defaults to the account language.
countrystringSearch country (2-letter ISO code), optional.
webhook_urlstringPublic HTTPS URL called when the grid ends. Defaults to the account’s.

Idempotency-Key header (optional): the same key returns the grid already created, with no new charge.

A circle grid
curl https://api.geogridapi.io/v1/grids \
  -H "Authorization: Bearer gga_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "Plomberie Vauban, Lille",
    "keyword": "plombier",
    "shape": "circle",
    "density": "medium",
    "radius_km": 3.5
  }'

On the coast, points that fall in the sea are neither measured nor charged: points_total can be lower than the shape’s count.

The grid object

ranks is a rows × cols matrix (size × size for the square): row 0 is north, column 0 is west. Each cell is the listing’s position in the Google Maps results at that point; null means not in the top 20. points details every measured point with its coordinates and the top 3 listings.

For shapes other than the square, a cell outside the shape (or at sea) is also null in ranks: points is the source of truth, a cell missing from points was not measured. shape and density echo the format; for a shape, size is max(rows, cols).

summary: average_rank (mean over points where the listing appears), average_rank_with_misses (misses counted as 21), top3_points, found_points.

Response
{
  "id": "3f2c9b1e-…",
  "object": "grid",
  "status": "completed",
  "mode": "live",
  "place": { "place_id": "ChIJ…", "name": "Plomberie Vauban", "lat": 50.63, "lng": 3.06 },
  "keyword": "plombier",
  "shape": "square",
  "density": null,
  "size": 7,
  "rows": 7,
  "cols": 7,
  "radius_km": 3,
  "points_total": 49,
  "points_charged": 49,
  "summary": { "average_rank": 4.3, "average_rank_with_misses": 4.6, "top3_points": 28, "found_points": 48 },
  "ranks": [[4, 3, 2, 2, 3, 5, 8], …, [11, 8, 6, 5, 9, 15, null]],
  "points": [{ "row": 0, "col": 0, "lat": 50.657, "lng": 3.024, "rank": 4,
               "top": [{ "rank": 1, "place_id": "ChIJ…", "name": "…" }, …] }, …],
  "error": null,
  "created_at": "2026-10-16T10:00:00Z",
  "completed_at": "2026-10-16T10:00:41Z"
}

GET /v1/grids lists your grids, newest first (limit up to 100, paginate with before = created_at of the last item), without point details.

Find a listing and read the balance

GET /v1/places/search
curl "https://api.geogridapi.io/v1/places/search?q=Plomberie%20Vauban%20Lille&language=fr" \
  -H "Authorization: Bearer gga_live_…"

GET /v1/balance returns your credits balance. Both calls are free.

Webhooks

When a grid ends, GeoGridAPI sends a JSON POST: type grid.completed or grid.failed, data = the grid object. Three attempts: immediately, after 1 minute, after 10 minutes. Answer with a 2xx status.

Every call is signed: header GeoGridAPI-Signature: t=<timestamp>,v1=<signature>, where the signature is an HMAC-SHA256 of “t.body” with your whsec_ secret (console, Webhook section). Reject old timestamps.

Verification (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the raw request body, exactly as received.
export function verifyGeoGridSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
Verification (Python)
import hashlib, hmac, time

def verify_geogrid_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", "0"))
    if not t or abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Errors and limits

Every error looks like { "error": { "code", "message" } }.

StatusCodeMeaning
400invalid_body / invalid_jsonInvalid body or parameters; the message names the field.
401invalid_keyMissing, unknown or revoked key.
402insufficient_pointsNot enough credits; required and balance are included.
404not_found / place_not_foundUnknown grid for this account, or no matching listing.
429rate_limited / too_many_open_gridsOver 60 requests per minute per key, or 10 grids running.
503engine_unavailableEngine temporarily unavailable; retry (Retry-After header).

A failed grid is refunded automatically. Price: 1 credit per grid point, credit packs from €20 excl. VAT, credits never expire, 500 free credits when you sign up.

MCP server

The MCP server gives the grid to Claude, ChatGPT, Cursor and any Streamable HTTP MCP client. Tools: find_business, run_grid (same shapes as the API: shape, size or density, radius_km), get_grid, get_balance. It uses your keys: a test key to try it for free.

Claude (claude.ai, desktop app) and ChatGPT: add a custom connector with the URL https://api.geogridapi.io/mcp. Connecting opens GeoGridAPI: log in, then allow the assistant. Grids run this way are real and spend your credits.

Claude Code
claude mcp add --transport http geogridapi https://api.geogridapi.io/mcp \
  --header "Authorization: Bearer gga_live_…"
Cursor (.cursor/mcp.json)
{
  "mcpServers": {
    "geogridapi": {
      "url": "https://api.geogridapi.io/mcp",
      "headers": { "Authorization": "Bearer gga_live_…" }
    }
  }
}