Local SEO Heatmap

Developers

API and webhooks

Read rankings into your own dashboards, start scans from your tools, and get a signed webhook when a scan finishes or a keyword drops. Included in the Agency plan.

Start

Everything is JSON over HTTPS. The base URL is https://localseoheatmap.com/api/v1. Create a key in Workspace, API and webhooks inside the app. A key belongs to one workspace and can only see that workspace, so give each client their own workspace and key.

curl https://localseoheatmap.com/api/v1/me \
  -H "Authorization: Bearer lsh_live_your_key"

Authentication

Send the key as a bearer token. Keys are shown once when you create them and stored only as a one-way hash, so we cannot recover a lost key: revoke it and make a new one. A read-only key can read; a read and scan key can also start scans, which cost credits like scans in the app. Revoking a key takes effect at once.

Limits and errors

  • 120 requests per minute per key. Over the limit you get 429 with a Retry-After header.
  • 401 missing, wrong or revoked key. 403 the plan has no API, the key is read-only, the subscription is not active, or you are out of credits (CREDITS_EXHAUSTED). 404 not found in this workspace. 400 invalid input, with an issues list.
  • Errors look like {"message":"...","code":"CREDITS_EXHAUSTED"}.

Endpoints

GET/v1/me

The workspace, its plan and the credits you can still use.

{ "workspace": { "id": "…", "name": "Acme clients" }, "plan": "AGENCY",
  "credits": { "used": 1240, "limit": 29000, "monthlyLeft": 27760, "topup": 2500, "left": 30260 } }

GET/v1/businesses

Every business in the workspace.

{ "data": [ { "id": "…", "name": "Epoch Coffee", "city": "Austin", "placeId": "0x…:0x…", "lat": 30.31, "lng": -97.72, "rating": 4.6, "reviewCount": 812 } ] }

GET/v1/businesses/{id}/keywords

Tracked keywords with their grid and schedule.

{ "data": [ { "id": "…", "keyword": "coffee shop", "gridSize": 7, "radiusKm": 5, "monitoring": "WEEKLY", "lastScanAt": "2026-10-12T09:00:04Z" } ] }

GET/v1/scans

Recent scans, newest first. Filter with businessId, keyword and limit (1 to 100, default 25).

curl "https://localseoheatmap.com/api/v1/scans?businessId=…&keyword=coffee%20shop&limit=2" -H "Authorization: Bearer …"

{ "data": [ { "id": "…", "keyword": "coffee shop", "gridSize": 7, "radiusKm": 5, "status": "DONE",
  "solvPercent": 64, "avgRank": 3.2, "rankedPins": 44, "pins": 49, "finishedAt": "2026-10-12T09:01:10Z" } ] }

GET/v1/scans/{id}

One scan with every pin and the leaderboard of who wins the area. rank is 1 to 20, or null when you are not in the top 20 at that pin.

{ "scan": { … }, "business": { "id": "…", "name": "Epoch Coffee" },
  "pins": [ { "position": 1, "row": 0, "col": 0, "lat": 30.34, "lng": -97.76, "rank": 4,
              "top": [ { "rank": 1, "name": "Houndstooth", "rating": 4.7, "reviewCount": 1200 } ] } ],
  "leaderboard": [ { "name": "Houndstooth", "top3Pins": 21, "avgRank": 2.1 } ] }

POST/v1/scans

Start a scan (needs a read and scan key). It returns 202 straight away; poll GET /v1/scans/{id} until status is DONE, or subscribe to the scan.completed webhook. Costs one credit per pin (a 7x7 grid is 49).

curl -X POST https://localseoheatmap.com/api/v1/scans -H "Authorization: Bearer …" -H "Content-Type: application/json" \
  -d '{ "businessId": "…", "keyword": "coffee shop", "gridSize": 7, "radiusKm": 5 }'

{ "id": "…", "status": "QUEUED", "pins": 49 }

Webhooks

Add an endpoint in the app and pick the events. We POST JSON to your https URL. Zapier and Make can receive them with a “Catch Hook” trigger. Your endpoint must answer with a 2xx within 10 seconds; otherwise we retry 5 more times over about 15 hours (after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours). An endpoint that fails 15 deliveries in a row is switched off, and you can switch it back on.

  • scan.completed a scan finished.
  • scan.failed a scan could not finish (credits are returned).
  • rank.dropped and rank.improved a tracked keyword moved by 10 points of top-3 coverage, or a full place in average rank, since the last scan of the same grid.
POST /your/endpoint
X-LSH-Event: rank.dropped
X-LSH-Delivery: 7f6c…
X-LSH-Signature: t=1760000000,v1=5257a869…

{ "id": "7f6c…", "type": "rank.dropped", "created": "2026-10-16T09:01:12Z",
  "data": { "business": { "id": "…", "name": "Epoch Coffee" }, "keyword": "coffee shop",
            "solvBefore": 64, "solvAfter": 41, "avgBefore": 3.2, "avgAfter": 5.1,
            "pinsLost": 11, "pinsGained": 0, "leader": "Houndstooth",
            "compareUrl": "https://localseoheatmap.com/app/b/…/compare?a=…&b=…" } }

Delivery can repeat (retries), so use X-LSH-Delivery to ignore a delivery you already handled. Order is not guaranteed.

Verify a signature

Each endpoint has a signing secret (whsec_…) you can copy from the app. The signature is an HMAC-SHA256 of {t}.{raw body}. Check it against the raw bytes before parsing, compare in constant time, and reject timestamps older than five minutes.

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret) {
  const t = /t=(\d+)/.exec(header)?.[1];
  const v1 = /v1=([0-9a-f]+)/.exec(header)?.[1];
  if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return want.length === v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(v1));
}
import hmac, hashlib, re, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    t = re.search(r"t=(\d+)", header); v1 = re.search(r"v1=([0-9a-f]+)", header)
    if not t or not v1 or abs(time.time() - int(t.group(1))) > 300:
        return False
    want = hmac.new(secret.encode(), t.group(1).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, v1.group(1))