Rank tracking
Track where a domain ranks in Google's top 100 for many keywords, by country, city and device. Uses POST /v1/rank, the num=100 depth bundle (7 credits), POST /v1/batches at half price, and signed webhooks.
A rank tracker asks one question per keyword, location and device: at what position does my domain appear? SerpKite gives you the pieces to answer it cheaply at scale:
POST /v1/rank: send a keyword and a domain, get back the best position and every matching URL in the top 100.- Depth bundle:
num: 100returns the top 100 organic results in one call for 7 credits instead of 10 pages at 1 credit each. - Canonical domains: every result has a
domain(host withoutwww.) and a resolvedlink, so matching doesn’t need URL parsing or redirect decoding. - Batches:
POST /v1/batchesqueues up to 100 keywords per call at half price, delivered asynchronously. - Webhooks: batch results are POSTed to your endpoint, signed with your webhook secret.
Find a domain’s position
POST /v1/rank does the matching for you. It checks the top 100 by default (num can be 10, 20, 30, 50 or 100) and is priced like the same search depth, so 7 credits for 100:
curl https://api.serpkite.com/v1/rank \
-H "Authorization: Bearer $SERPKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"q":"espresso machine","domain":"example.com","country":"us","language":"en"}'{
"request": { "endpoint": "rank", "engine": "google", "q": "espresso machine", "domain": "example.com", "country": "us", "language": "en", "num": 100 },
"domain": "example.com",
"position": 4,
"matches": [
{ "position": 4, "title": "Espresso Machines | Example", "link": "https://www.example.com/espresso-machines" },
{ "position": 37, "title": "Espresso machine guide", "link": "https://shop.example.com/guides/espresso" }
],
"checked": 100,
"meta": { "request_id": "req_01J8ZK4M6Q2V7", "credits_used": 7, "cached": false }
}
position is the best organic position, or null when the domain isn’t in the checked results. Subdomains match (shop.example.com counts for example.com), and matches lists every URL the domain ranks with.
Do it yourself from a search
If you want the whole top 100 as well (for share of voice or SERP features), call /v1/search with num: 100 and project the fields a tracker needs:
curl https://api.serpkite.com/v1/search \
-H "Authorization: Bearer $SERPKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"q":"espresso machine","country":"us","num":100,"fields":"results.position,results.domain,results.link,results.title"}'Then scan results:
def position(result: dict, domain: str) -> int | None:
domain = domain.removeprefix("www.")
for r in result["results"]:
if r["domain"] == domain or r["domain"].endswith("." + domain):
return r["position"]
return None # not in the top 100
Depth rules
numabove 10 works on/v1/searchand/v1/newsand requirespage: 1.- Depth is priced per page of 10 up to the bundle cap:
num: 20is 2 credits,num: 50is 5, andnum: 100is 7. Pages that come back empty aren’t billed. - On other endpoints
numabove 10 is treated as 10.
See Pagination and depth for the details.
Location and device
Rankings differ by country, city and device, so track each combination separately:
| Parameter | Use |
|---|---|
country |
Country, e.g. us, de, in. |
language |
Interface language, e.g. en, de. |
location |
City or region, e.g. "Austin, Texas, United States", for local rankings. |
uule |
A Google-encoded location, if your tool already stores them (search only). |
device |
desktop (default) or mobile. Mobile layouts rank differently. |
See Localization for country and language codes.
Run it as a batch
Daily rank checks don’t need an answer in one second. POST /v1/batches queues search jobs at half price and delivers them within minutes (the target is under 15 minutes). Send up to 100 keywords per call:
curl https://api.serpkite.com/v1/batches \
-H "Authorization: Bearer $SERPKITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"endpoint": "search",
"webhook_url": "https://tracker.example.com/hooks/serpkite",
"requests": [
{"q": "espresso machine", "country": "us", "num": 100, "fields": "results.position,results.domain,results.link"},
{"q": "espresso grinder", "country": "us", "num": 100, "fields": "results.position,results.domain,results.link"},
{"q": "dual boiler espresso", "country": "us", "num": 100, "fields": "results.position,results.domain,results.link"}
]
}'
from serpkite import SerpKite
sk = SerpKite()
keywords = ["espresso machine", "espresso grinder", "dual boiler espresso"]
job = sk.batches.create(
endpoint="search",
requests=[{"q": k, "country": "us", "num": 100} for k in keywords],
webhook_url="https://tracker.example.com/hooks/serpkite",
)
ids = {entry.id: k for entry, k in zip(job.batches, keywords) if getattr(entry, "id", None)}
The API answers 202 Accepted with a batches array in the same order as your requests; an entry is a job, or an error object for a request that was invalid or couldn’t be queued:
{
"batches": [
{
"id": "0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90",
"status": "queued",
"endpoint": "/v1/search",
"created_at": "2026-09-29T06:00:00Z",
"poll_url": "https://api.serpkite.com/v1/batches/0192f7a4-6c1e-7b3a-9d2f-5e8a1c4b7d90",
"webhook_url": "https://tracker.example.com/hooks/serpkite",
"webhook_status": "pending",
"completed_at": null,
"credits_used": 0
}
]
}
Store each id next to the keyword it belongs to. When a job finishes, SerpKite POSTs a batch.completed event with the full search result to the webhook URL. If you omit webhook_url, the account’s default webhook from the dashboard settings is used; with neither, poll GET /v1/batches/{id} or call sk.batches.wait(id) in the SDKs. Results are kept for 24 hours.
Receive and verify the webhook
Each delivery carries X-SerpKite-Timestamp and X-SerpKite-Signature: v1=<hex>, an HMAC-SHA256 of <timestamp>.<raw body> with your webhook secret. Verify it before trusting the payload:
import hashlib
import hmac
import time
def verify(secret: str, timestamp: str, signature: str, body: bytes) -> bool:
if abs(time.time() - int(timestamp)) > 300: # reject stale deliveries
return False
expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Answer with any 2xx quickly and process the result asynchronously; failed deliveries are retried with backoff. Deliveries can arrive more than once, so deduplicate on the job id (also sent as X-SerpKite-Delivery). The full payload format, retry schedule and verification code in more languages are in Webhooks.
Scheduling tips
- Spread a large daily run over the day rather than submitting everything at midnight. There is a cap on how many jobs an account can have queued at once (10,000 by default); beyond it, requests get
429 rate_limited. - Each job is billed on its own: 3.5 credits for a
num: 100batch search. Failed and empty searches are refunded. - An
insufficient_creditserror for one entry doesn’t fail the others that fit your balance, so check every entry of thebatchesarray.
What it costs
For 1,000 keywords checked daily in one location on desktop, top 100, as batch jobs:
| Per keyword per day | 7 credits × 0.5 = 3.5 credits |
| Per day | 1,000 × 3.5 = 3,500 credits |
| Per 30-day month | 105,000 credits |
| At Pro pack price ($0.60 per 1,000) | about $63 per month |
Add a location or device and the cost multiplies accordingly (desktop and mobile is 7,000 credits a day). Realtime /v1/rank or /v1/search at the same depth costs 7 credits per keyword, twice the batch price. Credits never expire, so buying a larger pack for its lower per-credit price doesn’t waste money if usage fluctuates. Tracking far more keywords than this? Volumes above the largest pack are quoted on request; see Pricing and Enterprise.
Beyond position
A full search response gives you more than rank:
- SERP features: check whether the query shows an
answer_box,people_also_ask,top_storiesorplaces(local pack). Add them tofieldswhen you track them. - Competitors: store the full top 10 or top 100 domains per keyword to chart share of voice.
- Changes:
meta.parse_qualityisokfor a clean parse; treatpartialresults with care before alerting on a rank drop.
Related
Last updated: 2026-09-29