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
429with aRetry-Afterheader. 401missing, wrong or revoked key.403the plan has no API, the key is read-only, the subscription is not active, or you are out of credits (CREDITS_EXHAUSTED).404not found in this workspace.400invalid input, with anissueslist.- 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.completeda scan finished.scan.faileda scan could not finish (credits are returned).rank.droppedandrank.improveda 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))