Integration Guide
How to Build Your Own Rank Tracker (Google, Bing, and DuckDuckGo)
Rank trackers disagree with each other, and with what you see in your own browser. Here's why — country, region, and device parameters — and how to build a tracker across three engines that gets it right.
Written by Alex P.
- rank tracker
- SEO
- keyword rankings
- Google rankings
- Bing rankings
- DuckDuckGo rankings
A thread on r/seogrowth captures a complaint that shows up constantly: rank trackers showing results that don’t match what a real person sees in a live browser search — sites sitting at position #2 for a real visitor, completely absent from the top 10 across “every automated data source” tested. The tools aren’t broken. They’re usually just checking the wrong location, the wrong device, or the wrong country, and reporting a confident number anyway.
Rankings are not one number. Google, Bing, and DuckDuckGo all personalize or localize results by country, region, language, and device, and a rank check that doesn’t pin all of those down is measuring something different every time it runs — which looks exactly like “the rank tracker is wrong” from the outside.
This guide builds a rank tracker across all three engines that controls for that, plus the parts that are genuinely different between them.
Pin down what “rank” means before you check it
Every engine’s rank-check route takes the same shape of decision: a query, a target, and the localization context the check runs under. Get the second part wrong and the number is meaningless even if the request succeeds:
const API_KEY = process.env.FETCHLAYER_API_KEY;
async function checkGoogle(query, target) {
const res = await fetch('https://api.fetchlayer.dev/google-search/rank-check', {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
target,
depth: 100,
country: 'US', // rankings differ by country — pin one and keep it fixed
language: 'en',
}),
});
return res.json();
}
async function checkBing(query, target) {
const res = await fetch('https://api.fetchlayer.dev/bing/rank-check', {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
target,
depth: 100,
market: 'en-US', // Bing's unit of localization is market, not country
}),
});
return res.json();
}
async function checkDuckDuckGo(query, target) {
const res = await fetch('https://api.fetchlayer.dev/duckduckgo/rank-check', {
method: 'POST',
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
target,
depth: 100,
region: 'us-en', // and DuckDuckGo's is region, one of 64 codes
}),
});
return res.json();
}
The three engines don’t share a localization vocabulary — Google takes a bare country, Bing takes a market (en-US) with an optional separate country override, DuckDuckGo takes a region (us-en). Map your one canonical “US, English” setting to each engine’s own parameter rather than assuming one string works everywhere.
Why last week’s number and this week’s don’t match
If you change country, market, region, or device between runs, you’re no longer tracking the same thing — you’re sampling a different personalization bucket each time and calling the difference a ranking change. Keep every localization parameter fixed for a given tracked (query, target) pair, and change it deliberately, as a new tracked series, when you actually want to check a different market.
The same applies to device: Google’s device parameter (desktop or mobile) and Bing’s equivalent can and do return different orderings for the same query — mobile search takes roughly 15 seconds against Google specifically, so budget for it rather than treating a slow mobile check as a failure.
A working tracker across all three
import { readFile, writeFile } from 'node:fs/promises';
const STATE_FILE = './rank-history.json';
const TARGET = 'yoursite.com';
const TRACKED = [
{ engine: 'google', fn: checkGoogle, query: 'project management software' },
{ engine: 'bing', fn: checkBing, query: 'project management software' },
{ engine: 'duckduckgo', fn: checkDuckDuckGo, query: 'project management software' },
];
async function run() {
let history = [];
try {
history = JSON.parse(await readFile(STATE_FILE, 'utf8'));
} catch {}
for (const { engine, fn, query } of TRACKED) {
const result = await fn(query, TARGET);
history.push({
date: new Date().toISOString().slice(0, 10),
engine,
query,
position: result.position ?? null, // null, not 0 — 0 would read as "ranking at position zero"
found: result.found ?? false,
});
}
await writeFile(STATE_FILE, JSON.stringify(history, null, 2));
}
run().catch(console.error);
Plot position per engine over weeks. A gap between Google and Bing for the same query isn’t a bug in the tracker — it’s the two engines genuinely disagreeing, which is worth knowing on its own.
allMatches: catching more than the first hit
By default, a rank check stops at the first matching result within depth. If your site owns more than one ranking page for a query — a blog post and a comparison page, say — the default check only tells you about whichever ranks higher. allMatches: true reports every matching position within the scanned depth instead of stopping at the first:
body: JSON.stringify({ query, target: TARGET, depth: 100, allMatches: true })
Read the result from matches — each entry carries its own position, page, and url — rather than just the top-level position, which only ever reports the single best-ranked match. This is the way to catch keyword cannibalization — two of your own pages competing for the same query — which a single-position check can’t see by design.
includeSubdomains, and why the default matters
All three engines default includeSubdomains to counting a subdomain (blog.example.com) as part of the domain when you target a bare domain (example.com). If you specifically want to track your root domain’s ranking separate from a blog or docs subdomain, target the exact host and set includeSubdomains: false — otherwise a blog post ranking well can mask your root domain not ranking at all for the same query.
Practical notes
- DuckDuckGo still shows a site for a query that names it outright, even with
safeSearch: strict. Don’t read a missing result as a block — check the query itself first. otherAppearancescatches more than the organic listing. Google’s response separately reports a target showing up in a featured snippet, a knowledge panel, or a sitelink — real visibility that a plain position number misses entirely.- Depth costs the same regardless of value. All three engines answer a
depth: 100scan in one request, the same asdepth: 10— there’s no reason to default to a shallow scan and miss a page ranking at position 40. - A
targetcan be a domain or a full URL with a path. A bare domain matches the site and its subdomains (unless you turn that off); a URL with a path only matches pages under that path — pick the narrower one when you’re tracking a single landing page rather than a whole site.
Next Steps
- Google SERP API · Bing SERP API · DuckDuckGo SERP API
- Get a free API key — no credit card required