# GeoGridAPI · Documentation

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

Source: https://geogridapi.io/en/docs

## 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**

```bash
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**

```bash
curl https://api.geogridapi.io/v1/grids/3f2c…  -H "Authorization: Bearer gga_test_…"
```

- [Tutorial: build a geo-grid rank tracker](https://geogridapi.io/en/guides/build-geo-grid-rank-tracker): About 20 lines of Node.js or Python: a test key, polling or a signed webhook, a terminal heatmap and the PNG map.

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

| Parameter | Type | Description |
| --- | --- | --- |
| `place_id` | `string` | Google id of the listing (ChIJ…). Find it with GET /v1/places/search. |
| `query` | `string` | Listing name and city; the first match is measured. |
| `center` | `{ lat, lng }` | Optional grid center. Skips resolving the listing. |
| `keyword` | `string` | Keyword searched on Google Maps (1 to 120 characters). |
| `shape` | `string` | Grid shape: square (default), circle, diamond, hexagon, cross, ellipse or rectangle. |
| `size` | `3 \| 5 \| 7 \| 9` | Square only: side of the grid, defaults to 7. Cost: size × size points. Not allowed with another shape. |
| `density` | `low \| medium \| high` | Other 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_km` | `number` | Distance 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). |
| `language` | `string` | Search language (2-letter code). Defaults to the account language. |
| `country` | `string` | Search country (2-letter ISO code), optional. |
| `webhook_url` | `string` | Public 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**

```bash
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.

## Exact price before running

POST /v1/grids/estimate takes the same body as POST /v1/grids and returns the number of points, the credits and the balance after, without reserving or charging anything. Square: size × size points, no engine call. Other shapes: points at sea are removed (points_at_sea), so the listing is located as for a search (positive balance, 30 searches per minute); otherwise exact is false and points is an upper bound. With a test key, credits is 0.

**POST /v1/grids/estimate**

```bash
curl https://api.geogridapi.io/v1/grids/estimate \
  -H "Authorization: Bearer gga_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "query": "Plomberie Vauban, Lille", "keyword": "plombier", "shape": "circle" }'
```

**Response**

```json
{
  "object": "estimate",
  "shape": "circle", "density": "medium", "rows": 7, "cols": 7, "radius_km": 3.5,
  "points": 24, "points_at_sea": 5, "exact": true,
  "credits": 24, "balance": 9976, "balance_after": 9952, "enough": true,
  "place": { "place_id": "ChIJ…", "name": "Plomberie Vauban", "lat": 50.63, "lng": 3.06 }
}
```

## 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**

```json
{
  "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. Filters: place_id (one listing) and keyword (exact keyword, case-insensitive).

competitors: who ranks ahead. The top 3 listings summed over the map, most present first: top3_points (points where it is in the top 3), first_points (points where it ranks first), ahead_points (points where it ranks ahead of your listing).

previous: the change since the previous grid of the same listing (same keyword, format, radius and center), otherwise null. A negative changes.average_rank is better; rank_changes gives the places gained per cell (positive is better, a miss counts as 21). Included in GET /v1/grids/{id} and in the webhook.

**Excerpt**

```
"competitors": [
  { "place_id": "ChIJ…", "name": "Plomberie Express", "top3_points": 29, "first_points": 12, "ahead_points": 21 }
],
"previous": {
  "grid_id": "41d0…", "created_at": "2026-10-10T05:00:12Z",
  "average_rank": 7.2, "top3_points": 10, "found_points": 40,
  "changes": { "average_rank": -2.4, "top3_points": 14, "found_points": 8 },
  "ranks": [[…]], "rank_changes": [[2, 0, -1, …]],
  "points": { "improved": 27, "worsened": 6, "unchanged": 16 }
}
```

## The map image

GET /v1/grids/{id}/image returns the PNG image of a completed grid (1200 × 1092): the map, the rank dots, the keyword, the listing, the date and the three key figures. lang=fr or en (otherwise the account language). Ready for a client report; the MCP get_grid tool attaches it to its answer.

**GET /v1/grids/{id}/image**

```bash
curl "https://api.geogridapi.io/v1/grids/8f3c2a6e-…/image?lang=fr" \
  -H "Authorization: Bearer gga_live_…" \
  -o carte.png
```

Free. 409 grid_not_ready while the grid runs, grid_failed if it failed. 30 images per minute and 500 per day per account.

## Ranking trend

GET /v1/history returns every completed grid of the same map (same listing, keyword, format, radius and center), oldest first, and the change between the first and the last (a negative change.average_rank is better). Name the map with place_id and keyword (the latest grid sets the format) or with grid_id. limit from 1 to 100, 50 by default; has_more means older grids exist. Free.

**GET /v1/history**

```bash
curl "https://api.geogridapi.io/v1/history?place_id=ChIJ…&keyword=plombier" \
  -H "Authorization: Bearer gga_live_…"
```

**Response**

```json
{
  "object": "history",
  "reference_grid_id": "8f3c…",
  "keyword": "plombier", "shape": "square", "size": 7, "radius_km": 3,
  "data": [
    { "grid_id": "41d0…", "created_at": "2026-09-01T05:00:12Z", "average_rank": 7.2, "top3_points": 10, "found_points": 40, "points_total": 49 },
    { "grid_id": "8f3c…", "created_at": "2026-10-13T05:00:09Z", "average_rank": 4.3, "top3_points": 28, "found_points": 48, "points_total": 49 }
  ],
  "has_more": false,
  "change": { "since": "2026-09-01T05:00:12Z", "average_rank": -2.9, "top3_points": 18, "found_points": 8 }
}
```

## Several grids in one call

POST /v1/batches runs up to 50 grids with the same format: one listing with several keywords, or several listings with one keyword. The format is given once for the batch (shape, size or density, radius_km, language, country, webhook_url); each entry of items has place_id or query, an optional center, and keyword. The Idempotency-Key header is accepted.

**POST /v1/batches**

```bash
curl https://api.geogridapi.io/v1/batches \
  -H "Authorization: Bearer gga_live_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: audit-lille-2026-10" \
  -d '{
    "size": 7,
    "items": [
      { "place_id": "ChIJ…", "keyword": "plombier" },
      { "place_id": "ChIJ…", "keyword": "plombier urgence" },
      { "query": "Plomberie Wazemmes, Lille", "keyword": "plombier" }
    ]
  }'
```

The batch is refused upfront (402) when the balance does not cover credits_estimated (shape points × grids; points at sea will not be charged). Grids start as slots free up (10 running grids per account), each one as a regular grid: charged when it starts, refunded if it fails, webhook when it ends.

GET /v1/batches/{id} tracks the batch: status running then completed, counts, and for each grid its status (queued, pending, running, completed, failed), grid_id, summary or error. At most 3 launching batches per account; a grid that found no slot within 2 hours is closed without charge (expired), and revoking the key that started the batch stops its waiting grids (key_revoked).

**GET /v1/batches/{id}**

```json
{
  "id": "b7a1…", "object": "batch", "status": "running",
  "items_count": 3, "credits_estimated": 147, "credits_charged": 49,
  "counts": { "queued": 1, "pending": 0, "running": 1, "completed": 1, "failed": 0 },
  "items": [
    { "index": 0, "keyword": "plombier", "status": "completed", "grid_id": "8f3c…",
      "place": { "place_id": "ChIJ…", "name": "Plomberie Vauban" },
      "summary": { "average_rank": 4.3, "top3_points": 28, "found_points": 48 }, "error": null },
    { "index": 1, "keyword": "plombier urgence", "status": "running", "grid_id": "c21e…", … },
    { "index": 2, "keyword": "plombier", "status": "queued", "grid_id": null, … }
  ]
}
```

## Repeated grids

POST /v1/schedules reruns an existing grid every week (weekday: 1 = Monday … 7 = Sunday) or every month (month_day: 1 to 28), around 7 am Paris time. Same listing, keyword, format and radius; every run spends its credits and calls your webhook.

**POST /v1/schedules**

```bash
curl https://api.geogridapi.io/v1/schedules \
  -H "Authorization: Bearer gga_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "grid_id": "8f3c2a6e-…", "frequency": "weekly", "weekday": 1 }'
```

GET /v1/schedules lists your repeated grids; PATCH /v1/schedules/{id} with { "status": "paused" | "active" } pauses or resumes them; DELETE /v1/schedules/{id} stops them. Not enough credits: the repeated grid stops (status stopped) and you get an email; resume it after topping up.

## Find a listing and read the balance

**GET /v1/places/search**

```bash
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)**

```javascript
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)**

```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" } }.

| Status | Code | Meaning |
| --- | --- | --- |
| `400` | `invalid_body / invalid_json` | Invalid body or parameters; the message names the field. |
| `401` | `invalid_key` | Missing, unknown or revoked key. |
| `402` | `insufficient_points` | Not enough credits; required and balance are included. |
| `404` | `not_found / place_not_found` | Unknown grid for this account, or no matching listing. |
| `409` | `grid_not_ready / grid_failed / already_scheduled` | Image of a running or failed grid; grid already repeated (schedule_id included). |
| `422` | `schedule_limit` | At most 100 repeated grids per account. |
| `429` | `rate_limited / too_many_open_grids / too_many_open_batches` | Over 60 requests per minute per key, 10 grids running or 3 batches launching. |
| `503` | `engine_unavailable` | Engine 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, estimate_grid (exact price), run_grid (same shapes as the API: shape, size or density, radius_km), run_grids and get_batch (up to 50 grids in one call; dry_run gives the total cost without running anything, output "compact" returns every point as raw JSON for agents), get_grid, list_grids (past grids), get_history (trend), schedule_grid, list_schedules, update_schedule and delete_schedule (repeated grids), get_balance. A completed grid comes back with the map image, who ranks ahead and the change since the previous grid. It uses your keys: a test key to try it for free.

Six ready-made shortcuts: in Claude, + menu then GeoGridAPI; in Claude Code, /mcp__geogridapi__… commands. Local visibility audit (local_audit), Who ranks ahead (who_ranks_ahead), Several keywords (several_keywords, up to 50 keywords), Ranking trend (ranking_trend), Track my listing (track_listing, every week or month) and Client report (client_report). Each one asks for the listing and the keyword, states the price before running a grid, then answers in the keyword’s language.

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: run the command, then /mcp, geogridapi and Authenticate: you sign in in the browser, with no key to copy. On a machine without a browser (CI, server), pass a key in the header.

**Claude Code**

```bash
claude mcp add --transport http geogridapi https://api.geogridapi.io/mcp
```

**Claude Code, with a key**

```bash
claude mcp add --transport http geogridapi https://api.geogridapi.io/mcp \
  --header "Authorization: Bearer gga_live_…"
```

**Cursor (.cursor/mcp.json)**

```json
{
  "mcpServers": {
    "geogridapi": {
      "url": "https://api.geogridapi.io/mcp",
      "headers": { "Authorization": "Bearer gga_live_…" }
    }
  }
}
```

The server is listed in the official MCP Registry as io.geogridapi/geogridapi.

- [MCP server page](https://geogridapi.io/en/mcp): The 13 tools and what each one costs, install in each client, test keys and example prompts.

## SDKs and tools

Official clients and tools, open source on GitHub (github.com/geogridapi). They follow API v1 and use the same keys.

**Install**

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

- [Node.js and TypeScript SDK (npm geogridapi)](https://www.npmjs.com/package/geogridapi): Typed client with no dependencies, ESM and CommonJS. It waits for a grid to finish and rides out 429 and 5xx errors.
- [Python SDK (geogridapi-python)](https://github.com/geogridapi/geogridapi-python): Sync and async client (httpx). Until it is published on PyPI, install it from GitHub.
- [LangChain tools (langchain-geogridapi)](https://github.com/geogridapi/langchain-geogridapi): The 13 MCP server tools as LangChain tools for your agents, with a read-only toolkit. PyPI release coming.
- [Stdio MCP server (geogridapi-mcp)](https://github.com/geogridapi/geogridapi-mcp): For MCP clients without Streamable HTTP: npx -y github:geogridapi/geogridapi-mcp, with GEOGRIDAPI_API_KEY.
- [local-rank-heatmap](https://github.com/geogridapi/local-rank-heatmap): One command turns a listing and a keyword into an HTML heatmap, a PNG, a CSV and JSON.
- [n8n node (n8n-nodes-geogridapi)](https://www.npmjs.com/package/n8n-nodes-geogridapi): Maps in your n8n workflows, without code.
- [Official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.geogridapi%2Fgeogridapi): The io.geogridapi/geogridapi entry, picked up by MCP server directories.
