Errors & rate limits
Every Ranktracker API error comes back in a predictable shape, with an HTTP
status code you can branch on and a machine-readable code you can log. This
guide covers the error envelope, what each status code means, and how to handle
throttling.
If you haven't made a request yet, start with the Quickstart and Authentication guides.
The error envelope
Errors use a JSON:API-style envelope: a top-level errors array, where each
entry has a code, a numeric status, and a human-readable detail.
{
"errors": [
{
"code": "over_quota",
"status": 402,
"detail": "You have reached your plan's keyword limit."
}
]
}
Key points:
- The HTTP response status matches the
statusfield on the error. - A single response can carry multiple entries in
errors— for example, a422validation failure may return one entry per invalid field. Iterate the array rather than reading onlyerrors[0]. - Some errors include extra fields. SERP errors (see
/rest-api#reference/tag/serp), for instance, add anidalongsidecode/status/detail. Treat unknown fields as additive and don't rely on their presence.
A handful of failures — a missing or invalid API key (401) and a plan without
API access (403) — are returned with an empty body rather than the JSON
envelope. Always branch on the HTTP status code first, then parse the JSON
body when one is present.
Status codes
| Status | Meaning | When it happens |
|---|---|---|
400 | Bad request | A required parameter is missing or malformed (for example, a create call sent without its wrapping resource object). |
402 | Payment required | The request would exceed your plan's quota — for example, adding keywords beyond your keyword or data-row allowance. Nothing is created. |
401 | Unauthorized | The Authorization header is missing or the key is invalid. The body is empty. See Authentication. |
403 | Forbidden | Your plan does not have API access enabled (a missing or invalid key is a 401, see Authentication). |
404 | Not found | The resource doesn't exist, or it belongs to another account. Lookups are account-scoped, so a foreign UUID looks identical to one that was never created. The body is the envelope with code not_found and a detail naming the resource, e.g. Domain not found. |
422 | Unprocessable entity | The request was well-formed but failed validation — an invalid attribute, an empty required array, or (for SERP) data that isn't ready yet. |
429 | Too many requests | The request was throttled (rate-limited). Wait for the interval in Retry-After, then retry — see Rate limiting. |
403 — plan access
A 403 means API access is disabled for your plan — the key is valid but
your plan isn't entitled to use the REST API. A key that is missing or invalid
is a 401 instead; see Authentication for how keys
are sent.
You can check whether API access is enabled for your account without triggering
an error: GET /v1/account/usage returns an apiEnabled flag in its
attributes.
curl https://api.ranktracker.com/v1/account/usage \
-H "Authorization: tkn_usr_your_api_key_here"
{
"data": {
"id": "…",
"type": "usage",
"attributes": {
"apiEnabled": true,
"keywords": { "usage": 842, "limit": 1000, "remaining": 158 },
"dataRows": { "usage": 12050, "limit": 20000, "remaining": 7950 }
}
}
}
If apiEnabled is false, upgrade or enable API access in the Ranktracker app
at https://app.ranktracker.com. See
/rest-api#reference/tag/account for the full response shape.
402 — over quota
Adding keywords tracks the cartesian product of words and search_engines,
so a single create call can consume many keyword and data-row credits. If the
request would push you past your plan's keyword or data-row allowance, the API
responds 402 and links nothing — the operation is all-or-nothing, so you
never end up with a partially-applied batch.
{
"errors": [
{
"code": "over_quota",
"status": 402,
"detail": "Tracking these keywords would exceed your plan's data-row limit."
}
]
}
To avoid 402s, check remaining allowance before a large add — the remaining
values from GET /v1/account/usage tell you how much headroom you have. See the
Usage & quota guide for the full workflow, and
Bulk operations for batching keywords safely.
422 — validation
A 422 means the request reached the resource but failed validation. Read every
entry in the errors array to surface each problem — creating a domain with a
bad match_type, bulk-tagging with an empty keyword_uuids array, or
requesting a SERP that hasn't been captured yet all return 422.
{
"errors": [
{
"code": "invalid",
"status": 422,
"detail": "match_type is not included in the list"
}
]
}
422 from keyword create — invalid_reference_value
POST /v1/domains/{domain_uuid}/keywords validates its catalogue fields —
frequency and each entry's name, device, location and language —
before it creates anything, and reports every failure in one response.
Each entry carries the machine-readable code invalid_reference_value, a
JSON pointer to the exact field, and a meta block you can act on without
parsing prose. This is what sending a place name as the location returns:
{
"errors": [
{
"code": "invalid_reference_value",
"status": 422,
"title": "Unknown location",
"detail": "Unknown location \"Dijon, France\" for search engine \"google\". Send the Google Ads geo-target id as a string (e.g. \"2250\" for France, \"1005850\" for Dijon) or an ISO 3166-1 alpha-2 country code (e.g. \"FR\"). See https://developers.ranktracker.com/guides/reference-values. Look it up with GET /v1/locations?search_engine=google&q=Dijon",
"source": { "pointer": "/search_engines/0/location" },
"meta": {
"field": "location",
"value": "Dijon, France",
"search_engine": "google",
"lookup": "/v1/locations?search_engine=google&q=Dijon",
"docs": "https://developers.ranktracker.com/guides/reference-values"
}
}
]
}
| Field | Meaning |
|---|---|
title | Unknown <field> when the value isn't recognised (Unknown location, Unknown language, Unknown device, Unknown name, Unknown frequency), or Missing <field> when the key was absent or blank (Missing location). |
detail | Human-readable: the value as sent, the engine it was checked against, and how to find a valid value. |
source.pointer | JSON pointer into your request body: /frequency, or /search_engines/<index>/<field>. |
meta.field | The field name (frequency, name, device, location, language). |
meta.value | The value you sent, as a string (an integer location id is echoed as its digits; values longer than 100 characters are truncated). null when the key was absent from the request; a blank or whitespace-only string is echoed as sent. In both cases the title is Missing <field> and the detail starts No <field> given. |
meta.search_engine | The engine the value was checked against — present only for location and language. |
meta.allowed | The full list of accepted values — present only for frequency, name and device, which have short fixed lists. |
meta.lookup | A GET /v1/locations or GET /v1/languages query that lists the accepted values for that engine — present only for location and language. For a location, the segment of your value before its first comma is pre-filled as q ("Dijon, France" → q=Dijon) unless your whole value is an id or ISO code, or the segment is shorter than two characters (the endpoint's minimum q). A postal-code name such as "21000, France" still pre-fills q=21000, which finds the catalogue's 21000,Bourgogne-Franche-Comte,France row. |
meta.docs | Always https://developers.ranktracker.com/guides/reference-values. |
Errors are ordered frequency first, then per search_engines entry:
name, device, location, language. When an entry's name is unknown,
its location and language are not checked (they are engine-scoped),
so fix the engine and resend to see any remaining problems with that entry.
Sending the Global language 0000 is rejected with its own message
(The Global language (0000) cannot be tracked via the API; send a language code such as "fr"). See Reference values for
every accepted value and the lookup endpoints.
422 for a malformed request — validation_failed
If the body has the wrong shape rather than an unknown value — no
frequency, words that is not an array of strings or has no non-blank
string, or search_engines that is
not a non-empty array of objects — the response is a single
validation_failed error with the same source.pointer / meta.field
envelope, so one decoder handles both:
{
"errors": [
{
"code": "validation_failed",
"status": 422,
"title": "Invalid search_engines",
"detail": "search_engines must be a non-empty array of objects",
"source": { "pointer": "/search_engines" },
"meta": { "field": "search_engines" }
}
]
}
The lookup endpoints use the same two codes for their own query parameters:
an unknown search_engine or a malformed country on GET /v1/locations is
an invalid_reference_value (source.pointer /search_engine or
/country), and a q that is too short, too long or not a string is a
validation_failed (q must be at least 2 characters, q must be a string).
Rate limiting
Requests authenticated with an API key are limited to:
| Scope | Limit |
|---|---|
| Per API key | 300 requests / minute |
| Per IP address | 600 requests / minute |
The per-key budget is shared between the REST endpoints and the GraphQL endpoint — they draw on the same 300 requests a minute, so a client using both should count them together.
The per-IP limit is a backstop, deliberately set well above the per-key limit. A single well-behaved key never reaches it; it exists to catch one source spreading traffic across several keys to multiply its ceiling.
The 429 response
When you exceed a limit, the request is rejected with 429 Too Many Requests:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37
RateLimit-Limit: 300
RateLimit-Remaining: 0
RateLimit-Reset: 37
The body is the standard error envelope, with the machine-readable code
rate_limited:
{
"errors": [
{
"status": 429,
"code": "rate_limited",
"detail": "Rate limit exceeded. Retry after 37 seconds."
}
]
}
| Header | Meaning |
|---|---|
Retry-After | Seconds to wait before retrying. |
RateLimit-Limit | The ceiling of the limit you hit. |
RateLimit-Remaining | Requests left in the window — always 0 on a 429. |
RateLimit-Reset | Seconds until the window resets (same value as Retry-After). |
:::warning These headers appear on 429 responses only
RateLimit-* and Retry-After are sent only when you are throttled, not on
successful responses. There is no way to poll your remaining quota ahead of
time — a successful 200 tells you nothing about how close you are to the
limit. Budget your request rate client-side rather than trying to read it back.
:::
Handling a 429
The limit is a fixed window, not a sliding one, and Retry-After tells you
exactly how many seconds remain in the current window. So the right response to
a 429 is simply to wait that long and try again — guessing with exponential
backoff makes you wait longer than necessary, and retrying sooner just burns
another rejected request.
Keep a fallback delay for the rare case where the header is missing or unparseable, cap the number of attempts so a persistent problem doesn't loop forever, and add a little jitter so parallel workers don't all resume in lockstep.
import time, random, requests
BASE_URL = "https://api.ranktracker.com"
HEADERS = {"Authorization": "tkn_usr_your_api_key_here"}
def retry_after_seconds(resp, attempt):
"""Seconds to wait, from Retry-After — falling back to 2^attempt."""
try:
return max(0, int(resp.headers["Retry-After"]))
except (KeyError, TypeError, ValueError):
return 2 ** attempt
def get_with_retry(path, max_retries=5):
for attempt in range(max_retries):
resp = requests.get(f"{BASE_URL}{path}", headers=HEADERS)
if resp.status_code != 429:
resp.raise_for_status()
return resp.json()
# Throttled: wait out the window, plus jitter so parallel
# workers don't all resume on the same tick.
time.sleep(retry_after_seconds(resp, attempt) + random.random())
raise RuntimeError(f"Still throttled after {max_retries} attempts")
domains = get_with_retry("/v1/domains")
const BASE_URL = "https://api.ranktracker.com";
const HEADERS = { Authorization: "tkn_usr_your_api_key_here" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Seconds to wait, from Retry-After — falling back to 2^attempt.
function retryAfterSeconds(resp, attempt) {
const seconds = Number.parseInt(resp.headers.get("Retry-After"), 10);
return Number.isNaN(seconds) ? 2 ** attempt : Math.max(0, seconds);
}
async function getWithRetry(path, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const resp = await fetch(`${BASE_URL}${path}`, { headers: HEADERS });
if (resp.status !== 429) {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
// Throttled: wait out the window, plus jitter so parallel
// workers don't all resume on the same tick.
await sleep(retryAfterSeconds(resp, attempt) * 1000 + Math.random() * 1000);
}
throw new Error(`Still throttled after ${maxRetries} attempts`);
}
const domains = await getWithRetry("/v1/domains");
:::tip Stay well under the limit
The cheapest way to avoid 429s is to not hit them: page through large result
sets with a sensible per_page (see Pagination), batch
writes where an endpoint supports it (see
Bulk operations), and cache responses that don't
change on every request.
:::
Quick reference
- Branch on the HTTP status first, then parse the JSON
errorsarray. 400/422are your bug — fix the request; they won't succeed on retry.402is a quota wall — check Usage & quota before large adds.401is your key — verify theAuthorizationheader.403is plan access — checkapiEnabled.404is account-scoped — a foreign UUID looks the same as a missing one.429is transient — wait theRetry-Afterinterval, then retry.