Rate limiting
Request limits per API key, the separate daily caps on PageSpeed and Google Ads, the 429 response and how to back off.
RankParse applies two kinds of limits: a per-minute limit on every API key, and a few daily caps on endpoints that depend on a third party (Google PageSpeed Insights and Google Ads). Both return 429, but with different error codes and different advice.
Per-key request limit
| Limit | 60 requests per minute per API key |
| Window | Sliding 60 seconds, not a fixed calendar minute |
| Scope | Each API key has its own counter. Two keys in one workspace do not share it. |
| Rejected requests | Do not count toward the window and are never charged |
The window slides: a request stops counting 60 seconds after it was made. If you send 60 requests in the first second, you can send again as soon as the oldest one is a minute old, not at the top of the next minute.
What counts
The limit applies to the credit-billed endpoints and to the rank tracking, Search Console, Google Ads keyword, outreach and approval inbox routes. A POST /v1/batch call is one request no matter how many domains it contains.
These account routes are not rate limited: /v1/credits, /v1/me, /v1/keys, /v1/usage and /v1/pricing, plus the connection status routes /v1/gsc/status and /v1/google-ads/status.
The 429 response
{
"error": "Too Many Requests",
"code": "rate_limited",
"message": "60 requests/minute exceeded"
}- The body is the only signal. There are no
X-RateLimit-*headers and noRetry-Afterheader on arate_limitedresponse. - No credits are charged. The limit is checked before billing.
- Authentication is checked first, so a bad key gets
401, never429. The limit is checked before billing.
Higher limits are not sold per key. If your workload needs more than 60 requests per minute, group work with /v1/batch (up to 50 domains per request) or /v1/site-explorer, which returns backlinks, authority, top pages and anchor text in one call.
Daily caps
Some endpoints also have a daily allowance that resets at 00:00 UTC. These are separate from the per-minute limit and use their own error codes, so you can tell them apart.
| Endpoint | Cap | Scope | Error code | Charged on failure? |
|---|---|---|---|---|
GET /v1/page-performance | 50 calls per day | Per person (not per workspace) | user_quota_exceeded | No |
GET /v1/page-performance | 25,000 fresh Google lookups per day | Shared by all users, including the free page-speed tool | quota_exceeded | No |
POST /v1/google-ads/keyword-ideas, POST /v1/google-ads/keyword-metrics, GET /v1/google-ads/accounts | 100 successful calls per day | Per person | google_ads_user_quota_exceeded | Free endpoint |
| Same Google Ads routes | 10,000 successful calls per day | Shared by all users | google_ads_daily_quota_exceeded | Free endpoint |
{
"error": "Too Many Requests",
"code": "user_quota_exceeded",
"message": "Maximum 50 page-performance calls per day reached"
}{
"error": "Too Many Requests",
"code": "google_ads_user_quota_exceeded",
"message": "Daily Google Ads keyword limit reached (100/day). Resets at midnight UTC.",
"retry_after": 41230
}Details worth knowing:
- PageSpeed per-user count. Every call counts toward the 50, including calls answered from the 24 hour result cache and calls that later fail.
- Global PageSpeed quota is only consumed by calls that need a fresh Google lookup. When it is exhausted,
quota_exceededincludesretry_after(seconds until 00:00 UTC). - Google Ads counts successes only. A call that fails upstream (for example
google_ads_developer_token_not_approved) does not use your allowance.retry_afteris the seconds until 00:00 UTC. - Google's own limit. If Google itself throttles the request you get
google_ads_quota_exceeded, which is a different, transient condition. Retry later. - Do not retry daily caps in a loop. They cannot succeed until the reset.
Free tools
The public /v1/tools/* routes behind the RankParse website tools use their own rolling 24 hour allowance per tool: 5 uses per day for anonymous visitors (per IP) and 25 per day for signed-in free accounts. Exceeding it returns 429 quota_exceeded with a reset_at timestamp in milliseconds. These routes are meant for the website and require a captcha token. See Free tools.
Backing off
Treat a 429 as "slow down", not as a failure. A good client does three things:
- Pace itself so it rarely hits the limit. One request per second on average is safely under 60 per minute.
- Retries
rate_limitedwith exponential backoff and jitter, capped at 60 seconds, because the window can need up to a minute to free a slot. - Stops on daily caps and waits for the reset instead of retrying.
import os
import random
import time
import requests
API = "https://api.rankparse.com/v1"
HEADERS = {"X-API-Key": os.environ["RANKPARSE_API_KEY"]}
DAILY_CAP_CODES = {
"quota_exceeded",
"user_quota_exceeded",
"google_ads_user_quota_exceeded",
"google_ads_daily_quota_exceeded",
}
def get_with_backoff(path, params=None, max_attempts=6):
for attempt in range(max_attempts):
resp = requests.get(f"{API}{path}", params=params, headers=HEADERS, timeout=60)
if resp.status_code != 429:
resp.raise_for_status()
return resp.json()
code = resp.json().get("code")
if code in DAILY_CAP_CODES:
# A daily cap will not clear by retrying. Surface it.
raise RuntimeError(f"Daily cap reached ({code}); resets at 00:00 UTC")
# rate_limited: 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s, plus jitter.
delay = min(2 ** attempt, 60) + random.random()
time.sleep(delay)
raise RuntimeError("Still rate limited after retries")
# Stay under 60 requests per minute without relying on 429s.
domains = ["github.com", "stripe.com", "vercel.com"]
for domain in domains:
result = get_with_backoff("/domain-authority", {"domain": domain})
print(domain, result["data"]["score"])
time.sleep(1)const API = "https://api.rankparse.com/v1";
const HEADERS = { "X-API-Key": process.env.RANKPARSE_API_KEY };
const DAILY_CAP_CODES = new Set([
"quota_exceeded",
"user_quota_exceeded",
"google_ads_user_quota_exceeded",
"google_ads_daily_quota_exceeded",
]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function getWithBackoff(path, params = {}, maxAttempts = 6) {
const url = `${API}${path}?${new URLSearchParams(params)}`;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const resp = await fetch(url, { headers: HEADERS });
if (resp.status !== 429) {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
const { code } = await resp.json().catch(() => ({}));
if (DAILY_CAP_CODES.has(code)) {
// A daily cap will not clear by retrying. Surface it.
throw new Error(`Daily cap reached (${code}); resets at 00:00 UTC`);
}
// rate_limited: 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s, plus jitter.
const delay = Math.min(2 ** attempt, 60) + Math.random();
await sleep(delay * 1000);
}
throw new Error("Still rate limited after retries");
}
// Stay under 60 requests per minute without relying on 429s.
for (const domain of ["github.com", "stripe.com", "vercel.com"]) {
const result = await getWithBackoff("/domain-authority", { domain });
console.log(domain, result.data.score);
await sleep(1000);
}Tips
- Batch instead of looping.
/v1/batchtakes up to 50 domains in one request and counts as one request against the limit. - Use one key per service, so a runaway job cannot starve another one of its 60 requests per minute.
- Cache on your side. Link graph data is a Common Crawl snapshot and does not change between calls. Repeating a call still costs credits. See Credits.
- Parallelism needs a limiter. If you fan out requests across workers that share a key, throttle them together. They all draw from the same 60 per minute.
- AI agents connected over MCP are subject to the same limit through their key or OAuth grant. See MCP server.