How to Build a Rank Tracker With a SERP API: A Developer’s Guide

TLDR: A rank tracker built on a SERP tracking API needs four parts: a schedule of keyword, location, and device checks; a fetch that finds your domain's position; append-only history; and change alerts. Use a SERP-faithful API such as DataForSEO or SerpApi for exact Google positions, and budget per 10-result page now that num=100 is gone. A web search API such as You.com suits cheaper monitoring, not Google rank reporting.
This guide builds that core in Python, tests it offline, and prices it per keyword, location, and check frequency. For what a SERP API is and how providers fetch and parse results, start with What Is a SERP API? Prices, parameters, and response fields were checked against provider documentation on September 28, 2026.
Which API should a rank tracker call?
Start from the number you have to report. If a client or an executive dashboard expects "position 4 for this keyword in London on mobile," you need an API that loads Google's results page for a pinned location and device, then parses it. SerpApi and DataForSEO both work this way, and their responses expose Google's own ordering: SerpApi returns organic_results with a position field (SerpApi organic results), and DataForSEO returns typed items with rank_group and rank_absolute (DataForSEO Live Advanced reference).
A web search API answers a different question: which pages does an index return for this query right now, and what changed since yesterday? The You.com Web Search API returns up to 100 web results per call from its own index for $5.00 per 1,000 calls as of September 2026 (You.com billing). Its order is You.com's ranking, not Google's, and the Search API reference offers country and language targeting but no city or device parameter. Never label that order as a Google position.
| What you need | API type | Why | Fields the tracker reads |
|---|---|---|---|
| Exact Google organic position for a city and device | SERP-faithful API | It loads and parses Google's page for the location you pin | DataForSEO rank_group; SerpApi position |
| SERP features you win or lose, such as a featured snippet | SERP-faithful API with a full parse | Features are page elements, not index entries | DataForSEO item_types and featured_snippet items |
| Change detection across many queries where Google rank is not the KPI | Web search API | One call returns up to 100 results at a flat per-call price | You.com results.web[].url and list order |
| Competitor and coverage discovery for a topic | Web search API | Web and news arrive as separate sections of one response | You.com results.web and results.news |
You can run both: a SERP-faithful API for the keywords that feed reporting, and a web search API for broad monitoring, such as the competitor-launch alerts in this You.com, HubSpot, and Slack workflow. The code below puts both providers behind one scheduler and keeps their histories in separate series.
How should you model keywords, locations, and schedules?
The unit of work is a check: one keyword, in one location, on one device, from one provider. Each check carries its own frequency and depth, because a revenue keyword may deserve a daily top-20 read while a long-tail term needs only a weekly one. Locations are provider-specific strings. DataForSEO accepts a location_name such as London,England,United Kingdom (task parameters); You.com takes a two-letter country such as GB.
History is append-only. Every successful check writes one row, and a NULL rank means "checked, not found within the requested depth." A failed request writes nothing. That distinction matters: an outage stored as "not ranking" fires a false drop alert today and a false recovery alert tomorrow.
import sqlite3
from datetime import datetime, timedelta, timezone
SCHEMA = """
CREATE TABLE IF NOT EXISTS tracked (
provider TEXT NOT NULL, keyword TEXT NOT NULL,
location TEXT NOT NULL, device TEXT NOT NULL DEFAULT 'desktop',
every_hours INTEGER NOT NULL DEFAULT 24, depth INTEGER NOT NULL DEFAULT 20,
PRIMARY KEY (provider, keyword, location, device));
CREATE TABLE IF NOT EXISTS observations (
checked_at TEXT NOT NULL, provider TEXT NOT NULL, keyword TEXT NOT NULL,
location TEXT NOT NULL, device TEXT NOT NULL, depth INTEGER NOT NULL,
rank_organic INTEGER, rank_absolute INTEGER, url TEXT,
owns_snippet INTEGER NOT NULL, features TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS obs_series
ON observations (provider, keyword, location, device, checked_at);
"""
def connect(path="ranks.db"):
db = sqlite3.connect(path)
db.row_factory = sqlite3.Row
db.executescript(SCHEMA)
return db
def due_checks(db, provider, now):
"""Tracked rows whose latest stored observation is older than every_hours."""
rows = db.execute(
"""SELECT t.*, (SELECT MAX(o.checked_at) FROM observations o
WHERE o.provider = t.provider AND o.keyword = t.keyword
AND o.location = t.location AND o.device = t.device) AS last
FROM tracked t WHERE t.provider = ?""", (provider,)).fetchall()
return [r for r in rows if r["last"] is None or
datetime.fromisoformat(r["last"]) <= now - timedelta(hours=r["every_hours"])]
The scheduler reads the latest stored observation for each series and returns the checks whose interval has elapsed. Because failures are never stored, a check that failed at 06:00 is still due when cron runs the job again at 07:00. Run the job hourly and let every_hours decide what actually executes.
How do you fetch results and extract your position?
Fetch with retries
The fetch layer uses only the standard library. fetch_dataforseo posts one task to the Live Advanced endpoint, which accepts one task per call and authenticates with HTTP Basic using your API login and password. The stop_crawl_on_match parameter ends the crawl at the page that contains your domain, and DataForSEO bills per SERP crawled through the specified targets, so a domain ranking fourth costs one page even when depth is 20. fetch_youcom posts to https://ydc-index.io/v1/search with an X-API-Key header; count accepts 1 to 100.
import base64, json, os, random, time, urllib.error, urllib.request
RETRY_HTTP = {429, 500, 502, 503, 504}
RETRY_DATAFORSEO = {40202, 40209, 50000, 50301} # rate limit, concurrency, transient
class Retryable(Exception):
def __init__(self, message, wait=None):
super().__init__(message)
self.wait = wait
def post_json(url, body, headers, timeout=90):
request = urllib.request.Request(
url, data=json.dumps(body).encode(), method="POST",
headers={"Content-Type": "application/json", **headers})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.load(response)
except urllib.error.HTTPError as err:
if err.code in RETRY_HTTP:
after = err.headers.get("Retry-After") or ""
raise Retryable(f"HTTP {err.code}", float(after) if after.isdigit() else None)
raise # 401, 402, 403, 422: fix the key, balance, or parameters instead
except OSError as err: # connection resets and timeouts
raise Retryable(repr(err))
def with_retries(call, attempts=5, sleep=time.sleep):
for attempt in range(attempts):
try:
return call()
except Retryable as err:
if attempt == attempts - 1:
raise
backoff = min(60, 2 ** attempt) * random.uniform(0.5, 1.0)
sleep(err.wait if err.wait is not None else backoff)
def fetch_dataforseo(keyword, location, device, depth, target):
login = f'{os.environ["DATAFORSEO_LOGIN"]}:{os.environ["DATAFORSEO_PASSWORD"]}'
auth = {"Authorization": "Basic " + base64.b64encode(login.encode()).decode()}
task = {"keyword": keyword, "location_name": location, "language_code": "en",
"device": device, "depth": depth,
"stop_crawl_on_match": [{"match_value": target, "match_type": "with_subdomains"}]}
def call():
data = post_json("https://api.dataforseo.com/v3/serp/google/organic/live/advanced",
[task], auth)
codes = {data.get("status_code")} | {t.get("status_code") for t in data.get("tasks") or []}
if codes & RETRY_DATAFORSEO:
raise Retryable(f"DataForSEO status {codes}")
if codes != {20000}:
raise RuntimeError(f"DataForSEO status {codes}")
return data
return with_retries(call)
def fetch_youcom(keyword, country, count):
body = {"query": keyword, "count": count, "country": country, "language": "EN"}
return with_retries(lambda: post_json(
"https://ydc-index.io/v1/search", body, {"X-API-Key": os.environ["YDC_API_KEY"]}))
Two details are easy to miss. DataForSEO returns HTTP 200 for most errors and reports the real outcome in status_code, at the top level and per task: 20000 is success, 40202 means the per-minute rate limit was exceeded, and 40209 means too many simultaneous requests (DataForSEO error codes). A retry policy that only reads HTTP status would store those failures as empty results. Second, never retry 401, 402, 403, or 422. The You.com error reference maps those to a bad key, missing credits, missing scope, and an invalid parameter combination; repeating the call fixes none of them.
Extract the target's position
Matching on the hostname instead of a substring avoids two classic bugs: notexample.com matching example.com, and blog.example.com failing to match when you track the root domain. For DataForSEO, rank_group is the position among elements of the same type, which makes it the organic rank. rank_absolute counts every element on the page, including snippets, People Also Ask boxes, and AI Overviews. Store both. The featured snippet is its own item type, so the parser records ownership separately instead of letting it distort the organic rank.
from urllib.parse import urlsplit
def on_target(host, target):
host = (host or "").lower().rstrip(".")
return host == target or host.endswith("." + target)
def position_from_dataforseo(data, target):
result = (data["tasks"][0].get("result") or [{}])[0]
items = result.get("items") or []
obs = {"rank_organic": None, "rank_absolute": None, "url": None,
"owns_snippet": any(i.get("type") == "featured_snippet"
and on_target(i.get("domain"), target) for i in items),
"features": ",".join(result.get("item_types") or [])}
for item in items:
if item.get("type") == "organic" and on_target(item.get("domain"), target):
obs.update(rank_organic=item["rank_group"], rank_absolute=item["rank_absolute"],
url=item.get("url"))
break
return obs
def position_from_youcom(data, target):
web = (data.get("results") or {}).get("web") or []
obs = {"rank_organic": None, "rank_absolute": None, "url": None,
"owns_snippet": False, "features": ""}
for index, hit in enumerate(web, start=1): # You.com list order, not a Google rank
if on_target(urlsplit(hit.get("url") or "").hostname, target):
obs.update(rank_organic=index, url=hit["url"])
break
return obs
For You.com, the rank is the index of the first matching URL inside results.web. News results arrive in a separate results.news list and are ignored here. Label that series clearly: it measures visibility in You.com's index, useful for spotting new competitor pages and dropped pages, not a Google position.
How do you turn history into change alerts?
Daily positions are noisy, and a one-place wobble is rarely actionable. Alert on edges and larger moves instead: entering or leaving the top 3 and top 10, a move of five or more places, dropping out of the tracked depth, a change of ranking URL, and winning or losing the featured snippet. A URL change with a steady rank is often the most useful alert in the set, because it can mean two of your pages compete for the query, or that a redirect or canonical change swapped the page Google shows.
def classify(prev, curr, jump=5):
"""Alerts for one keyword/location/device series between two stored checks."""
if prev["depth"] != curr["depth"]:
return ["depth changed; ranks are not comparable"]
p, c, alerts = prev["rank_organic"], curr["rank_organic"], []
if p and not c:
alerts.append(f"left the top {curr['depth']} (was {p})")
elif c and not p:
alerts.append(f"entered the top {curr['depth']} at {c}")
elif p and c:
for edge in (3, 10):
if (p <= edge) != (c <= edge):
alerts.append(f"{'entered' if c <= edge else 'left'} top {edge}: {p} -> {c}")
if abs(p - c) >= jump:
alerts.append(f"{'up' if c < p else 'down'} {abs(p - c)}: {p} -> {c}")
if prev["url"] != curr["url"]:
alerts.append(f"ranking URL changed: {prev['url']} -> {curr['url']}")
if bool(prev["owns_snippet"]) != bool(curr["owns_snippet"]):
alerts.append("featured snippet " + ("won" if curr["owns_snippet"] else "lost"))
return alerts
CHECKS = {
"dataforseo": lambda r, target: position_from_dataforseo(fetch_dataforseo(
r["keyword"], r["location"], r["device"], r["depth"], target), target),
"youcom": lambda r, target: position_from_youcom(fetch_youcom(
r["keyword"], r["location"], r["depth"]), target),
}
def run(db, provider, target, now=None):
now = now or datetime.now(timezone.utc)
alerts = []
for row in due_checks(db, provider, now):
key = (provider, row["keyword"], row["location"], row["device"])
try:
curr = dict(CHECKS[provider](row, target), depth=row["depth"])
except Exception as err: # nothing stored, so the check stays due
print("skipped", key, repr(err))
continue
prev = db.execute(
"""SELECT * FROM observations WHERE provider = ? AND keyword = ?
AND location = ? AND device = ? ORDER BY checked_at DESC LIMIT 1""",
key).fetchone()
db.execute("INSERT INTO observations VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(now.isoformat(timespec="seconds"), *key, row["depth"],
curr["rank_organic"], curr["rank_absolute"], curr["url"],
int(curr["owns_snippet"]), curr["features"]))
db.commit()
alerts += [(key, a) for a in (classify(dict(prev), curr) if prev else [])]
return alerts
The depth guard prevents a common false alarm: after you raise a keyword from top 20 to top 100, yesterday's "not found" and today's position 45 are different measurements, not a move. The run function returns alerts keyed by series, ready to post to Slack, email, or a ticket queue.
What does rank tracking cost per keyword, location, and frequency?
Every SERP-faithful price now depends on depth. Google stopped honoring the num=100 parameter in September 2025 (SerpApi's September 2025 note reads "Num is not supported anymore"), so deeper results cost extra pages. Our num=100 explainer covers why that multiplied costs. For a tracker, the arithmetic is:
Monthly cost = keywords × locations × devices × checks per month × pages per check × price per page
Daily is about 30 checks a month, weekly about 4.3, and twice daily 60. The table prices one portfolio: 500 keywords in 3 locations on desktop, checked daily, which is 1,500 checks a day and 45,000 a month, at list prices as of September 2026.
| Provider and mode | List price, September 2026 | Top 10 (1 page) | Top 20 (2 pages) | Top 100 (10 pages) |
|---|---|---|---|---|
| DataForSEO Standard queue, normal priority | $0.0006 per 10-result page | $27 | $54 | $270 |
| DataForSEO Live (the endpoint in the code) | $0.002 per page | $90 | $180 | $900 |
| SerpApi, smallest plan that covers the volume | Searcher: $725 a month for 100,000 searches; Infrastructure: $2,750 for 500,000 | $725 | $725 | $2,750 (450,000+ searches) |
| You.com Web Search API | $5.00 per 1,000 calls, up to 100 results each | $225 | $225 | $225 |
Per keyword and location, a daily top-10 check costs $0.018 a month on the DataForSEO Standard queue, $0.06 on Live, and $0.15 on You.com. SerpApi works out to about $0.22 at the Searcher plan's full-utilization rate of $7.25 per 1,000 searches. Sources: DataForSEO Google Organic pricing, the DataForSEO depth FAQ, SerpApi pricing, and You.com billing, linked above.
The top-100 column is a ceiling, not a forecast. Three levers cut it:
- Stop at your domain. With
stop_crawl_on_match, DataForSEO bills only the pages crawled until your domain appears, so deep pages cost money only for keywords where you rank deep or not at all. DataForSEO also refunds requested depth that the SERP could not fill. - Queue scheduled work. The Standard method (
task_post, then collect the results) takes up to 100 tasks per POST and costs 30% of the Live price. Live suits on-demand checks; a nightly batch rarely needs six-second turnaround. - Tier the frequency. Weekly checks cost about a seventh of daily ones. Keep daily reads for keywords that drive revenue, and let the "left the top 20" alert trigger an ad hoc deep check instead of paying for top 100 every day.
SerpApi bills only successful searches; cached, errored, and failed searches are free. Its cache lasts one hour for identical parameters, so set no_cache=true when a re-run must be a fresh read (SerpApi Google Search API). You.com's row is flat because its price does not change with count, but that buys a different measurement, not a cheaper copy of the same one.
Which failure modes corrupt rank data?
Localization and personalization
No API reproduces an individual searcher's history, so a tracked rank is a standardized reading, not what a given customer sees. Location, language, and device are what you control. SerpApi recommends a city-level location, warns that when it is omitted the search may take on the proxy's location, and suggests pairing location with gl for consistent country filtering (parameter reference). DataForSEO requires a location and a language on every task. Pin all three per series, store them with every row, and never compare ranks across them.
SERP features that move the page, not the rank
An AI Overview, a local pack, or a People Also Ask box can push your first organic result down the page while its organic rank stays at 1. That is why the schema stores both ranks and the page's item_types. When clicks fall and rank_organic is flat, compare rank_absolute and the feature list before blaming content. SerpApi returns features in their own arrays, such as related_questions, and numbers organic_results separately, so the same caution applies there.
Pagination after num=100
Each Google page is now a separate billable unit. SerpApi paginates with start (0, 10, 20, and so on), and its own pagination links advance start by the number of organic results actually returned, so a page does not always hold exactly 10 organic results (SerpApi pagination). Follow those links and derive ranks from the results you received rather than assuming ten per page. In DataForSEO, depth or max_crawl_pages sets how many pages one task collects. To check only the pages around a known rank, pass "search_param": "start=31"; the returned ranks then count from the first page crawled, so rank_absolute 1 means position 31 (DataForSEO depth FAQ). The same FAQ notes that deep Live tasks take three to four times longer than a first-page task.
Rate limits, retries, and partial results
You.com's default is 10 requests per second per endpoint for self-serve accounts, with X-RateLimit-* headers on every response and Retry-After on a 429 (You.com rate limits). At that rate, 1,500 daily checks take about two and a half minutes. DataForSEO allows 2,000 API calls per minute and 30 simultaneous requests. SerpApi guarantees an hourly throughput per plan, 20,000 searches an hour on Searcher, and returns 429 both when you exceed it and when the account has run out of searches (SerpApi status codes). Read the error message before backing off: sleeping will not refill a monthly quota.
Partial results need a policy too. DataForSEO's error list includes a code for tasks that completed with partial results, where some pages could not be retrieved and were not charged. The code above discards those checks, because any task code other than 20000 raises. Whatever you choose, never record "not found" for pages that were never fetched.
How was this code tested, and what should you test before you commit?
The four blocks were run as one module on Python 3.9, standard library only, against fixtures shaped like the documented DataForSEO and You.com responses, with a fake urlopen in place of the network. The 26 checks covered request contracts, rank extraction, look-alike domains, retry and no-retry paths, scheduling, and a three-day alert sequence. That verifies the logic, not live ranking accuracy, uptime, or billing.
Before you commit to a provider, run a two-week pilot on 50 of your real keywords in your real locations:
- Spot-check fidelity. DataForSEO returns a
check_urlwith each result, a direct link to the search it ran; SerpApi returns agoogle_urlinsearch_metadata. Open a sample in a clean browser session and compare. - Measure stability. Count how many positions move day to day on keywords where nothing changed on your site. That number sets your alert thresholds.
- Price the real mix. Record pages crawled per check with and without
stop_crawl_on_match, plus retries and failures, then compute cost per successful check. - Test the failure paths. Force a 429, a bad key, and an empty balance, and confirm that none of them writes a "not found."
Trial allowances keep that pilot cheap: SerpApi's free plan includes 250 searches a month, DataForSEO adds a $1 credit at signup, and new You.com accounts receive $100 in API credits. For a structured way to compare providers on your own queries, see how to benchmark a search provider before you commit.
LI Test
LI Test
Share Article:
Related resources.

How to Use the You.com Web Search API in TypeScript
September 22, 2026
Blog

How to Build a News Search Pipeline With the You.com Web Search API
September 22, 2026
Blog

What Is the You.com Web Search API? Endpoint, Pricing, and Limits
September 22, 2026
Blog

How Authentication Works in the You.com Web Search API
September 21, 2026
Blog

How to Use Date Filters With the You.com Web Search API
September 21, 2026
Blog
