Rank Tracking
Read stored Google, Bing and Yandex keyword ranking snapshots, history and rank-change alerts for your connected websites.
/v1/rank-tracking/rankingsFreeAPI keyThe rank-tracking endpoints return stored keyword ranking snapshots collected from the webmaster providers you have connected (Google Search Console, Bing Webmaster Tools, Yandex Webmaster). Snapshots are synced on a schedule, so this is never a live SERP check. All reads cost 0 credits, and alert settings can also be updated with an API key.
Google, Bing and Yandex report different search populations, so RankParse keeps their positions, dates and availability separate. Do not average positions across engines. Compare per engine.
Before you call
Set up tracking in the dashboard
Connect a provider, attach a property to a website and add queries. The walkthrough is in Sites and rank tracking.
Find your site ID
Call GET /v1/rank-tracking/sites and copy the site_id of the website you want.
Read rankings
Call GET /v1/rank-tracking/rankings?site_id=... for current-versus-previous summaries, or GET /v1/rank-tracking/history for a day-by-day series.
Endpoints at a glance
| Route | Purpose | Auth role |
|---|---|---|
GET /v1/rank-tracking/sites | Websites with tracking configured, per-engine property and sync freshness | Any role |
GET /v1/rank-tracking/keywords | Tracked keyword IDs and text for a site | Any role |
GET /v1/rank-tracking/rankings | Current-vs-previous ranking summaries across engines | Any role |
GET /v1/rank-tracking/history | Day-by-day history for one keyword on one engine | Any role |
GET /v1/rank-tracking/alerts | Recorded rank-change alert events | Any role |
GET /v1/rank-tracking/alert-settings | Read a site's alert configuration | Any role |
PATCH /v1/rank-tracking/alert-settings | Update a site's alert configuration | Owner, admin or member (not client) |
Every route needs your API key in X-API-Key and shares the 60 requests per minute per key rate limit. See Authentication. Responses are plain objects, not wrapped in the usual data / credits_used envelope.
Engines and availability
Bing and Yandex tracking is opt-in per account, so many accounts only have Google connected. Read engine fields carefully:
- A missing engine key in a site's
providersor a keyword'senginesmeans that engine is not connected or attached. It is never a zero or bottom rank. To tell "not enabled for your account" apart from "enabled but not attached to this site", compare againstenabled_engineson the rankings response. - A present engine with
availabilityother thanavailablecan carry nullposition,clicksandimpressions. That is a data-quality or sync-timing signal, not evidence that the keyword ranks nowhere. - Positions are provider-reported averages over the returned period, not live, neutral-location SERP ranks.
availability is one of:
| Value | Meaning |
|---|---|
available | Data was synced and is usable |
pending_first_sync | The property was attached but has not synced yet |
provider_limited | The provider did not return enough data for this keyword or day. Common for Bing and Yandex, which report a bounded set of queries |
no_observed_data | The sync ran but the provider reported nothing for this keyword in the window |
authorization_expired | The provider connection needs to be reconnected |
sync_failed | The most recent sync failed |
stale | The stored data is out of date |
not_supported | The provider does not support this for the keyword |
Treat every value other than available as a data-quality or timing signal and check data_as_of, last_success_at and error_code before drawing conclusions.
GET /v1/rank-tracking/sites
/v1/rank-tracking/sitesFreeAPI keyLists the websites configured for rank tracking, including each engine's attached primary property and sync freshness.
Example
curl "https://api.rankparse.com/v1/rank-tracking/sites" \
-H "X-API-Key: rp_your_key"import requests
res = requests.get(
"https://api.rankparse.com/v1/rank-tracking/sites",
headers={"X-API-Key": "rp_your_key"},
)
for site in res.json()["sites"]:
print(site["site_id"], site["canonical_host"], list(site["providers"]))const res = await fetch("https://api.rankparse.com/v1/rank-tracking/sites", {
headers: { "X-API-Key": "rp_your_key" },
});
const { sites } = await res.json();Response
{
"sites": [
{
"site_id": "site_abc123",
"canonical_host": "example.com",
"display_name": "example.com",
"providers": {
"google": {
"engine": "google",
"property_id": "prop_g1",
"property_url": "https://example.com/",
"is_active": true,
"last_synced_at": 1756112400000,
"data_as_of": "2026-08-25",
"latest_sync_status": "succeeded",
"latest_sync_error_code": null,
"search_appearance": {
"as_of_date": "2026-08-25",
"entries": [
{ "appearance": "AMP_BLUE_LINK", "clicks": 3, "impressions": 120, "ctr": 0.025, "position": 8.4 }
]
},
"connection": { "label": "you@example.com", "status": "active" }
}
}
}
]
}Response fields
| Field | Type | Description |
|---|---|---|
sites[].site_id | string | Pass this as site_id to every other rank-tracking route |
sites[].canonical_host | string | The website's host |
sites[].display_name | string | Display name |
sites[].providers | object | Keyed by google, bing, yandex. Only engines with an attached primary property appear |
providers.*.property_id, property_url | string | The attached primary property |
providers.*.is_active | boolean | Whether the property is active |
providers.*.last_synced_at | integer or null | Unix time in milliseconds of the last sync |
providers.*.data_as_of | string or null | YYYY-MM-DD the stored data is good through |
providers.*.latest_sync_status | string or null | Status of the latest sync run: queued, running, succeeded, partial or failed |
providers.*.latest_sync_error_code | string or null | Stable error code of the latest run. Raw provider errors are never exposed |
providers.*.search_appearance | object or null | The property's latest stored search-appearance breakdown: as_of_date and entries (appearance, clicks, impressions, ctr, position). null when it has never been synced; it comes from Google Search Console properties |
providers.*.connection | object or null | The connected account the property is read through (label, status). null when unbound, and always null for roles that cannot view connections |
A missing key (no bing above) means Bing is not connected for this site, not that it has zero rankings.
GET /v1/rank-tracking/keywords
/v1/rank-tracking/keywordsFreeAPI keyLists the tracked keyword IDs and query text for one site.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
site_id | string | Yes | From GET /v1/rank-tracking/sites | |
limit | integer | No | 100 | Keywords per page, 1 to 100. An invalid or out-of-range value falls back to 100 |
offset | integer | No | 0 | Pagination offset. An invalid value falls back to 0 |
Example
curl "https://api.rankparse.com/v1/rank-tracking/keywords?site_id=site_abc123&limit=50" \
-H "X-API-Key: rp_your_key"import requests
res = requests.get(
"https://api.rankparse.com/v1/rank-tracking/keywords",
headers={"X-API-Key": "rp_your_key"},
params={"site_id": "site_abc123", "limit": 50},
)
print(res.json()["keywords"])const params = new URLSearchParams({ site_id: "site_abc123", limit: "50" });
const res = await fetch(`https://api.rankparse.com/v1/rank-tracking/keywords?${params}`, {
headers: { "X-API-Key": "rp_your_key" },
});
const { keywords } = await res.json();Response
{
"site_id": "site_abc123",
"canonical_host": "example.com",
"keywords": [
{ "id": "kw_1", "query": "buy widgets", "created_at": 1753900800000 }
],
"total": 1,
"limit": 50,
"offset": 0
}created_at is Unix time in milliseconds. total is the number of tracked keywords on the site, so page with offset until you have them all.
GET /v1/rank-tracking/rankings
/v1/rank-tracking/rankingsFreeAPI keyReturns current-versus-previous-period ranking summaries for a site's tracked keywords, across every engine enabled for your account by default.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
site_id | string | Yes | From GET /v1/rank-tracking/sites | |
keyword_id | string | No | Restrict to one tracked keyword | |
engine | string | No | all | all, google, bing or yandex. all covers every engine enabled for your account |
period_days | integer | No | 28 | Length of the current and previous comparison windows in days, 1 to 90 |
limit | integer | No | 100 | Keywords per page, 1 to 100. An invalid or out-of-range value falls back to 100 |
offset | integer | No | 0 | Pagination offset |
Example
curl "https://api.rankparse.com/v1/rank-tracking/rankings?site_id=site_abc123&engine=all&period_days=28" \
-H "X-API-Key: rp_your_key"import requests
res = requests.get(
"https://api.rankparse.com/v1/rank-tracking/rankings",
headers={"X-API-Key": "rp_your_key"},
params={"site_id": "site_abc123", "engine": "all", "period_days": 28},
)
body = res.json()
for kw in body["keywords"]:
google = kw["engines"].get("google")
if google and google["availability"] == "available":
print(kw["query"], google["current"]["position"], google["movement"])const params = new URLSearchParams({
site_id: "site_abc123",
engine: "all",
period_days: "28",
});
const res = await fetch(`https://api.rankparse.com/v1/rank-tracking/rankings?${params}`, {
headers: { "X-API-Key": "rp_your_key" },
});
const { keywords, enabled_engines } = await res.json();To look at one keyword, add &keyword_id=kw_1. To read a single engine, set engine=google, bing or yandex.
Response
{
"site_id": "site_abc123",
"canonical_host": "example.com",
"enabled_engines": ["google", "bing", "yandex"],
"period_days": 28,
"limit": 100,
"offset": 0,
"total": 1,
"keywords": [
{
"id": "kw_1",
"query": "buy widgets",
"engines": {
"google": {
"engine": "google",
"property_id": "prop_g1",
"property_url": "https://example.com/",
"availability": "available",
"data_as_of": "2026-08-25",
"top_page": "https://example.com/widgets",
"current_period": { "start_date": "2026-07-29", "end_date": "2026-08-25" },
"previous_period": { "start_date": "2026-07-01", "end_date": "2026-07-28" },
"current": { "clicks": 12, "impressions": 340, "ctr": 0.035, "position": 6.2, "click_position": 5.9 },
"previous": { "clicks": 9, "impressions": 310, "ctr": 0.029, "position": 7.1, "click_position": 6.8 },
"movement": 0.9,
"countries": [
{ "country": "usa", "clicks": 8, "impressions": 210, "ctr": 0.038, "position": 5.8 },
{ "country": "gbr", "clicks": 2, "impressions": 60, "ctr": 0.033, "position": 7.0 }
]
},
"bing": {
"engine": "bing",
"property_id": "prop_b1",
"property_url": "https://example.com/",
"availability": "provider_limited",
"data_as_of": "2026-08-20",
"top_page": null,
"current_period": { "start_date": "2026-07-29", "end_date": "2026-08-25" },
"previous_period": { "start_date": "2026-07-01", "end_date": "2026-07-28" },
"current": { "clicks": null, "impressions": null, "ctr": null, "position": null, "click_position": null },
"previous": { "clicks": null, "impressions": null, "ctr": null, "position": null, "click_position": null },
"movement": null,
"countries": null
}
}
}
]
}Response fields
| Field | Type | Description |
|---|---|---|
enabled_engines | string[] | Engines enabled for your account, whether or not they have data for these keywords |
period_days, limit, offset | integer | Echo of the values used |
total | integer | Matching keywords, for pagination |
keywords[].id, query | string | The tracked keyword |
keywords[].engines | object | Keyed by engine. A missing engine means not connected or attached for this site |
engines.*.property_id, property_url | string | The primary property the numbers come from |
engines.*.availability | string | See Engines and availability |
engines.*.data_as_of | string or null | YYYY-MM-DD the data is good through |
engines.*.top_page | string or null | The page the provider reports as top for the query, when stored |
engines.*.current_period, previous_period | object | start_date and end_date of each window |
engines.*.current, previous | object | clicks, impressions, ctr (a 0 to 1 fraction), position (average) and click_position for that window. Each can be null |
engines.*.movement | number or null | previous.position minus current.position. Positive means the keyword moved up (a lower, better position number). null unless both periods have a position |
engines.*.countries | array or null | Up to 10 countries, by impressions, from the keyword's most recent stored sync period, with country, clicks, impressions, ctr and position. country is the provider's code. null means country data has never been synced for this keyword, which is different from "no traffic" |
In the example, bing is provider_limited with entirely null metrics. That is a data-quality gap for the period, not a signal that the keyword does not rank on Bing. yandex is absent from this keyword's engines even though it appears in enabled_engines: Yandex is enabled for the account but not attached to this website.
GET /v1/rank-tracking/history
/v1/rank-tracking/historyFreeAPI keyReturns day-by-day history for one keyword on one engine over a date range. History is always single-engine: engine is required and all is not accepted. Call rankings first to compare engines.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
site_id | string | Yes | From GET /v1/rank-tracking/sites | |
keyword_id | string | Yes | From GET /v1/rank-tracking/keywords | |
engine | string | Yes | google, bing or yandex. Must be enabled for your account | |
start_date | string | Yes | YYYY-MM-DD, inclusive | |
end_date | string | Yes | YYYY-MM-DD, inclusive. Must be a real calendar date on or after start_date; the inclusive span is capped at 400 days | |
dimension | string | No | total | total returns one row per day. device returns one row per day per device |
Example
curl "https://api.rankparse.com/v1/rank-tracking/history?site_id=site_abc123&keyword_id=kw_1&engine=google&start_date=2026-08-01&end_date=2026-08-25" \
-H "X-API-Key: rp_your_key"import requests
res = requests.get(
"https://api.rankparse.com/v1/rank-tracking/history",
headers={"X-API-Key": "rp_your_key"},
params={
"site_id": "site_abc123",
"keyword_id": "kw_1",
"engine": "google",
"start_date": "2026-08-01",
"end_date": "2026-08-25",
"dimension": "device",
},
)
for row in res.json()["rows"]:
print(row["date"], row["device"], row["position"])const params = new URLSearchParams({
site_id: "site_abc123",
keyword_id: "kw_1",
engine: "google",
start_date: "2026-08-01",
end_date: "2026-08-25",
});
const res = await fetch(`https://api.rankparse.com/v1/rank-tracking/history?${params}`, {
headers: { "X-API-Key": "rp_your_key" },
});
const { rows } = await res.json();Response
{
"site_id": "site_abc123",
"keyword_id": "kw_1",
"query": "buy widgets",
"engine": "google",
"property_id": "prop_g1",
"property_url": "https://example.com/",
"availability": "available",
"data_as_of": "2026-08-25",
"last_attempt_at": 1756112400000,
"last_success_at": 1756112400000,
"error_code": null,
"start_date": "2026-08-01",
"end_date": "2026-08-25",
"dimension": "total",
"rows": [
{ "date": "2026-08-01", "clicks": 1, "impressions": 30, "ctr": 0.033, "position": 6.8, "click_position": 6.5 },
{ "date": "2026-08-03", "clicks": 0, "impressions": 25, "ctr": 0, "position": 7.2, "click_position": null }
]
}Response fields
| Field | Type | Description |
|---|---|---|
availability, data_as_of | string or null | Same meaning as on rankings, for this keyword and engine |
last_attempt_at, last_success_at | integer or null | Unix time in milliseconds of the last sync attempt and last successful sync |
error_code | string or null | Stable error code of the last failed attempt |
dimension | string | total or device, echoing the request |
rows | array | Ascending by date (then by device) |
rows[].date | string | YYYY-MM-DD |
rows[].clicks, impressions, ctr, position, click_position | number or null | Daily metrics |
rows[].device | string | desktop, mobile or tablet. Present only when dimension=device |
- A missing calendar date (
2026-08-02above) means there was no synced observation that day. Never treat a gap as a zero or a dropped rank. - Device rows exist only where the provider synced them: always for Google, and for Yandex only when its per-device breakdown is switched on. An empty
devicehistory next to a populatedtotalhistory means "not synced per device", not "no traffic". Bing has no device breakdown.
GET /v1/rank-tracking/alerts
/v1/rank-tracking/alertsFreeAPI keyLists recorded rank-change alert events for one site, newest first. Events are recorded daily while alerts are enabled for the site (see Alert settings). This is the full history, including changes that were left out of the weekly email because their net movement cancelled out.
Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
site_id | string | Yes | From GET /v1/rank-tracking/sites | |
limit | integer | No | 100 | Events per page, 1 to 100. An invalid value falls back to 100 |
offset | integer | No | 0 | Pagination offset |
curl "https://api.rankparse.com/v1/rank-tracking/alerts?site_id=site_abc123" \
-H "X-API-Key: rp_your_key"import requests
res = requests.get(
"https://api.rankparse.com/v1/rank-tracking/alerts",
headers={"X-API-Key": "rp_your_key"},
params={"site_id": "site_abc123"},
)
for alert in res.json()["alerts"]:
print(alert["metric_date"], alert["query"], alert["alert_type"])const res = await fetch(
"https://api.rankparse.com/v1/rank-tracking/alerts?site_id=site_abc123",
{ headers: { "X-API-Key": "rp_your_key" } },
);
const { alerts } = await res.json();Response
{
"site_id": "site_abc123",
"total": 1,
"limit": 100,
"offset": 0,
"alerts": [
{
"keyword_id": "kw_1",
"query": "buy widgets",
"engine": "google",
"property_id": "prop_g1",
"metric_date": "2026-08-25",
"alert_type": "entered_top_10",
"old_position": 12.4,
"new_position": 8.9,
"detected_at": 1756166400000,
"digest_date": "2026-08-26",
"delivered_at": null
}
]
}Response fields
| Field | Type | Description |
|---|---|---|
keyword_id, query | string | The keyword |
engine, property_id | string | Engine and property the change was seen on |
metric_date | string | The synced day the change was measured on |
alert_type | string | See the table below |
old_position | number or null | The keyword's average position over the prior 7-day baseline. null for new_ranking |
new_position | number or null | Position on the freshest synced day. null for lost_ranking |
detected_at | integer | Unix time in milliseconds when it was recorded |
digest_date | string | The UTC date of the detection pass that recorded the event |
delivered_at | integer or null | When it was included in a sent weekly email. null while pending, and for events that were cancelled out or never emailed |
Alert types
Detection compares each keyword's position on the property's freshest synced day with its average position over the prior 7 days, per engine.
alert_type | Recorded when |
|---|---|
entered_top_3 | Now in positions 1 to 3, baseline outside them |
left_top_3 | Now outside positions 1 to 3 but still in the top 10, baseline inside the top 3 |
entered_top_10 | Now in positions 4 to 10, baseline outside the top 10 |
left_top_10 | Now outside the top 10, baseline inside it |
moved_up | Average position improved by at least the site's move_threshold places |
moved_down | Average position worsened by at least move_threshold places |
new_ranking | The keyword has a position now and had none in the baseline |
lost_ranking | The keyword had a baseline position and has none now, and the provider's data covers the freshest day |
Only the most specific boundary crossing is recorded per direction (entering the top 3 implies entering the top 10, so only entered_top_3 is recorded). moved_up and moved_down are recorded independently of boundary events. Only keyword and engine pairs whose availability is available generate events, and a null position is never treated as a move.
Alert settings
/v1/rank-tracking/alert-settingsFreeAPI keyAlerts are opt-in and off by default, set per website. Read the settings with GET, change them with PATCH. Both take site_id as a query parameter.
GET response
{
"site_id": "site_abc123",
"enabled": false,
"email_override": null,
"move_threshold": 5,
"last_detected_date": null
}| Field | Type | Description |
|---|---|---|
enabled | boolean | Whether detection and the weekly email are on for this site. Default false |
email_override | string or null | Address that receives this site's lines instead of your account email. null uses the account email |
move_threshold | number | Position places that make a moved_up or moved_down event. Default 5 |
last_detected_date | string or null | The last UTC date detection ran for this site |
/v1/rank-tracking/alert-settingsFreeAPI keyPATCH merges only the fields you send; anything you omit keeps its current value. Send site_id in the query string, the fields in a JSON body.
| Body field | Type | Rules |
|---|---|---|
enabled | boolean | true or false |
move_threshold | number | From 1 to 50 |
email_override | string or null | A valid email address, or null to go back to the account email |
curl -X PATCH "https://api.rankparse.com/v1/rank-tracking/alert-settings?site_id=site_abc123" \
-H "X-API-Key: rp_your_key" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "move_threshold": 5}'import requests
res = requests.patch(
"https://api.rankparse.com/v1/rank-tracking/alert-settings",
headers={"X-API-Key": "rp_your_key"},
params={"site_id": "site_abc123"},
json={"enabled": True, "move_threshold": 5},
)
print(res.json())const res = await fetch(
"https://api.rankparse.com/v1/rank-tracking/alert-settings?site_id=site_abc123",
{
method: "PATCH",
headers: {
"X-API-Key": "rp_your_key",
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled: true, move_threshold: 5 }),
},
);
console.log(await res.json());The response is the full settings object, in the same shape as GET.
Turning enabled on starts a real weekly email to the account address (or email_override) whenever meaningful changes exist. Confirm with the account owner before enabling it on someone else's behalf.
Weekly rank-change emails
Detection runs daily for each site with alerts enabled. Emails go out on Sundays at 18:00 UTC, and only when meaningful changes remain after consolidation.
- Opt-in per website. Nothing is sent until you set
enabledtotrue. - One email per recipient. If you track several websites, each recipient gets at most one digest per week covering all of their sites. A per-site
email_overridecan split sites across different recipients. - One net change per keyword. Repeated moves for the same website, keyword and engine in the week are shown as a single net change. Moves that cancel out, or that fall below the site's
move_threshold, are left out. - No meaningful changes means no email.
- Failed sends are retried. If the email service does not accept a send, those events stay pending and are included in the next weekly run.
- Positions in the email are provider-reported averages, not live SERP checks. Digests never contain tokens or API keys.
- Events older than 400 days are pruned.
The dashboard shows each website's alert state read-only under its Settings, in the Rank alerts section. Change it with the API or the MCP tool below.
Behavior and limits
- All routes cost 0 credits and are limited only by the 60 requests per minute rate limit.
site_idandkeyword_idare scoped to your workspace. An ID that belongs to someone else returns the same404as one that does not exist.limitandoffsetonkeywords,rankingsandalerts: invalid or out-of-range values fall back to the defaults instead of returning an error.period_daysis validated strictly.
Errors
All errors use the standard shape { "error", "code", "message" }. See Error codes.
| Status | Code | Meaning |
|---|---|---|
400 | invalid_rank_tracking_engine | engine is not one of the allowed values, or on /history it is missing or all |
400 | invalid_rank_tracking_period | period_days is not an integer from 1 to 90 |
400 | invalid_rank_tracking_date | start_date or end_date is missing, malformed, not a real date, out of order, or spans more than 400 days |
400 | invalid_rank_tracking_dimension | dimension is not total or device |
400 | invalid_rank_tracking_alert_settings | The PATCH body is not a JSON object, enabled is not a boolean, move_threshold is outside 1 to 50, or email_override is not an email or null |
400 | rank_tracking_provider_disabled | engine is a real engine that is not enabled for your account |
400 | rank_tracking_property_not_attached | /history only: the keyword has no primary property attached for that engine |
401 | invalid_api_key | Missing, invalid or revoked API key |
403 | capability_missing | Your workspace role cannot make this call (for example PATCH as a client) |
404 | rank_tracking_site_not_found | No site with that site_id in your workspace |
404 | rank_tracking_keyword_not_found | No keyword with that keyword_id on this site |
429 | rate_limited | More than 60 requests per minute for this key |
MCP tools
All seven tools are on the API-key MCP endpoint (mcp.rankparse.com) only. The secure OAuth connector does not include them. Reads cost 0 credits. See MCP Server.
| Tool | Route | Notes |
|---|---|---|
list_rank_tracking_sites | GET /sites | Read-only |
list_tracked_keywords | GET /keywords | Read-only |
get_keyword_rankings | GET /rankings | Read-only |
get_keyword_rank_history | GET /history | Read-only. Takes the optional dimension |
list_rank_change_alerts | GET /alerts | Read-only |
get_rank_alert_settings | GET /alert-settings | Read-only |
update_rank_alert_settings | PATCH /alert-settings | Writes; safe to repeat. Takes enabled, move_threshold and email_override |
Distinct from GSC and Google Ads
Rank tracking, Google Search Console and Google Ads are three separate data products. None of them changes the others:
- Google Search Console endpoints expose live, Google-only Search Console dimensions: pages, devices, countries, sitemaps and URL inspection.
- Google Ads keyword research returns search volume and competition, never organic rankings, for any engine.
- Rank tracking is the only family that reads stored Google, Bing and Yandex position snapshots side by side.