# 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: 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`](https://serpkite.com/docs/batch) 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`](https://serpkite.com/docs/endpoints/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:

```bash
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"}'
```

TypeScript:

```ts
import { SerpKite } from "serpkite";

const sk = new SerpKite(); // reads SERPKITE_API_KEY
const res = await sk.rank({ q: "espresso machine", domain: "example.com", country: "us", language: "en" });
console.log(res.position, res.checked);
```

Python:

```python
from serpkite import SerpKite

sk = SerpKite()  # reads SERPKITE_API_KEY
res = sk.rank("espresso machine", "example.com", country="us", language="en")
print(res.position, res.checked)
```

Go:

```go
package main

import (
	"context"
	"fmt"
	"log"

	serpkite "github.com/serpkite/serpkite-go"
)

func main() {
	ctx := context.Background()
	c := serpkite.NewClient() // reads SERPKITE_API_KEY
	res, err := c.Rank(ctx, serpkite.RankParams{Q: "espresso machine", Domain: "example.com", Country: "us", Language: "en"})
	if err != nil {
		log.Fatal(err)
	}
	if res.Position != nil {
		fmt.Println(*res.Position, res.Checked)
	}
}
```

```json
{
  "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:

```bash
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"}'
```

TypeScript:

```ts
import { SerpKite } from "serpkite";

const sk = new SerpKite(); // reads SERPKITE_API_KEY
const res = await sk.search({ q: "espresso machine", country: "us", num: 100, fields: "results.position,results.domain,results.link,results.title" });
console.log(res.results[0].title, res.meta.credits_used);
```

Python:

```python
from serpkite import SerpKite

sk = SerpKite()  # reads SERPKITE_API_KEY
res = sk.search("espresso machine", country="us", num=100, fields="results.position,results.domain,results.link,results.title")
print(res.results[0].title, res.meta.credits_used)
```

Go:

```go
package main

import (
	"context"
	"fmt"
	"log"

	serpkite "github.com/serpkite/serpkite-go"
)

func main() {
	ctx := context.Background()
	c := serpkite.NewClient() // reads SERPKITE_API_KEY
	res, err := c.Search(ctx, serpkite.SearchParams{Q: "espresso machine", Country: "us", Num: 100, Fields: "results.position,results.domain,results.link,results.title"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(res.Results[0].Title, res.Meta.CreditsUsed)
}
```

Then scan `results`:

```python
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](https://serpkite.com/docs/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](https://serpkite.com/docs/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`](https://serpkite.com/docs/batch) queues search jobs at **half price** and delivers them within minutes (the target is under 15 minutes). Send up to 100 keywords per call:

```bash
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"}
    ]
  }'
```

```python
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:

```json
{
  "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}`](https://serpkite.com/docs/endpoints/batches) 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:

```python
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](https://serpkite.com/docs/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](https://serpkite.com/pricing) and [Enterprise](https://serpkite.com/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.

## Related

- [Rank endpoint](https://serpkite.com/docs/endpoints/rank): Request and response reference for POST /v1/rank.
- [Free rank checker](https://serpkite.com/tools/rank-checker): Check a domain's position for one keyword in the browser.
- [Batch requests](https://serpkite.com/docs/batch): POST /v1/batches, polling and pricing.
- [Webhooks](https://serpkite.com/docs/webhooks): Payload, retries and signature verification.