Checking where a website ranks used to mean maintaining a scraper: proxies, CAPTCHAs, and a parser that breaks every time Google reshuffles the page. The DataForSEO SERP API removes the entire collection layer. You send a keyword with a location, language, and device, and get the results page back as structured data, with every element’s position already parsed.
This guide goes from a single position check to a complete rank tracking setup: automated task scheduling, tracking multiple domains and competitors, a near-real-time pipeline for 1,000+ keywords with retries and caching, alerts when positions move, and automated reporting.
You can test everything below on the free $1 trial credit. Examples use plain HTTP and JSON. If you want a Python-first walkthrough of the basics, our Python SERP API tutorial covers the first call in detail.
How rank tracking with a SERP API works
Two ways to get a SERP: Live and Standard
Live endpoints return the SERP inside the same response. One keyword per request, results in up to ~6 seconds, base price $0.002 per SERP. This is the right mode for an on-demand “check this keyword now” button.
Standard mode works with tasks. You post batches of keywords, our system crawls them in a queue, and you collect the results when they’re ready. You can send up to 100 tasks per POST call and up to 10,000 calls per minute per account, and each SERP costs about 70% less than Live. In our measured example, the same keyword at the same depth cost $0.0035 through Live and $0.00105 through Standard.
A daily or weekly tracking run is a batch job, and nobody is watching each SERP come in, so Standard mode is what a rank tracker is built on.
The two position fields: rank_group and rank_absolute
Every result item carries two positions, and mixing them up is the most common integration bug we see.
rank_groupis the position among elements of the same type. For an organic result, this is the organic position an SEO would recognize as “the ranking.”rank_absoluteis the position among all elements on the page, counting AI Overviews, ads, local packs, People Also Ask, and everything else Google rendered.
Here is a real response from a Live call without any filter (30 Oct 2026, United States, desktop), trimmed to the position fields:
{
"keyword": "dataforseo serp api",
"se_domain": "google.com",
"check_url": "https://www.google.com/search?q=dataforseo%20serp%20api&hl=en&gl=US",
"datetime": "2026-10-30 17:24:49 +00:00",
"item_types": ["ai_overview", "organic", "people_also_ask", "related_searches"],
"pages_count": 1,
"items_count": 11,
"items": [
{ "type": "organic", "rank_group": 1, "rank_absolute": 1 },
{ "type": "organic", "rank_group": 2, "rank_absolute": 2 },
{ "type": "organic", "rank_group": 3, "rank_absolute": 3 },
{ "type": "ai_overview", "rank_group": 1, "rank_absolute": 4 },
{ "type": "organic", "rank_group": 4, "rank_absolute": 5 },
{ "type": "organic", "rank_group": 5, "rank_absolute": 6 },
{ "type": "organic", "rank_group": 6, "rank_absolute": 7 },
{ "type": "organic", "rank_group": 7, "rank_absolute": 8 },
{ "type": "people_also_ask", "rank_group": 1, "rank_absolute": 9 },
{ "type": "organic", "rank_group": 8, "rank_absolute": 10 },
{ "type": "related_searches", "rank_group": 1, "rank_absolute": 11 }
]
}
Read the fourth row. Three organic results hold absolute positions 1–3, then an AI Overview takes absolute position 4, and every element after it is pushed one slot down. rank_group keeps counting within each type — the next organic is rank_group: 4, not 5 — while rank_absolute counts everything on the page. That gap is why clicks can drop even when “the position” holds steady: SERP features are taking the visual space above your result.
Store both numbers, and report rank_group from items whose type is organic as “the ranking.” Our rank absolute vs rank group explainer has more cases.
Two more fields are worth keeping. check_url opens the exact Google query we crawled, so anyone on your team can spot-check a result. item_types lists everything that was on the page, even when the items are filtered.
Filter to one domain with the target parameter
Pulling the whole SERP and hunting for your domain works, but it moves the filtering into your code and the bandwidth into your bill. On the Live Google Organic SERP API endpoints, the target parameter does that work on our side: pass a domain, subdomain, or URL pattern, and the response keeps only the elements that match.
Wildcards set how wide the match goes:
example.comreturns the home page (example.com,www.example.com/).*example.comreturns the home page regardless of subdomain (en.example.com).example.com*returns the domain, including all its pages.*example.com*returns the entire domain, all pages and subdomains.example.com/blog/pagereturns the exact URL.example.com/blog/*returns all URLs starting with that path.
Here is a real request from 30 Oct 2026:
POST https://api.dataforseo.com/v3/serp/google/organic/live/advanced
[
{
"keyword": "dataforseo serp api",
"location_code": 2840,
"language_code": "en",
"target": "dataforseo.com*"
}
]
The response keeps only the matching items — each with its rank_group and rank_absolute intact — instead of the full page you saw above.
One detail from this exact crawl is worth knowing about. Google increasingly wraps organic result URLs in its redirect (“goto”) links, and when it does, the url and domain fields of the wrapped items contain the redirect values (google.com), not the destination. Our target matching still resolved those items against dataforseo.com* correctly — matching works on the underlying result, not just the visible link — but if you filter SERPs in your own code, don’t match on the bare domain field alone, or wrapped results will slip past you.
One caution from the same day: a Live call for serp api with target dataforseo.com* returned the task status 40102 — No Search Results. That is not a failure. The crawl ran, and the target simply didn’t appear within the crawled depth. In a tracker, treat it exactly like “not ranking (in the top 10).” On a Live call with target, this is the one status that means “not ranking.” Every other non-success status means the check didn’t complete — 40601 Task Handed and 40602 Task In Queue mean it is still pending, and the rest are failures — so log it and retry, and never write any of them as a position (see the mistakes section below).
One limitation to plan around: target is available on the two Live endpoints only. It is not a parameter of the Standard task queue. Batch workflows collect the full SERP and filter in their own code, which is exactly what the next section sets up. For the same filter written in Python, see the Python tutorial.
Set up automated rank tracking with the Standard method
A Standard-mode tracker uses three endpoints:
- Task POST submits keyword batches, up to 100 tasks per call.
- Tasks Ready lists completed tasks you haven’t collected yet. Postbacks can replace or back it up.
- Task GET returns the full structured SERP for one task.
Step 1: Submit keywords in batches of 100
The Task POST endpoint accepts up to 100 tasks per call, and you can send up to 10,000 calls per minute. A 10,000-keyword list is therefore 100 calls.
The snippet below splits your keywords into batches of 100 and posts each batch to Standard mode. Every task gets a stop_crawl_on_match target (covered in the next section) and a tag with your internal keyword ID. Any rejected tasks are printed.
import requests
API = "https://api.dataforseo.com/v3/serp/google/organic"
AUTH = ("your_login", "your_password") # requests builds the Basic auth header from these
TARGETS = [{"match_type": "with_subdomains", "match_value": "example.com"}]
def build_task(kw):
return {
"keyword": kw["keyword"],
"location_code": kw["location_code"], # e.g. 2840 = United States
"language_code": "en",
"depth": 100, # a ceiling, not a fixed cost: the crawl stops earlier on a match
"stop_crawl_on_match": TARGETS,
"find_targets_in": ["organic"], # a paid ad for example.com shouldn't end the crawl
"tag": str(kw["id"]), # echoed back in the results so you can map SERPs to DB rows
}
def submit(keywords):
for i in range(0, len(keywords), 100): # tasks above 100 per POST fail with error 40006
batch = [build_task(kw) for kw in keywords[i:i + 100]]
resp = requests.post(f"{API}/task_post", auth=AUTH, json=batch, timeout=60).json()
for task in resp["tasks"]:
if task["status_code"] != 20100: # 20100 = "Task Created."
print("rejected:", task["data"].get("tag"), task["status_message"])
See the Task POST docs.
Two details matter once this runs in production. tag is a free-form identifier of up to 255 characters, echoed back with the result. Put your internal keyword ID in it, or pack several values, for example "8812|mobile|2840". Matching results back by comparing keyword strings breaks on duplicates and encoding differences, and tag doesn’t.
And a batch call can succeed as a whole even if individual tasks inside it are rejected. That’s why the loop checks the status_code of every task, not just the HTTP response.
Step 2: Collect results with postbacks and tasks_ready
Postbacks come first. Set postback_url (and postback_data: "advanced") on each task, and we send the gzipped result to your server the moment it’s ready. Your endpoint has 10 seconds to respond. If it doesn’t, the task automatically goes back to the ready list, so a postback outage never loses data. Our pingbacks and postbacks guide covers the setup.
The ready list is your safety net. The Tasks Ready endpoint returns the IDs of completed tasks you haven’t collected, up to 1,000 per call and up to 20 calls per minute, so one worker can clear 20,000 task IDs a minute. A task stays on that list for three days after it completes. The results stay available for 30 days, and you aren’t billed for collecting them: you pay at post time, never for collection. If a postback was lost or your server errored, the Errors and Webhook Resend endpoints let you replay it.
To keep the example short, the next snippet polls. It gets the list of ready tasks and fetches each one from Task GET. Then it keeps only the organic items from your domain and saves the best position under the keyword ID from tag.
def is_target(domain, target="example.com"):
# mirrors match_type "with_subdomains" without matching lookalikes like notexample.com
return domain == target or domain.endswith("." + target)
def collect():
ready = requests.get(f"{API}/tasks_ready", auth=AUTH, timeout=60).json()
for item in ready["tasks"][0]["result"] or []:
res = requests.get(f"{API}/task_get/advanced/{item['id']}", auth=AUTH, timeout=60).json()
task = res["tasks"][0]
if task["status_code"] != 20000 or not task["result"]:
continue # log and retry later; don't record a missing SERP as "not ranking"
serp = task["result"][0]
hits = [
{"rank_group": i["rank_group"], "rank_absolute": i["rank_absolute"], "url": i["url"]}
for i in serp["items"] or []
if i["type"] == "organic" and is_target(i.get("domain") or "")
]
# items are ordered by position, so hits[0] is the best ranking URL;
# None means the domain wasn't found within the crawled depth
save(task["data"]["tag"], hits[0] if hits else None, serp["check_url"])
See the Task GET Advanced docs.
save() is a placeholder for your own database write; the schema is in the reporting section below. Keep submit() and collect() as separate jobs. A slow batch should never block the next one, and a crashed worker resumes from the ready list.
Track multiple domains and websites
The unit of rank tracking is one keyword, in one location, on one device. Multiple websites and multiple domains are therefore not a different feature. They are more tasks, organized well:
- Label everything with
tag. Encode the project and the domain in each task’s tag (acme|acme.com|desktop|2840), and every collected result lands in the right row of your database without string matching. - Schedule per location. Stagger the daily runs (United States at 06:00, Germany at 06:15, mobile an hour later) to smooth your request volume. Store the location, language, device, and OS with every position you save. Positions collected under different settings are not comparable, and mixing them quietly corrupts your trends.
Competitors in the same task: stop_crawl_on_match
For each task, you can pass an array of up to 10 targets in stop_crawl_on_match. Each target has two fields. match_value is the domain, subdomain, or URL pattern, written without a protocol. match_type sets how that value is matched:
domainmatches that exact domain or subdomain and nothing else, soblog.example.comwon’t matchshop.example.com.with_subdomainsmatches the domain and everything under it. For most rank trackers, this is the one to start with.wildcardmatches a URL pattern such as/blog/post-*. Use it when you only track one section of a site.
Three more parameters control the crawl, and each one only works when stop_crawl_on_match is set:
target_search_mode:any(the default) stops the crawl on the first SERP page where any one target appears, andallkeeps crawling until every target is found. Useallwhen you track competitors. Inanymode, a competitor ranking further down never gets a position at all.find_targets_inrestricts matching to specific element types, such as["organic"]. Without it, a paid result or a local pack entry for your domain counts as a match and can end the crawl too early.ignore_targets_indoes the opposite and skips matching in the element types you choose.
Expect all mode to crawl more pages, because you are billed for every SERP page crawled until the condition is met. That is usually the right trade: complete competitor data beats a marginally smaller bill. The full parameter walkthrough is in our guide to crawl control parameters, and the cost mechanics are in our stop_crawl_on_match guide.
A real competitor call
Here is a real request from 30 Oct 2026 — one keyword, four tracked domains, and the crawl-control parameters doing the work:
POST https://api.dataforseo.com/v3/serp/google/organic/live/advanced
[
{
"keyword": "cloud storage",
"location_code": 2840,
"language_code": "en",
"depth": 20,
"stop_crawl_on_match": [
{ "match_type": "with_subdomains", "match_value": "dropbox.com" },
{ "match_type": "with_subdomains", "match_value": "google.com" },
{ "match_type": "with_subdomains", "match_value": "icloud.com" },
{ "match_type": "with_subdomains", "match_value": "microsoft.com" }
],
"target_search_mode": "all",
"find_targets_in": ["organic"],
"tag": "hc-competitor-example"
}
]
And a trimmed version of what came back:
{
"items_count": 24,
"pages_count": 2,
"items": [
{ "type": "ai_overview", "rank_absolute": 1 },
{ "type": "organic", "rank_group": 1, "rank_absolute": 2,
"domain": "cloud.google.com", "title": "What is Object Storage? Use cases & benefits" },
{ "type": "organic", "rank_group": 2, "rank_absolute": 3,
"domain": "www.sync.com", "title": "Free cloud storage, sync and sharing" },
{ "type": "organic", "rank_group": 14, "rank_absolute": 17,
"title": "Free Cloud Storage for Photos and Files | OneDrive" }
]
}
Three things happened in this crawl, and each one is a lesson. Google’s storage pages matched on the very first organic result (rank_group 1, under the AI Overview), but OneDrive’s listing only turned up on the second page (rank_group 14) — so with target_search_mode: "all" the crawl went to page 2 and finished at the depth-20 bound (pages_count: 2), and the bill was for the two pages crawled ($0.0035 in Live mode), not the ceiling. In any mode, this crawl would have stopped on page 1 the moment Google’s result matched — cheaper, but OneDrive and every later-ranking competitor would have no position at all. And notice the sync.com row: the response contains the full crawled SERP, not just your targets — you pick your domains’ rows out of it. A target that never appears within the crawled depth simply returns no items; that is a ranking fact, not a failure, exactly like the 40102 case above.
A near-real-time pipeline for 1,000+ keywords
“Near-real-time” for a rank tracker means three things: submitted in one pass, collected continuously, and never recording a failure as a ranking. Here is what that looks like against the limits above.
Throughput
100 tasks per call × 10,000 calls per minute means one account can queue a 1,000-keyword list in a single 10-call burst. A practical schedule runs submit() once a day at a set hour per location, then runs collect() every minute until the ready list is empty. Submission and collection are independent jobs, so run them as separate processes.
Retries
There are two different failure classes, and they need different handling:
- Transport failures (timeouts, connection resets, 5xx errors): retry with exponential backoff and a small jitter. Three to five attempts is standard. Be careful with Task POST: a request can time out on your side after the tasks were already created, and a blind retry posts and bills them twice. Before resubmitting a batch, check which
tagvalues already came back as created or ready, and resend only the missing ones. - Task-level failures: the task completed with an error
status_code. Log it, resubmit the keyword in the next run, and move on. Never write a null position for a failed task. “The task errored” and “the domain doesn’t rank” are different facts, and your users can tell the difference in a report.
Caching and deduplication
Key every stored row by keyword | location | device | date and deduplicate by task id. Postbacks can be resent after a timeout, so make your database writes idempotent (upsert on task ID). Within a run, don’t resubmit a keyword that already has today’s row. If you re-check on demand, mark those rows as “manual” so daily diffs stay clean.
The same table doubles as a cache for on-demand checks. When a user asks “where do we rank for X right now?”, serve today’s row if one exists, and only fall back to a paid Live call when it doesn’t.
Cost control, in order of impact
stop_crawl_on_matchstops the crawl on the first page where your target appears, and you pay only for the pages crawled. In the documented example, a task set todepth: 100found its target at position 7 and cost $0.002 instead of $0.0155. Those are Live prices; in the Standard queue, both figures are proportionally lower. Logpages_counton every run, and you’ll see the savings across your keyword set.depthis the number of results returned, billed per SERP page of up to 10 results. The default is 10, and the maximum is 700 in Standard mode (200 in Live). Most trackers need 100 at most.max_crawl_pagesis a hard cap on pages crawled (up to 100) and works alongsidedepth.- The
start=trick, with its catch. Thesearch_paramparameter can pass Google’sstart=argument and skip pages you don’t need. But ranks then count from the first crawled page: atstart=20, arank_absoluteof 1 means position 21. If you use it, store the offset with the row, or don’t use it for reported positions at all.
Get alerts when positions change
Once positions land in storage reliably, alerting is a daily diff with rules:
- Thresholds that matter to users. A keyword dropped out of the top 3 or top 10, moved N or more places day-over-day, or the ranking URL changed. The usual causes of a URL change are cannibalization or a redirect, and either way, someone wants to know.
- Delivery. Post the diff to a webhook: Slack, email, or whatever your team already watches. Keep the payload small: keyword, old position, new position, check URL.
You don’t have to build this from scratch. Our reporting and alerts automation templates already wire “keyword ranking dropped → instant alert → Google Sheets,” and the n8n integration (official and community nodes) turns the same pattern into a no-code workflow.
Turn it into an automated SEO reporting tool
The storage schema is small, with one row per keyword per run: keyword_id, checked_at, rank_group, rank_absolute, url, pages_count, location, device, tag.
From that single table, you get position history, daily movement, and best and worst movers. Add up positions weighted by search volume, and you have a visibility trend your clients recognize.
You also don’t need to build a UI to get outputs:
- Google Sheets connector. Bulk rank tracking is built in ($0.002 per domain/URL), with ready-made templates.
- n8n and Make integrations. Schedule runs and send results to Sheets, Slack, or a database without writing code.
Historical data before you have your own
Your pipeline starts producing history on day one. For month-over-month trends before that, the DataForSEO Labs historical endpoints return a domain’s ranking distribution going back to 2020-10, with no storage required on your side.
And to answer the question every tracker starts with, “what does this domain even rank for?”, the Labs Ranked Keywords endpoint returns every keyword a domain currently ranks for, positions included, in one call. Our Help Center shows how to check Google keyword rankings in bulk with the Labs API. Seed your keyword list from there, then let the SERP API pipeline keep it fresh.
For complete dashboards with visibility dynamics, ranked pages, and new keywords, the Rank Tracking solution guide maps every widget to its endpoints.
Common mistakes when tracking rankings with an API
- Reporting
rank_absoluteas the position. AI Overviews, ads, and local packs take absolute slots. Takerank_groupfrom items of typeorganic. - Posting more than 100 tasks per call. Every task over the limit comes back with error
40006. Split your list into slices of 100 before you send it. - Polling Task GET in a tight loop. A queued task returns
40602 — Task In Queue. Ask the ready list instead, or let a postback deliver the result. - Leaving
target_search_modeonanywhile tracking competitors. The crawl ends at the first target found, and the others get no position. Useall. - Letting ads or local packs end the crawl. Set
find_targets_in: ["organic"]so only organic appearances count as matches. - Recording a failed task as “not ranking.” Check the task’s
status_codebefore you write anything. The only exception is40102 — No Search Resultson a Live call withtarget, which does mean “not found within the crawled depth.” - Skipping
tag. Without it, you have to match results to keywords by string comparison, and that breaks on duplicates. - Comparing positions collected under different settings. Store location, language, device, and OS with every row.
- Using
start=without storing the offset. Ranks count from the first crawled page, so position 1 atstart=20is really position 21.
Short on time?
- Just need your first position check in Python? See how to get Google rankings with Python.
- Want to know what your domain ranks for, in one call? See bulk keyword rankings with DataForSEO Labs.
- Building a full tracker with dashboards? See the Rank Tracking solution guide.
- Pricing? Pay-as-you-go, from $0.0006 per SERP in the Standard queue. Every account starts with a free $1 credit, and the Sandbox lets you test integrations for free.
Register an account, run any request from this guide in the API Explorer, and you have a working rank checker before the coffee cools.