Try it
Check a live ranking right now
One real call, no signup and no key. The panel opens on Quick Search — read the position on each row, beside the JSON.
Why position data
What ranking data is, and who buys it
A rank is not a metric a search engine publishes. It is an observation: one query, one place, one moment, and the ordered list that came back. Everything a rank tracker sells is built out of repeating that observation and keeping the answers.
Agencies who have to show the work
An agency's monthly report lives or dies on a position column. What they need is not a prettier chart — it is data they own, on their own keywords, in their own brand, with a history nobody can revoke when a subscription lapses.
Per-call pricing is the part that changes their margin: a client with 400 keywords costs what 400 checks cost, not what a 500-keyword plan costs.
Product and SEO teams inside a company
In-house teams already have a warehouse, a scheduler and a BI tool. They do not want a twelfth dashboard login; they want a table of positions arriving next to their traffic and revenue tables so the join is possible at all.
That is an API-shaped job: one GET, a position on every row, and a schema they choose.
People building the tracker itself
Every rank tracker on the market is a UI over somebody's position data. If you are building one, this is the layer underneath — and the reason to buy it rather than scrape it is that the response shape never changes and the failure cases are already named.
The same five engines, the same keys, the same types, on every call.
Agents and models that need today's ranking
A language model cannot know where a page ranks this morning; that fact did not exist when it was trained. A rank call gives an agent a dated, checkable answer — position, title and URL — instead of a plausible guess.
format=simple trims each row to exactly those three fields, which is usually all a model needs in context.
One field, on every row, on every engine, with no flag to set
position is a 1-indexed integer on every organic result. It is not opt-in, it is not a paid add-on, and it does not change name or type between engines — so the code that reads a Google rank reads a Brave rank unchanged.
It is contiguous: the list is numbered from 1 with no gaps, across every page walked, so row 47 in the array is position 47 in the response. And it is relative to the set you asked for — which is why the depth you request, and whether you actually received it, is the thing a rank log has to record alongside the number.
GET /api/search/quick
?q=best+running+shoes
&engine=google
&country=us
&num=50
{
"position": 7,
"title": "Best Running Shoes 2026",
"url": "https://example.com/shoes",
"snippet": "We tested 41 pairs...",
"displayedUrl": "example.com"
}
Which endpoint
Quick Search for the log, Deep Search for the audit
Both return the same position on the same organic rows. They differ in what else comes back, in how num is read, and — the part that decides a tracking budget — in how a multi-page call is billed.
| What differs | Quick Search — /api/search/quick |
Deep Search — /api/search |
|---|---|---|
| Billing | Flat. One call, one charge, whatever depth you ask for — on every tier and at any balance. | One charge per call on Growth and Scale, and on a Default account holding $10 or more. Below that, a multi-page call is billed per page. |
num |
Taken as given, 1–100. num=25 asks for 25. |
Rounded up to the next 10 and read in a 10–100 band. num=25 becomes 30. |
pages |
1–10, used when num is not set. |
1–10, used when num is not set. Defaults to 1 page. |
| What else is in the response | The ranked organic list. Every feature key is still present in results so the shape never varies; a block this call does not carry arrives as [] or null. |
The rest of the page as named fields: ads, People Also Ask, related searches, featured snippet, knowledge panel, local pack, inline videos, rich snippets. |
pixel_position |
Not supported. The parameter is accepted, the search runs normally, and the response says so in metadata.pixelPositionUnavailable with a hint naming the other endpoint. |
Supported on google, yahoo, bing and ddg, on paid tiers, at the same per-call price. |
| Typical use | The recurring log. Every keyword, every day, at the lowest cost per check. | The occasional audit: what was above you on the page, and how far down the page you actually sat. |
Both endpoints are free-tier eligible and both draw from the same pool of free API calls. include_aio, the AI Overview opt-in, is read only on Deep Search. pixel_position is not offered on engine=brave on either endpoint — see the pixel position API for what it measures and where.
Build it
How to actually build a daily rank log
Five decisions separate a rank log you can trust from a table of numbers that quietly lies to you. None of them is about the HTTP call — that part is one line.
Pick the row key before you pick the schema
A rank is only meaningful with its context attached. The unit is one keyword, one engine, one country, one day — make that the primary key and a re-run overwrites cleanly instead of double-counting.
Store position, the URL that ranked, and the date. The URL matters more than people expect: a keyword that "held position 4" while the ranking page changed underneath it is a different story than one that held with the same URL.
Record the depth you scanned, not just the rank you found
This is the one people get wrong. "Not found" is not a fact about your ranking — it is a fact about your window. A domain absent from 50 scanned rows and a domain absent from 100 are different observations, and a chart that plots both as the same blank is a chart that invents drops.
Write the row count you actually looked at into the same row. Then "fell out of the top 50" is a query you can answer, and "we stopped looking" never masquerades as a ranking change.
Treat a short delivery as its own outcome
Ask for a depth and receive less, and the response carries a top-level delivery block: requested, returned, a plain-English note, sometimes a reason, and where a job form is live an async_endpoint. A call that named no depth carries no block at all — with no target there is nothing to be short of.
So the test is simply is there a delivery key. If there is, you looked at a smaller page than you asked for, and the honest thing to store is a gap rather than a null that reads as a drop.
Ask for the smallest window that answers your question
num=50 and below delivers in full; above that a call is best-effort. Most tracking questions are top-20 questions, and a shallower window is both more reliable and faster. Go deep when you are hunting for a page that is genuinely far down, not by default.
On Quick Search the depth does not change the price — one call is one charge — so depth is purely a reliability and latency decision, not a budget one.
Send format=simple and stop parsing what you throw away
format=simple returns a flat array of exactly position, title and url — three columns, which is a rank log's whole schema. No snippet text, no feature blocks, nothing to strip before the insert.
Same price, same call, a payload a fraction of the size. Use format=full on the audit run when you want the rest of the page.
Quick Start
Check a ranking in one call, log it in twenty lines
A rank check is a GET with an API key header. No SDK, no job to poll, no id to chase — the positions are in the response body when it returns.
# 1. Where does this keyword rank on Google in the US, top 50? curl "https://apiserpent.com/api/search/quick?q=best+running+shoes&engine=google&country=us&num=50" \ -H "X-API-Key: YOUR_API_KEY" # 2. The same keyword, three columns only — position, title, url curl "https://apiserpent.com/api/search/quick?q=best+running+shoes&engine=google&num=20&format=simple" \ -H "X-API-Key: YOUR_API_KEY" # 3. The same keyword in a second market, in its own language curl "https://apiserpent.com/api/search/quick?q=laufschuhe+test&engine=google&country=de&language=de&num=20" \ -H "X-API-Key: YOUR_API_KEY"
{
"success": true,
"query": "best running shoes",
"type": "web",
"engine": "google",
"country": "us",
"pagesScraped": 5,
"results": [
{ "position": 1, "title": "…", "url": "https://example.com/a" },
{ "position": 2, "title": "…", "url": "https://example.com/b" },
{ "position": 3, "title": "…", "url": "https://example.com/c" }
],
"meta": {
"total": 50, // rows you actually got — store this
"requestedNum": 50,
"elapsed": "2841ms",
"timestamp": "2026-09-07T09:12:04.000Z"
}
}
// Had this call come back short, it would also carry a top-level block:
// "delivery": { "requested": 100, "returned": 63, "note": "…" }
// A full answer has no `delivery` key at all.
import os, sqlite3, datetime, requests API = "https://apiserpent.com/api/search/quick" KEY = os.environ["SERPENT_API_KEY"] DOMAIN = "yoursite.com" DEPTH = 50 # num=50 and below delivers in full db = sqlite3.connect("ranks.db") db.execute("""CREATE TABLE IF NOT EXISTS rank_log ( day TEXT, keyword TEXT, engine TEXT, country TEXT, position INTEGER, -- NULL = we looked and did not find it scanned INTEGER, -- how many rows we actually looked at short INTEGER, -- 1 = we got a smaller window than we asked for url TEXT, PRIMARY KEY (day, keyword, engine, country))""") def check(keyword, engine="google", country="us"): r = requests.get(API, headers={"X-API-Key": KEY}, params={ "q": keyword, "engine": engine, "country": country, "num": DEPTH, "format": "simple"}, timeout=60) r.raise_for_status() body = r.json() rows = body["results"] # format=simple: a flat list scanned = body["meta"]["total"] # `delivery` is present ONLY when we named a depth and came up short. short = bool(body.get("delivery")) hit = next((x for x in rows if DOMAIN in x["url"]), None) return { "position": hit["position"] if hit else None, "url": hit["url"] if hit else None, "scanned": scanned, "short": short, } day = datetime.date.today().isoformat() for line in open("keywords.txt"): kw = line.strip() if not kw: continue row = check(kw) # A missing domain in a SHORT window is not a drop — flag it, do not plot it. db.execute("INSERT OR REPLACE INTO rank_log VALUES (?,?,?,?,?,?,?,?)", (day, kw, "google", "us", row["position"], row["scanned"], int(row["short"]), row["url"])) db.commit()
const engines = ["google", "bing", "yahoo", "ddg", "brave"]; for (const engine of engines) { const url = `https://apiserpent.com/api/search/quick` + `?q=serp+api&engine=${engine}&country=us&num=20&format=simple`; const res = await fetch(url, { headers: { "X-API-Key": process.env.SERPENT_API_KEY } }); const body = await res.json(); const hit = body.results.find(r => r.url.includes("yoursite.com")); // Say WHICH kind of "no" it was — the two mean different things. const verdict = hit ? `#${hit.position}` : body.delivery ? `not in ${body.meta.total} rows (asked for 20)` : `not in top ${body.meta.total}`; console.log(engine.padEnd(7), verdict); }
Parameters
Everything a rank call can control
The same parameter set on /api/search/quick and /api/search, with the two differences called out. Anything not listed here is not read on these endpoints.
google, bing, yahoo, ddg or brave. Defaults to google. Every engine returns the same keys with the same types, so a second engine is a query parameter rather than a second integration.us, gb, de, in and 112 codes in all; both uk and gb are accepted. Defaults to us. GET /api/countries returns the full list and needs no API key. Country-level only; city and postcode targeting are not supported here.en, es, de, ja…) to set the result language. Derived from country when omitted.100. Default 10. A ceiling you request, not a count that is guaranteed: num=50 and below delivers in full, above that is best-effort. On Quick Search the value is taken as given (1–100); on Deep Search it is rounded up to the next 10 and read in a 10–100 band.1–10. Used only when num is not set — send one or the other, not both. Positions stay sequential across pages.h or 1h, d or 1d, w or 7d, m or 1m, y or 1y.off, moderate or strict.full (default) returns the whole response object. simple returns a flat array of position, title and url per row — a rank log's entire schema, at the same price.true on /api/search adds an integer pixel_position and a pixel_box (x, y, w, h) to every measured item. Paid tiers; available on google, yahoo, bing and ddg. On /api/search/quick the parameter is accepted and the search runs normally, but no pixel fields come back — the response carries metadata.pixelPositionUnavailable and a metadata.pixelPositionHint naming /api/search instead./api/search only, on eligible paid calls. Not read on Quick Search.positiondeliveryrequested, returned, a plain-English note, sometimes a reason, and where a job form is live an async_endpoint. The same counts also appear as meta.partialResults.Every parameter above is one the route actually reads. GET /api/status reports what your own key is entitled to, including which async job forms are live for your account — read that rather than a table before you build.
Pricing
Per call, not per tracked keyword
Web search is one billing category: Quick and Deep, all five engines, one rate. A deposit is credit you spend rather than a monthly fee, and the tier it unlocks is permanent.
Default
- No deposit and no subscription
- Covered by the free API calls
- Quick Search: one charge per call
- Deep Search below a $10 balance bills per page
Growth
- Unlocked by a one-time $100 deposit
- 10× off the Default rate
- Always one charge per call
- Same rate on all five engines
Scale
- Unlocked by a one-time $500 deposit
- 20× off the Default rate
- Always one charge per call
- Same rate on all five engines
Worked example, one engine, one country, once a day: 2,000 keywords is about 60,000 calls a month — $1.80 at Scale, $3.60 at Growth, $36.00 at Default. Depth does not change that on Quick Search, because a Quick call is billed flat whatever depth you ask for. Growth and Scale accounts are billed once per call on Deep Search too; a Default account holding under $10 is the one case where a multi-page Deep call is billed per page.
Compare
What position data costs everywhere else
Ranking data is sold two ways: per call, like an API, or per tracked keyword, like a rank tracker. The second one charges you again for every keyword on the list whether or not anyone looked at it, which is where the two models separate.
| Provider | How it is priced | Cost per 1,000 position checks |
|---|---|---|
| Serpent | Per call. No plan, no per-seat fee, no per-keyword fee. | $0.60 Default → $0.06 Growth → $0.03 Scale |
| SerpApi | Monthly plan with a search allowance. | $25.00 on Starter ($25/mo, 1,000) → $1.96 on their largest listed plan ($106,050/mo, 54M) |
| DataForSEO | Per request, against a $50 minimum payment. | $0.60 standard queue → $1.20 priority → $2.00 live |
| AccuRanker | Per tracked keyword, refreshed daily. | $3.73 — from $224/mo for 2,000 keywords |
| Semrush | Suite subscription with a tracked-keyword allowance. | $7.82 — from $117.33/mo (annual) for 500 keywords daily |
Every rate is the vendor's own published price, read from their pricing page and checked 7 September 2026. The two per-keyword rows are converted at 30 daily refreshes a month so the column is comparable — that conversion is our arithmetic, not their published figure. On free access: SerpApi's free plan is 250 searches a month; DataForSEO lists no free tier and a $50 minimum payment; AccuRanker's pricing page lists no free plan or trial. Serpent gives every new account Free to start, with no card.
And the honest caveat. AccuRanker and Semrush are finished products — dashboards, stored history, share-of-voice, competitor discovery, alerting, support. The row above prices the data, not the product. If what you want is a tool you log into, buy the tool. If what you want is the positions, in your own database, on your own schedule, that is what an API is for.
Full breakdowns in our SERP API pricing comparison and rank tracking cost guides.
Built for tracking
The details that only matter when you run it every day
Six behaviours that make the difference between a rank log you can chart and one you have to apologise for.
Contiguous positions
position is numbered from 1 with no gaps, across every page walked. Row 47 in the array is position 47 in the response — no re-indexing on your side.
A shape that never varies
Every feature key is always present in results. A block a call did not carry arrives as [] or null, never as a missing key, so one parser survives every engine.
Short answers say so
Ask for a depth and get less, and a delivery block names requested and returned. You are never left counting an array to work out whether a blank is a drop.
Depth that does not cost more
Quick Search is billed flat — one call, one charge, whatever depth you ask for. Going from num=20 to num=50 changes your latency, not your invoice.
112 countries, one code path
Change country and you are reading the ranking for that market. GET /api/countries lists every accepted code and needs no API key.
A three-column payload
format=simple returns exactly position, title and url. Same call, same price, nothing to strip before the insert.
At scale
When one keyword becomes two hundred
A loop over a keyword file is the right answer for a long time. Two things change when the list gets long, and both are parameters rather than rewrites.
Submit the batch instead of looping it
POST /api/bulk/jobs takes up to 200 items in one submission, each item the parameters of its synchronous twin. Use the serp_quick key for the Quick Search behaviour, or serp_web when you want the Deep Search depth. Poll the job, collect the results, and each item's result is the same body the synchronous call returns.
Each item is billed at the same flat rate for its category — asking for more results per item does not cost more. GET /api/status lists which job forms are live for your key under limits.endpoints[…].async_available; read that before you build. Full walkthrough in the documentation.
Spread the schedule, and never re-run blind
A thousand keywords do not need to leave at midnight together. Spread them across the window you have; a rank check is not more accurate for being simultaneous, and a staggered schedule is kinder to every part of the chain including yours.
When you re-run a day, write to the same (day, keyword, engine, country) key so the second answer replaces the first instead of appearing as a second observation. That one constraint is what keeps a history honest after an outage.
FAQ
Rank Tracking API Questions
/api/search/quick with engine=google and country=us. Every organic result in the response carries a 1-indexed position field. Find your domain in the results and read its position — that is its rank in the set you scanned. Run the same call on a schedule and store each position with the date to build a history. The position field is not opt-in; it ships on every organic result on every engine.
/api/search/quick, for the recurring log. It is billed flat: one call, one charge, whatever depth you ask for, on every tier and at any balance. Deep Search, /api/search, is billed one unit per page requested, up to 10 pages, at every tier and every balance — so the same num=100 request costs ten units on Deep and one on Quick, whatever the account. Use Deep Search when you also need the rest of the page: ads, People Also Ask, featured snippets, knowledge panels, or pixel positions. Use Quick Search when you only need the ranked list.
position field on every search, so it works as both — a one-off rank checker and the data layer behind a continuous rank tracker.
engine=google, engine=bing, engine=yahoo, engine=ddg or engine=brave. Every engine returns the same response shape with a position on every organic result, so tracking a keyword on a second engine is one query parameter rather than a second integration.
country with a two-letter code — country=us, country=gb, country=de, country=in, and 112 codes in all; both uk and gb are accepted. GET /api/countries returns the full list and needs no API key. Pass language with a two-letter ISO 639-1 code to set the result language. Targeting is country-level: city and postcode targeting are not supported on this endpoint. For position as it varies by place, see the local rank tracking API.
num — but num is a ceiling you request, not a count that is guaranteed. num=50 and below delivers in full; above that a call is best-effort. When a call comes back short of the depth you named, the response carries a top-level delivery block with requested, returned, a plain-English note and, where a job form is live, an async_endpoint. A call that named no depth carries no block, because there is nothing to be short of. For a rank log that distinction matters: a domain missing from 62 scanned rows is not the same fact as a domain missing from 100.
pixel_position=true to /api/search and every measured item also carries an integer pixel_position — its y-coordinate on the rendered desktop page — plus a pixel_box with x, y, width and height. It is available on engine=google, engine=yahoo, engine=bing and engine=ddg, on paid tiers, at the same per-call price. Quick Search does not support it: the parameter is accepted, the search runs normally, and the response says so in metadata.pixelPositionUnavailable with a hint naming /api/search, rather than quietly returning nothing. See the pixel position API.
Start tracking rankings free
free API calls, no card and no subscription. Positions on five engines and 112 countries, from $0.03 per 1,000 SERP API calls at Scale.
Get an API Key