Skip to content

New Official SDKs for TypeScript, Python and Go

SerpKite
Get API key
Docs menu / Rank tracking

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.

View as Markdown

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: 100 returns 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 without www.) and a resolved link, so matching doesn’t need URL parsing or redirect decoding.
  • Batches: POST /v1/batches queues 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.

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

  • num above 10 works on /v1/search and /v1/news and requires page: 1.
  • Depth is priced per page of 10 up to the bundle cap: num: 20 is 2 credits, num: 50 is 5, and num: 100 is 7. Pages that come back empty aren’t billed.
  • On other endpoints num above 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: 100 batch search. Failed and empty searches are refunded.
  • An insufficient_credits error for one entry doesn’t fail the others that fit your balance, so check every entry of the batches array.

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_stories or places (local pack). Add them to fields when you track them.
  • Competitors: store the full top 10 or top 100 domains per keyword to chart share of voice.
  • Changes: meta.parse_quality is ok for a clean parse; treat partial results with care before alerting on a rank drop.

Last updated: 2026-09-29