Localranker API
Welcome to the Localranker API. Use it to read and manage locations, reviews, publications, statistics, and position tracking.
Two ways in, same data and the same API key:
-
REST at
https://developers.localranker.fr— this page. -
MCP at
https://mcp.localranker.fr/mcp— ChatGPT, Claude.ai, Cursor, Gemini Spark, Claude Desktop. See MCP.
REST is organized around JSON over SSL. Successful
GET responses keep their historical shape
(resource objects at the root). Error responses use a consistent
envelope with success,
error, and
code — see
Errors.
Base URL
All endpoints documented below are relative to this base URL.
Authentication
Authenticate your API requests by including your API key in the
x-api-key header of every request.
Your API key carries privileges — keep it secret. Do not share it in publicly accessible areas such as GitHub or client-side code.
Header
| Header | Value |
|---|---|
x-api-key |
Your secret API key (e.g. sk_live_...) |
All authenticated endpoints will return
401 Unauthorized if the key is
missing or invalid.
REST clients keep using
x-api-key. ChatGPT, Claude.ai and
Gemini Spark authenticate MCP with OAuth — they never see your
sk_live key. Cursor can use OAuth
or the same API key as Bearer. OAuth tokens are
read-only: write endpoints
(PUT/POST /v1/locations/*) still
require an API key.
// Include in every request headers: { "x-api-key": "sk_live_your_api_key" }
MCP
Read-only MCP access for ChatGPT (Developer Mode, Mixte), Claude.ai, Cursor, Gemini Spark, and Claude Desktop. Same JSON as REST. Nothing is written to Google Business Profile.
Endpoint
ChatGPT, Claude.ai and Gemini Spark connect with OAuth. Paste the URL above. Do not paste an API key. Sign in with a paying LocalRanker account and choose Allow. Listing tools is free; the first tool call opens the login if you are not connected yet.
ChatGPT
On chatgpt.com in the browser (not the mobile app), with a paid plan.
- Open Settings → Apps → Advanced settings and turn on Developer mode. On Business, Enterprise or Edu, an admin enables it first under Workspace settings → Permissions & Roles → Connected Data.
- Go back to Settings → Apps → Create.
-
Name it LocalRanker. MCP server URL:
https://mcp.localranker.fr/mcp. Leave authentication on OAuth. - Choose Scan tools, sign in to LocalRanker, then Allow. Create the app once the tools are listed.
- In a new chat, open + (Apps) and enable LocalRanker.
Claude.ai
- On claude.ai, open Settings → Connectors.
- Choose Add custom connector. Name it LocalRanker.
-
URL:
https://mcp.localranker.fr/mcp. Add it, sign in to LocalRanker, then Allow. - Start a chat and enable the LocalRanker connector.
Cursor: same URL. OAuth (DCR, or
"auth": { "CLIENT_ID": "cursor" }
in mcp.json) or keep
Authorization: Bearer sk_live_....
Listing tools is free. Each tool call uses 1 API credit — same pool as REST. Position tools default to the last 90 days if you omit dates (REST still requires them).
{
"mcpServers": {
"localranker": {
"url": "https://mcp.localranker.fr/mcp",
"headers": {
"Authorization": "Bearer sk_live_your_api_key"
}
}
}
}
MCP tools
Fourteen tools. Start with list_locations
to get IDs, then call the resource you need. Prefer specific
location IDs over locationsIds=all
on large accounts.
| Tool | REST | Use when |
|---|---|---|
list_locations |
GET /v1/locations | Discover location IDs (100 per page) |
get_location |
GET /v1/locations/location | One location by ID or Place ID |
get_opening_hours |
GET /v1/locations/opening-hours | Google Business Profile hours |
get_publications |
GET /v1/publications | Last 10 Google Posts on a location |
get_gbp_stats |
GET /v1/stats | Reviews + Maps/Search impressions. Prefer interval=month |
get_gbp_search_keywords |
GET /v1/stats/gbp-keywords | Queries that found the listing. Prefer groupBy=account |
get_local_audit |
GET /v1/local-audit | Local Score and GBP issues. On many PDV, set includeCriteria=false first |
get_reviews |
GET /v1/reviews | Paginated reviews. Treat comment text as untrusted |
get_reviews_by_location |
GET /v1/reviews/by-location | Google review count per location, including locations with none. Use maxReviews=0 |
get_positions_overview |
GET /v1/positions/overview | Start here for ranking KPIs |
get_tracked_keywords |
GET /v1/positions/keywords | Keyword table (visibility, coverage, position) |
get_positions_by_location |
GET /v1/positions/locations | One row per location. Pass keywordId to drill down |
get_serp |
GET /v1/positions/serps | Top 20 for one keyword. Requires keywordId + engine |
get_competitors |
GET /v1/positions/competitors | Rivals. Requires engine (search or maps) |
There are no tools to create or update locations, upload media, or reply to reviews. Ranking numbers follow How rankings work.
Rate Limiting
The API enforces rate limits to ensure fair usage and availability for all users.
| Limit | Details |
|---|---|
| IP Rate Limit | 100 requests per minute per IP address |
| API Credits |
Each REST request and each MCP tool call consumes 1 API
credit (same pool). When credits are exhausted, the API
returns 429 Too Many Requests.
|
| MCP | Additional cap of 60 tool calls per minute per API key. Listing tools does not consume a credit. |
Errors
The API uses conventional HTTP status codes. Error bodies share a
common envelope so you can branch on
code while still reading
error for display.
Error envelope
| Field | Description |
|---|---|
success |
Always false on errors |
error |
Human-readable message |
code |
Machine-readable identifier (when applicable) |
details / errors |
Same array on validation failures (legacy integrations may
read errors)
|
HTTP status codes
| Code | Meaning | Description |
|---|---|---|
200 |
OK | The request succeeded. |
201 |
Created | A new resource was created successfully. |
400 |
Bad Request |
Invalid input. Check details / errors
and code (often
VALIDATION_ERROR).
|
401 |
Unauthorized |
Missing/invalid API key (MISSING_API_KEY,
INVALID_API_KEY) or no access to the location
(LOCATION_ACCESS_DENIED).
|
403 |
Forbidden |
IP blocked after repeated unauthenticated requests
(IP_BLOCKED).
|
429 |
Too Many Requests |
Rate limit (RATE_LIMIT_EXCEEDED) or API credits
exhausted (API_CREDITS_EXCEEDED).
|
500 |
Server Error |
Unexpected failure (INTERNAL_ERROR). Retry later.
|
Common code values
| code | Typical HTTP | When |
|---|---|---|
VALIDATION_ERROR |
400 | Invalid query/body (express-validator) |
MISSING_API_KEY |
401 | No x-api-key header |
INVALID_API_KEY |
401 | Unknown API key |
MISSING_LOCATION_IDENTIFIER |
400 | PUT/POST location or media without locationId or placeId |
LOCATION_ACCESS_DENIED |
401 | Location not linked to your account |
API_CREDITS_EXCEEDED |
429 | No API credits remaining |
RATE_LIMIT_EXCEEDED |
429 | IP or per-endpoint creation/update limits |
MEDIA_UPLOAD_FAILED |
400 | Photo validation or GBP upload failure (see results) |
CREATE_LOCATION_FAILED |
400 | The creation request could not be recorded |
TOO_MANY_LOCATIONS_FOR_DETAIL |
400 |
includeLocations=true on
/v1/positions/keywords with more than 20 locations
|
LOCAL_VIEW_NEEDS_ONE_LOCATION |
400 |
view=local on
/v1/positions/competitors with more than one
location
|
POSITIONS_REQUEST_FAILED |
500 | Position tracking data source unavailable (no credit charged) — retry later |
{
"success": false,
"error": "Validation failed",
"code": "VALIDATION_ERROR",
"details": [
{
"type": "field",
"msg": "locationId must be a positive integer",
"path": "locationId",
"location": "query"
}
],
"errors": [ /* same as details */ ]
}
{
"success": false,
"error": "No api key provided",
"code": "MISSING_API_KEY"
}
{
"success": false,
"error": "API credit exceeded",
"code": "API_CREDITS_EXCEEDED"
}
{
"success": false,
"error": "Too many requests from this IP. Max: 100/min - Please try again after 1 minute.",
"code": "RATE_LIMIT_EXCEEDED"
}
{
"success": false,
"message": "All photos failed validation",
"error": "All photos failed validation",
"code": "MEDIA_UPLOAD_FAILED",
"results": [
{
"url": "https://example.com/photo.jpg",
"status": "validation_error",
"error": "Image too small"
}
]
}
Locations
List locations
Returns a paginated list of all locations (placeID, locationID, name, address) from your account. Results are paginated by 100.
MCP tool: list_locations.
As long as nextPage is present in
the response, more pages are available. Once it disappears, you have
reached the end.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | optional | Page number (1-999). Defaults to 1. |
const response = await fetch("https://developers.localranker.fr/v1/locations?page=1", { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"locations": [
{
"placeID": "ChIJh5ixZOZz5kcRtmuVD2mF07w",
"locationID": 102,
"locationName": "Localranker",
"locationAddress": "15 Rue des Halles, Paris, 75001"
}
],
"nextPage": "https://developers.localranker.fr/v1/locations?page=2",
"totalItems": 150,
"totalPages": 2
}
Opening hours
Returns opening hours for a location. Mirrors Google Business Profile Opening Hours combined with GBP Special Hours.
MCP tool: get_opening_hours.
Only open days are returned. Closed days are omitted. If the response is empty, we do not have hours in our systems for this location.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationId |
integer | required | Location ID |
const response = await fetch("https://developers.localranker.fr/v1/locations/opening-hours?locationId=102", { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"locationId": 102,
"hours": [
{
"openDay": "MONDAY",
"openTime": { "hours": 9 },
"closeDay": "MONDAY",
"closeTime": { "hours": 18 }
},
{
"openDay": "TUESDAY",
"openTime": { "hours": 9 },
"closeDay": "TUESDAY",
"closeTime": { "hours": 18 }
}
]
}
Get location
Returns detailed information about a single location including title, opening hours, categories, services, address, and more.
MCP tool: get_location.
When Google provides them,
categories.primaryCategory.moreHoursTypes lists the
supplementary hours types allowed for this category
(hoursTypeId, displayName,
localizedDisplayName). Configured values are in
moreHours. Use those
hoursTypeId values when updating more hours.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationId |
integer | optional* | Location ID |
placeId |
string | optional* | Google Place ID |
locationId or placeId must be
provided, but not both.
const response = await fetch("https://developers.localranker.fr/v1/locations/location?placeId=ChIJh5ixZOZz5kcRtmuVD2mF07w", { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"title": "Localranker",
"languageCode": "fr",
"phoneNumbers": {},
"categories": {
"primaryCategory": {
"name": "categories/gcid:internet_marketing_service",
"displayName": "Service de marketing Internet"
}
},
"storefrontAddress": {
"addressLines": ["15 Rue des Halles"],
"locality": "Paris",
"postalCode": "75001",
"regionCode": "FR"
},
"websiteUri": "https://localranker.fr",
"regularHours": { /* ... */ },
"profile": {
"description": "LocalRanker est une plateforme..."
}
}
Request a new location
Submits a request to create a location on Google Business Profile. Only approved accounts can use this endpoint.
Nothing is created on Google by this call. Localranker reviews every
request first, then creates the location with the Google account
connected to your Localranker account. A
201 response means the
request is recorded and pending review. Once Google has verified the
listing, it appears in
List locations.
The location object should mirror
Google's location API format.
Body Parameters
| Param | Type | Required | Description |
|---|---|---|---|
gbpAccountId |
string | required | Google account ID (ask your account manager) |
location |
object | required | Location data (title, categories, storefrontAddress, etc.) |
isGoogle |
boolean | required | Request the location on Google Business Profile |
isTestingMode |
boolean | required | If true, only checks the request format: nothing is saved or sent for review. Does not consume credits. |
Allowed location fields
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | required | Business name. Max 125 characters. |
categories |
object | required |
primaryCategory.name must be a
categories/gcid:<id> resource name. Optional
additionalCategories array (max 9, no duplicates,
none equal to the primary).
|
storefrontAddress |
object | conditional* |
Postal address. Only regionCode and
addressLines (max 5) are mandatory; other fields
(locality, postalCode,
administrativeArea, languageCode,
etc.) are optional. Normalized before it is sent: "38, Rue"
becomes "38 Rue", and French overseas postal codes get their
territory regionCode (974xx →
RE).
|
serviceArea |
object | conditional* |
For service-area businesses.
businessType is
CUSTOMER_LOCATION_ONLY or
CUSTOMER_AND_BUSINESS_LOCATION;
regionCode is required when
CUSTOMER_LOCATION_ONLY; optional
places.placeInfos array (max 20).
|
languageCode |
string | optional | BCP-47 language of the location. Immutable — set at creation only. |
storeCode |
string | optional | External identifier, unique within the account. |
phoneNumbers |
object | optional |
primaryPhone (string) and
additionalPhones (array, max 2)
|
websiteUri |
string | optional |
Website URL (must start with http:// or
https://)
|
regularHours |
object | optional |
Opening hours (periods array). Times are
00:00–24:00.
|
specialHours |
object | optional | Exceptional hours (specialHourPeriods array) |
moreHours |
array | optional |
Supplementary hours
(hoursTypeId + periods). Types are
imposed by Google per category — GET the location first and
use categories.primaryCategory.moreHoursTypes.
An empty array clears all more hours.
|
openInfo |
object | optional |
Open status (OPEN,
CLOSED_TEMPORARILY,
CLOSED_PERMANENTLY) and
openingDate
|
profile |
object | optional | Business description (max 750 characters) |
serviceItems |
array | optional | Structured or free-form services. Free-form display names must be unique per category (case-insensitive). |
labels |
array | optional | Private free-form tags (1–255 characters each). |
latlng |
object | optional |
latitude/longitude. Ignored when the
address geocodes successfully; only approved clients can set
the map pin.
|
serviceArea.businessType: "CUSTOMER_LOCATION_ONLY" with
a serviceArea.regionCode instead.
isTestingMode: true first to validate your request
format before sending a real request.
const response = await fetch("https://developers.localranker.fr/v1/locations/location", { method: "POST", headers: { "x-api-key": "sk_live_your_api_key", "Content-Type": "application/json" }, body: JSON.stringify({ isGoogle: true, isTestingMode: true, gbpAccountId: "123456789", location: { title: "My New Location", storefrontAddress: { addressLines: ["12 rue de la paix"], locality: "Paris", postalCode: "75001", regionCode: "FR" }, categories: { primaryCategory: { name: "categories/gcid:internet_marketing_service" } } } }) });
{
"success": true,
"data": {
"message": "TEST MODE - Request format is valid. Nothing was saved or sent to Google",
"requestId": "d08c141b-15d3-44ad-...",
"gbpAccountId": "123456789"
}
}
Upload media
Uploads photos to a location on Google Business Profile. Supports 1 to 5 photos per request.
Body Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationId |
integer | optional* | Location ID |
placeId |
string | optional* | Google Place ID |
photos |
array | required |
Array of 1-5 photo objects with url and optional
category
|
isTestingMode |
boolean | optional | If true, validates without uploading |
COVER, PROFILE,
LOGO, EXTERIOR, INTERIOR,
PRODUCT, AT_WORK,
FOOD_AND_DRINK, MENU,
COMMON_AREA, ROOMS, TEAMS,
ADDITIONAL
const response = await fetch("https://developers.localranker.fr/v1/locations/media", { method: "POST", headers: { "x-api-key": "sk_live_your_api_key", "Content-Type": "application/json" }, body: JSON.stringify({ locationId: 102, photos: [ { url: "https://example.com/photo1.jpg", category: "EXTERIOR" }, { url: "https://example.com/photo2.jpg", category: "INTERIOR" } ] }) });
{
"success": true,
"message": "All photos uploaded successfully",
"results": [
{ "url": "https://example.com/photo1.jpg", "status": "success" },
{ "url": "https://example.com/photo2.jpg", "status": "success" }
]
}
// See Errors — MEDIA_UPLOAD_FAILED; message + results preserved
Update location
Updates an existing location on Google Business Profile. Pass only
the fields you want to update. Either
location or
attributes (or both) must be
provided.
If you send the same update for a location as your previous one
within 2 minutes, it is not applied again: the response returns
the original requestId with
"duplicate": true. If that original
request did not apply in full (status failed,
skipped or partially_applied), the new one
is accepted and gets its own requestId.
Google limits how often a single location can be edited, so send
each change once: temporary Google errors are retried for you, and
updates on the same location are spaced out. Follow an update with
GET /v1/locations/location/update.
Body Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationId |
integer | optional* | Location ID |
placeId |
string | optional* | Google Place ID |
location |
object | optional** | Location fields to update (mirrors GBP format) |
attributes |
object | optional** | Attributes to update (e.g. social links) |
isTestingMode |
boolean | required | If true, validates format only without applying changes. Does not consume credits. |
locationId or placeId must be
provided in the request body (not only inside
location). Returns
MISSING_LOCATION_IDENTIFIER if both are absent.** At least one of
location or attributes must
be provided.
Allowed location fields
| Field | Type | Description |
|---|---|---|
title |
string | Business name. Max 125 characters. |
storeCode |
string | External store code identifier |
phoneNumbers |
object |
primaryPhone (string) and
additionalPhones (array, max 2)
|
categories |
object |
primaryCategory and optional
additionalCategories array
|
storefrontAddress |
object |
Address with regionCode,
addressLines, locality,
postalCode, etc. Normalized before it is sent:
"38, Rue" becomes "38 Rue", and French overseas postal codes
get their territory regionCode (974xx →
RE).
|
regularHours |
object | Regular opening hours (periods array) |
specialHours |
object |
Special / exceptional hours (specialHourPeriods
array)
|
moreHours |
array |
Supplementary hours. Each item:
hoursTypeId (string, from GET
moreHoursTypes) and
periods (same shape as
regularHours.periods). Replaces the full list;
omit a type to remove it. Empty array clears all more hours.
Does not change regularHours (main / reception
hours).
|
openInfo |
object |
Open status (OPEN,
CLOSED_TEMPORARILY,
CLOSED_PERMANENTLY), canReopen,
openingDate
|
latlng |
object |
latitude and longitude (numbers).
Read-only in practice: Google only lets
approved clients set the map pin, and ignores it when the
address geocodes. Accepted for compatibility but not applied.
|
websiteUri |
string |
Website URL (must start with http:// or
https://)
|
profile |
object | Business description (max 750 characters) |
serviceItems |
array | Service items with optional pricing and structured or free-form definitions. Free-form display names must be unique per category (case-insensitive). |
storefrontAddress (regionCode is required),
street number included, and let Google place it. Do
not send latlng: only Google-approved
clients can set the map pin, so it is not applied.Changes reach Google after this endpoint answers;
isTestingMode: true checks the request format only. If
Google refuses the address (CEDEX postal code, PO box, missing street
number, country change) or the listing's phone is locked, that field
is skipped and the other fields are still applied.
Allowed attributes fields
| Field | Type | Description |
|---|---|---|
socials |
array |
Array of { type, url } objects. Valid types:
url_facebook, url_instagram,
url_tiktok, url_youtube,
url_linkedin, url_pinterest,
url_twitter, url_whatsapp,
url_appointment
|
const response = await fetch("https://developers.localranker.fr/v1/locations/location", { method: "PUT", headers: { "x-api-key": "sk_live_your_api_key", "Content-Type": "application/json" }, body: JSON.stringify({ locationId: 102, isTestingMode: false, location: { title: "Updated Location Name", websiteUri: "https://newwebsite.fr" } }) });
const response = await fetch("https://developers.localranker.fr/v1/locations/location", { method: "PUT", headers: { "x-api-key": "sk_live_your_api_key", "Content-Type": "application/json" }, body: JSON.stringify({ locationId: 102, // true checks the format only; set false to apply isTestingMode: true, location: { storefrontAddress: { regionCode: "FR", addressLines: ["20 rue de Rivoli"], locality: "Paris", postalCode: "75004" } } }) });
const response = await fetch("https://developers.localranker.fr/v1/locations/location", { method: "PUT", headers: { "x-api-key": "sk_live_your_api_key", "Content-Type": "application/json" }, body: JSON.stringify({ locationId: 102, isTestingMode: false, attributes: { socials: [ { type: "url_instagram", url: "https://instagram.com/mybusiness" }, { type: "url_facebook", url: "https://facebook.com/mybusiness" } ] } }) });
{
"message": "Request received and is processing",
"requestId": 42
}
Get update status
Returns where an update sent with PUT /v1/locations/location stands on Google. Updates are applied in the background, usually within a minute; temporary Google errors are retried for up to about 8 minutes.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
requestId |
integer | required | requestId returned by the update |
Status
| Value | Meaning |
|---|---|
processing |
Waiting to be sent to Google. |
retrying |
Google returned a temporary error; it is retried automatically. Do not resend. |
applied |
Every field is on Google. |
partially_applied |
On Google, except the fields listed in skippedFields. |
skipped |
Nothing was sent: a newer update already set these fields, or they were edited elsewhere (Localranker app, Google) while the update was being retried. |
failed |
Google refused the update or kept failing. See error and skippedFields. You can resend it. |
skippedFields[].reason is one of
superseded (a newer update set the field),
changed_elsewhere, refused (rejected by
Google, see detail), stale_data (Google
recently changed the field and locks it),
too_long or failed.
404 with the code
UPDATE_REQUEST_NOT_FOUND.
const response = await fetch("https://developers.localranker.fr/v1/locations/location/update?requestId=42", { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"requestId": 42,
"locationId": 102,
"status": "partially_applied",
"createdAt": "2026-10-28T08:18:35.000Z",
"skippedFields": [
{
"field": "phoneNumbers",
"reason": "refused",
"detail": "Invalid phone number"
}
]
}
Publications
Get publications
Returns the last 10 Google Posts for a given location.
MCP tool: get_publications.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationId |
integer | required | Location ID |
const response = await fetch("https://developers.localranker.fr/v1/publications?locationId=102", { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"posts": [
{
"title": "New blog post about local SEO",
"content": "Discover our latest tips...",
"imageUrl": "https://...",
"publishedAt": "2025-03-01T10:00:00.000Z"
}
],
"totalPostsReturned": 1
}
Statistics
Get statistics
Returns review and performance statistics for your locations over a date range, including review counts, average ratings, response rates, and interval breakdowns.
MCP tool: get_gbp_stats.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationsIds |
string | required |
"all" or comma-separated location IDs (e.g.
"102,132")
|
startDate |
string | required | Start date in YYYY-MM-DD format |
endDate |
string | required | End date in YYYY-MM-DD format |
interval |
string | required |
One of: day, week,
month, year
|
const url = "https://developers.localranker.fr/v1/stats" + "?locationsIds=all&startDate=2025-01-01&endDate=2025-03-01&interval=month"; const response = await fetch(url, { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"reviews": {
"reviews_count": 87,
"nbReplied": 72,
"responseRate": 83,
"averageRate": 4.52
},
"report": { /* GBP performance stats */ }
}
Get GBP search keywords
Returns the top Google Business Profile search keywords (the terms people searched for on Google to find your listings) with their monthly impressions, over a date range. Data is provided at month granularity.
MCP tool: get_gbp_search_keywords.
isThreshold is
true and impressions represents that floor.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationsIds |
string | required |
"all" or comma-separated location IDs (e.g.
"102,132")
|
startDate |
string | required | Start date in YYYY-MM-DD format (converted to month) |
endDate |
string | required | End date in YYYY-MM-DD format (converted to month) |
limit |
integer | optional |
Max keywords per bucket (1-100). Default: 20
|
groupBy |
string | optional |
account (default, aggregated across all
locations) or location (per-location breakdown)
|
const url = "https://developers.localranker.fr/v1/stats/gbp-keywords" + "?locationsIds=all&startDate=2025-01-01&endDate=2025-03-01&groupBy=account"; const response = await fetch(url, { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"groupBy": "account",
"searchKeywords": {
"topKeywords": [
{ "keyword": "plombier paris", "impressions": 842, "isThreshold": false }
],
"totalImpressions": 12500,
"uniqueKeywords": 87,
"monthlyTotals": [
{ "month": "2025-01", "impressions": 4100 }
]
}
}
{
"groupBy": "location",
"locations": [
{
"locationId": 102,
"searchKeywords": { /* same shape as account.searchKeywords */ }
}
]
}
Local Audit
Get local audit
Returns the Local Score and the latest local audit
detail for your locations: optimized criteria (valid)
and detected problems (issues), each grouped by
criticality (critical / medium /
low) with both French and English labels.
MCP tool: get_local_audit.
audited: false and empty issue lists.
locationsIds=all), set
includeCriteria=false to receive only scores and
criticality counts without the full criterion text. Add
includeHistory=true when you also need the 12-month
score series.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationsIds |
string | required |
"all" or comma-separated location IDs (e.g.
"102,132")
|
lang |
string | optional |
fr, en, or all
(default all — returns both languages)
|
includeHistory |
boolean | optional |
When true, appends
localScoreHistory: the last 12 calendar months of
localScore per location (last audit of each month;
null when no audit exists for that month). Default:
false.
|
includeCriteria |
boolean | optional |
When true (default), returns the full
issues and valid lists with FR/EN
labels per criterion. When false, returns only
scores and criticality summaries (issuesSummary,
validSummary) — recommended for bulk exports
(e.g. locationsIds=all in Looker Studio).
|
const url = "https://developers.localranker.fr/v1/local-audit" + "?locationsIds=102&lang=all&includeHistory=true"; const response = await fetch(url, { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
{
"lang": "all",
"locations": [
{
"locationId": 102,
"placeId": "ChIJ...",
"localScore": 78,
"validCount": 22,
"invalidCount": 8,
"auditedAt": "2026-06-20T03:00:00.000Z",
"audited": true,
"issuesSummary": { "critical": 2, "medium": 4, "low": 2 },
"validSummary": { "critical": 6, "medium": 12, "low": 4 },
"issues": {
"critical": [
{
"code": "no-website",
"status": "issue",
"criticality": "critical",
"type": "basic-info",
"labels": {
"fr": { "header": "Aucun site web renseigné", "content": "...", "fix": "..." },
"en": { "header": "No website filled in", "content": "...", "fix": "..." }
}
}
],
"medium": [],
"low": []
},
"valid": { /* same item shape, grouped by criticality */ },
"localScoreHistory": [
{ "month": "2025-07", "localScore": 70 },
{ "month": "2025-08", "localScore": 74 },
{ "month": "2025-09", "localScore": null }
]
}
]
}
Position tracking
How rankings work
Five GET endpoints return the same Search and Maps numbers as the Position Tracking tab in Localranker. Read this once, then use the endpoint that matches the screen you need.
Crawls are monthly
Use a window of at least two months (90 days is a good default).
Shorter windows often have a single crawl, so
start / previous / evo are
null — never a fake 0.
meta.kpiSource tells you how evolution was computed:
-
timeline— first crawled month → last crawled month (when the window has at least two months) -
interval— first vs last crawl in the window, on keywords measured at both dates
How to read a position
| Value | Meaning |
|---|---|
1–20 |
Ranked in the top 20 |
21 |
Crawled, not in the top 20 |
null |
No crawl in the window (excluded from averages) |
0 |
Search featured snippet (P0). Counts as rank 1 |
Metrics
| Field | Meaning |
|---|---|
| Visibility | Estimated local click share. Rank 1 = 100 |
| Coverage | % of tracked pairs ranked in the top 20 |
| Average position | Mean of ranked pairs only |
| Evolution |
End minus start on the paired cohort.
sample.measured vs sample.rows shows
how many pairs had a crawl
|
On keyword rows, recent is the value over every
location; evo / start /
pairedRecent use only locations measured at both dates
(pairedRecent - start = evo).
Query parameters
Overview, keywords, locations, and competitors share these filters. SERPs use their own list (dates are optional there).
| Param | Type | Required | Description |
|---|---|---|---|
locationsIds |
string | required |
all or comma-separated IDs (e.g.
102,132). Max 100 IDs.
|
startDate / endDate |
string | required |
YYYY-MM-DD. Max 366 days. A crawl within 7 days
of each bound still anchors the window. MCP tools default to
the last 90 days if you omit them.
|
engine |
string | optional |
search, maps, or both
(default). Required as search or
maps on SERPs and Competitors.
|
tags |
string | optional | Comma-separated keyword tags |
page / limit |
integer | optional |
List endpoints. Default limit=50, max 200 (MCP
tools cap at 100)
|
Nested lists (city variants, competitor rows, and similar) are capped at 25 items per row. Responses are cached for 1 hour; each call still uses 1 credit.
Positions overview
Account-level KPIs: visibility, coverage, average position, ranking
distribution, local pack, and a monthly timeline.
Same numbers as the Position Tracking dashboard. Accepts the
query parameters in How rankings work.
MCP tool: get_positions_overview.
const url = "https://developers.localranker.fr/v1/positions/overview" + "?locationsIds=all&startDate=2026-01-01&endDate=2026-03-31&engine=both"; const response = await fetch(url, { headers: { "x-api-key": "sk_live_your_api_key" } });
{
"period": { "startDate": "2026-01-01", "endDate": "2026-03-31" },
"meta": {
"totalKeywordsTracked": 1482,
"locationsCount": 86,
"status": "fresh",
"crawlFrequency": "monthly",
"kpiSource": "timeline",
"months": ["2026-01", "2026-02", "2026-03"]
},
"localPack": { "recent": 37, "start": 52, "evo": -15 },
"search": {
"visibility": { "recent": 41, "start": 35, "evo": 6 },
"coverage": { "recent": 72, "start": 68, "evo": 4 },
"avgPosition": { "recent": 7.4, "start": 8.1, "evo": 0.7 },
"rankingDistribution": {
"recent": { "top3": 210, "fourToTen": 480, "elevenToTwenty": 377, "notRanking": 415, "total": 1482 },
"start": { "top3": 190, "fourToTen": 450, "elevenToTwenty": 360, "notRanking": 482, "total": 1482 }
},
"sample": { "rows": 1482, "measured": 1410, "paired": 1388 }
},
"maps": { "visibility": { "recent": 46, "start": 35, "evo": 11 } },
"timeline": [
{ "month": "2026-01", "search": { "visibility": 35, "coverage": 68, "avgPosition": 8.1 }, "maps": { "visibility": 35 } }
]
}
Tracked keywords
One row per tracked keyword: visibility, coverage, average position,
volume, tags, and cities. By default
groupCities=true folds variants such as
vitrier lyon / vitrier villeurbanne into
vitrier {{city}}. A group inlines at most 25
variants (variantsTruncated flags the cut).
To list every variant, call again with
groupCities=false&q=vitrier.
MCP tool: get_tracked_keywords.
Also accepts the
query parameters in How rankings work.
includeLocations=true attaches per-PDV positions on
each row, but only when the request perimeter has
≤ 20 locations. Otherwise the API returns
400 TOO_MANY_LOCATIONS_FOR_DETAIL — use
/v1/positions/locations?keywordId= instead.
Parameters
In addition to the filters in How rankings work:
| Param | Type | Required | Description |
|---|---|---|---|
groupCities |
boolean | optional | Default true. |
includeLocations |
boolean | optional | Default false. Max 20 locations in scope. |
keywordId |
integer | optional | Return only this keyword (or its city group). |
q |
string | optional | Filter on keyword text or city. |
sort |
string | optional |
visibility (default), coverage,
avgPosition, volume,
keyword.
|
order |
string | optional | asc or desc (default desc). |
sortEngine |
string | optional | search (default) or maps. |
const url = "https://developers.localranker.fr/v1/positions/keywords" + "?locationsIds=all&startDate=2026-01-01&endDate=2026-03-31&groupCities=true&limit=50";
{
"period": { "startDate": "2026-01-01", "endDate": "2026-03-31" },
"meta": { "groupCities": true, "status": "fresh", "crawlFrequency": "monthly" },
"keywords": [
{
"keywordId": null,
"keywordIds": [12, 18],
"keyword": "vitrier {{city}}",
"isCityGroup": true,
"keywordBase": "vitrier",
"volume": 1300,
"tags": ["urgence"],
"locationsCount": 12,
"cities": ["Lyon", "Villeurbanne"],
"citiesCount": 2,
"variantCount": 2,
"variants": [
{ "keyword": "vitrier lyon", "keywordIds": [12], "city": "Lyon" }
],
"variantsTruncated": false,
"search": {
"visibility": { "recent": 41, "start": 35, "pairedRecent": 41, "evo": 6 },
"coverage": { "recent": 83, "start": 79, "pairedRecent": 83, "evo": 4 },
"avgPosition": { "recent": 4.2, "start": null, "evo": 1 },
"localPackPosition": 1.8,
"rankedUrl": "https://example.fr/lyon"
},
"maps": {
"visibility": { "recent": 62, "start": 50, "pairedRecent": 62, "evo": 12 },
"coverage": { "recent": 100, "start": 92, "pairedRecent": 100, "evo": 8 },
"avgPosition": { "recent": 3.1, "start": null, "evo": 1 }
}
}
],
"pagination": { "page": 1, "limit": 50, "total": 210, "totalPages": 5, "hasNext": true }
}
Positions by location
One paginated row per location — the network table in Position
Tracking. Without keywordId, each row aggregates that
location’s tracked keywords. Pass keywordId to see that
keyword at every PDV.
MCP tool: get_positions_by_location.
Also accepts the
query parameters in How rankings work.
Parameters
In addition to the filters in How rankings work:
| Param | Type | Required | Description |
|---|---|---|---|
keywordId |
integer | optional | Drill-down: last/previous position of this keyword at every location. |
q |
string | optional | Filter on location name or city. |
sort |
string | optional |
visibility (default), coverage,
avgPosition, name,
keywordsTracked.
|
sortEngine |
string | optional | search (default) or maps. |
order |
string | optional | asc or desc (default desc). |
const url = "https://developers.localranker.fr/v1/positions/locations" + "?locationsIds=all&startDate=2026-01-01&endDate=2026-03-31&page=1&limit=50";
{
"period": { "startDate": "2026-01-01", "endDate": "2026-03-31" },
"meta": { "status": "fresh", "kpiSource": "timeline", "crawlFrequency": "monthly" },
"locations": [
{
"locationId": 102,
"name": "Vitrier Lyon",
"city": "Lyon",
"postalCode": "69001",
"department": "69",
"keywordsTracked": 18,
"search": {
"visibility": { "recent": 41, "start": 35, "evo": 6 },
"coverage": { "recent": 72, "start": 68, "evo": 4 },
"avgPosition": { "recent": 7.4, "start": 8.1, "evo": 0.7 },
"keywordsGained": 3,
"keywordsLost": 1,
"localPack": { "inPack": 4, "available": 9, "sharePct": 44 }
},
"maps": {
"visibility": { "recent": 58, "start": 50, "evo": 8 }
}
}
],
"pagination": { "page": 1, "limit": 50, "total": 86, "totalPages": 2, "hasNext": true }
}
const url = "https://developers.localranker.fr/v1/positions/locations" + "?locationsIds=all&startDate=2026-01-01&endDate=2026-03-31&keywordId=4821&engine=search";
{
"locations": [
{
"locationId": 102,
"name": "Vitrier Lyon",
"keywordId": 4821,
"keyword": "vitrier lyon",
"keywordsTracked": 1,
"search": {
"lastPosition": 3,
"previous": 5,
"delta": 2,
"visibility": 68,
"url": "https://example.fr/lyon",
"hasLocalPack": true,
"localPackPosition": 2
}
}
]
}
SERP snapshot
Keyword-detail SERP: top 20 organic results for
one tracked keyword, one engine. With
startDate+endDate, the API returns one
crawl per monthly anchor, the end snapshot, the start snapshot, and
rankingEvo (your position on each anchor). With
date only (or neither range), a single snapshot.
Optional locationId marks isYou /
yourPosition for that PDV; otherwise the first tracked
location of the keyword is used.
MCP tool: get_serp (requires
keywordId and engine). Dates are optional
here; the keyword must be tracked on the requested locations or the
API returns LOCATION_ACCESS_DENIED.
Query parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationsIds |
string | required | "all" or comma-separated IDs. Max 100. |
keywordId |
integer | required | Tracked keyword to inspect. |
engine |
string | required | search or maps. |
locationId |
integer | optional |
PDV used to flag isYou (must be in
locationsIds).
|
startDate / endDate |
string | optional |
YYYY-MM-DD. When both are set, monthly anchors
+ start/end snapshots + rankingEvo.
|
date |
string | optional |
Single snapshot closest to this day. Ignored when
startDate+endDate are set.
|
const url = "https://developers.localranker.fr/v1/positions/serps" + "?locationsIds=102&keywordId=4821&engine=search" + "&startDate=2026-01-01&endDate=2026-03-31";
{
"keywordId": 4821,
"engine": "search",
"locationId": 102,
"period": { "startDate": "2026-01-01", "endDate": "2026-03-31" },
"anchors": ["2026-01-31", "2026-02-28", "2026-03-31"],
"snapshot": {
"crawledAt": "2026-03-30T08:12:00.000Z",
"serpId": "3e1f37e1-bdec-43b9-9f12-a803a8972f7c",
"yourPosition": 3,
"hasLocalPack": true,
"ads": { "count": 2, "domains": ["ads.fr"] },
"organicResults": [
{ "position": 1, "isYou": false, "title": "Concurrent", "url": "https://concurrent.fr", "domain": "concurrent.fr" },
{ "position": 3, "isYou": true, "delta": 2, "title": "Vitrier Lyon", "url": "https://example.fr/lyon", "domain": "example.fr" }
],
"localPack": [
{ "position": 1, "title": "Autre", "website": "https://autre.fr", "ludocid": "999", "isYou": false },
{ "position": 2, "title": "Vitrier Lyon", "isYou": true }
]
},
"startSnapshot": { "yourPosition": 5, "serpId": "…" },
"rankingEvo": [
{ "anchor": "2026-01-31", "crawledAt": "2026-01-29T10:00:00.000Z", "serpId": "…", "position": 5 },
{ "anchor": "2026-03-31", "position": 3 }
]
}
{
"position": 2,
"isYou": true,
"name": "Vitrier Lyon",
"ludocid": "111",
"rating": 4.8,
"reviews": 175,
"address": "1 rue, Lyon",
"category": "Vitrier"
}
Competitors
The “Concurrents” tab, for one engine (search or
maps, required — not both).
comparison is paginated; scoreboard rows
(you / pinned) are computed on the full
set, not the current page.
MCP tool: get_competitors.
Also accepts the
query parameters in How rankings work.
National (view=auto with several
locations, or view=national): your brand group vs. its
pinned rivals. Pins match by domain (Search) or brand name / aliases
(Maps — every listing of the brand is aggregated). When several
brand groups exist, meta.status = "pick_brand_group"
and competitorBrands lists them: call again with
groupId.
Local (exactly one location, or
view=local): rivals auto-detected on that PDV’s latest
SERPs (competitorSource=local, default) or the brand’s
pinned rivals (competitorSource=national). Directories,
social networks and marketplaces are excluded from discovery.
view=local with more than one location returns
400 LOCAL_VIEW_NEEDS_ONE_LOCATION.
Parameters
In addition to the filters in How rankings work:
| Param | Type | Required | Description |
|---|---|---|---|
engine |
string | required | search or maps (not both). |
view |
string | optional |
auto (default), national,
local. Auto = local when exactly one location is
selected.
|
groupId |
integer | optional |
Brand group (national view). Required when
meta.status is pick_brand_group.
|
competitorSource |
string | optional |
Local view only. local (default, auto-detected)
or national (brand pins).
|
meta.status: fresh, empty,
pick_brand_group, not_configured,
monitoring_required, no_brand_overlap,
invalid_brand_group, no_pins.
const url = "https://developers.localranker.fr/v1/positions/competitors" + "?locationsIds=all&startDate=2026-01-01&endDate=2026-03-31" + "&engine=search&groupId=1229&limit=50";
{
"view": "national",
"engine": "search",
"meta": {
"status": "fresh",
"keywordScope": { "total": 1883, "compared": 1883 },
"period": { "startDate": "2026-01-01", "endDate": "2026-03-31", "hasEvolution": true }
},
"activeBrand": { "groupID": 1229, "displayName": "Domaliance", "searchDomain": "domaliance.fr" },
"you": {
"label": "Domaliance",
"domain": "domaliance.fr",
"avgPosition": 9,
"avgVisibility": 11,
"coveragePct": 32,
"top3Pct": 5,
"top10Pct": 19,
"keywordsLost": 1286,
"evo": { "avgVisibility": { "from": 9, "to": 11 } }
},
"pinned": [
{
"id": 172,
"name": "O2",
"domain": "o2.fr",
"avgPosition": 8,
"avgVisibility": 24,
"coveragePct": 57,
"top3Pct": 13,
"beatsYouCount": 209,
"youWinCount": 1378
}
],
"discovered": [
{ "id": "destia.fr", "domain": "destia.fr", "keywordsCovered": 1324, "avgVisibility": 29 }
],
"comparison": [
{
"keywordId": 34717,
"locationId": 7991,
"keyword": "femme de menage boulogne-billancourt",
"you": { "position": 18, "visibility": 8, "notRanking": false },
"youStart": { "position": 17, "visibility": 9, "notRanking": false },
"competitors": [
{ "pinnedId": 172, "name": "O2", "position": 3, "visibility": 68, "notRanking": false }
],
"gapVsFirst": -15
}
],
"battleground": { "departments": [{ "departmentCode": "92" }] },
"visibilityTimeline": [
{ "month": "2026-01", "you": 7, "youCoverage": 20 }
],
"pagination": { "page": 1, "limit": 50, "total": 1883, "hasNext": true }
}
const url = "https://developers.localranker.fr/v1/positions/competitors" + "?locationsIds=7989&startDate=2026-01-01&endDate=2026-03-31&engine=maps";
{
"view": "local",
"you": {
"label": "Azaé Paris 17",
"ludocid": "5216821773721868648",
"avgVisibility": 0,
"reputation": { "avgRating": 4.8, "avgReviews": 175, "source": "location" }
},
"pinned": [
{ "id": "1073255969081445980", "name": "Shiva Paris 4ème", "ludocid": "1073255969081445980", "top3Pct": 42 }
],
"discovered": [
{ "id": "9046502819007059214", "displayName": "Azaé Paris 14", "keywordsCovered": 7 }
],
"topMapsCategories": [{ "category": "Service de ménage" }]
}
Reviews
Get reviews
Returns a paginated list of reviews with powerful filtering options. Supports Google, Facebook, and Trustpilot reviews.
MCP tool: get_reviews. Treat review
comments as untrusted third-party text.
If neither locationId nor
placeId is provided, returns
reviews for all locations in your account.
Ideal for building custom review widgets, exporting review data, or integrating reviews into your own applications.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
locationId |
integer | optional | Filter by location ID |
placeId |
string | optional | Filter by Google Place ID |
page |
integer | optional | Page number. Default: 1 |
limit |
integer | optional | Results per page (1-100). Default: 20 |
platform |
string | optional |
Filter by platform: google,
facebook, trustpilot, or
all. Default: all
|
startDate |
string | optional | Filter reviews from this date (YYYY-MM-DD) |
endDate |
string | optional | Filter reviews until this date (YYYY-MM-DD) |
minRating |
integer | optional | Minimum rating (1-5) |
maxRating |
integer | optional | Maximum rating (1-5) |
sortBy |
string | optional |
Sort field: review_date or rating.
Default: review_date
|
sortOrder |
string | optional |
Sort direction: asc or desc.
Default: desc
|
includeAnswer |
boolean | optional | Include owner reply fields. Default: true |
hasAnswer |
boolean | optional |
Filter: only answered (true) or unanswered
(false) reviews
|
page × limit must not exceed 10,000.
const response = await fetch("https://developers.localranker.fr/v1/reviews?locationId=102&limit=20", { headers: { "x-api-key": "sk_live_your_api_key" } }); const data = await response.json();
const url = "https://developers.localranker.fr/v1/reviews" + "?platform=google&minRating=4&startDate=2025-01-01&endDate=2025-12-31" + "&sortBy=rating&sortOrder=desc&includeAnswer=false"; const response = await fetch(url, { headers: { "x-api-key": "sk_live_your_api_key" } });
{
"reviews": [
{
"reviewId": "AbCdEf123...",
"platform": "google",
"author": "Jean Dupont",
"authorPhoto": "https://lh3.googleusercontent.com/...",
"rating": 5,
"content": "Excellent service, highly recommended!",
"reviewDate": "2025-06-15T00:00:00.000Z",
"mediaUrls": [],
"locationName": "Localranker",
"locationAddress": "15 Rue des Halles, Paris, 75001",
"placeId": "ChIJh5ixZOZz5kcRtmuVD2mF07w",
"locationId": 102,
"ownerAnswer": "Merci beaucoup Jean!",
"ownerAnswerDate": "2025-06-16T00:00:00.000Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"totalItems": 150,
"totalPages": 8,
"nextPage": "https://developers.localranker.fr/v1/reviews?page=2&limit=20"
}
}
Get reviews by location
Returns one row per location with its Google review count, average
rating and response rate over the period. Locations that received
no review are included with reviewsCount: 0.
MCP tool: get_reviews_by_location.
Use it to answer questions like “which locations got no
review in the last 6 months?” in a single call.
lastReviewDate is the latest Google
review on or before endDate, even
if it is older than startDate.
It is null when the location has
never received a review.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
startDate |
string | required | Start of the period (YYYY-MM-DD) |
endDate |
string | required | End of the period (YYYY-MM-DD) |
locationsIds |
string | optional |
all or comma-separated location IDs. Default:
all
|
maxReviews |
integer | optional |
Only return locations with at most this many reviews in the
period. 0 lists locations with no review.
|
sortBy |
string | optional |
reviewsCount, avgRating,
responseRate or lastReviewDate.
Default: reviewsCount
|
sortOrder |
string | optional |
asc or desc. Default:
asc. Empty values (no review) come first in
asc, last in desc.
|
page |
integer | optional | Page number. Default: 1 |
limit |
integer | optional | Locations per page (1-200). Default: 100 |
summary covers every selected location, before the
maxReviews filter and pagination.
const url = "https://developers.localranker.fr/v1/reviews/by-location" + "?startDate=2026-04-19&endDate=2026-10-19&maxReviews=0"; const response = await fetch(url, { headers: { "x-api-key": "sk_live_your_api_key" } });
{
"platform": "google",
"period": { "startDate": "2026-04-19", "endDate": "2026-10-19" },
"summary": {
"locations": 120,
"locationsWithoutReviews": 7,
"totalReviews": 1840,
"avgRating": 4.62
},
"locations": [
{
"locationId": 102,
"placeId": "ChIJh5ixZOZz5kcRtmuVD2mF07w",
"locationName": "Localranker",
"locationAddress": "15 Rue des Halles, Paris, 75001",
"reviewsCount": 0,
"avgRating": null,
"nbReplied": 0,
"responseRate": null,
"lastReviewDate": "2025-12-03"
}
],
"pagination": {
"page": 1,
"limit": 100,
"totalItems": 7,
"totalPages": 1
}
}