Find out who you're actually up against
Competitors ranked by real keyword overlap from the SERP archive — not who you assume they are.
Who you think you compete with and who shares your SERPs are usually two different lists. Three endpoints — five requests — build the shortlist, and the whole page runs on our archive's real data for semrush.com, not a mocked shape.
Export your key once.
export SEOFETCH_KEY=sof_live_… # the scripts below use jq -- brew install jq / apt-get install jq
export SEOFETCH_KEY=sof_live_… pip install requests
export SEOFETCH_KEY=sof_live_… # Node 18+ -- built-in fetch, no npm install needed
Ask the archive who ranks where you rank.
curl https://api.seofetch.com/v1/domains/competitors \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "semrush.com", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 25}' \
-o competitors.json
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/domains/competitors",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domain": "semrush.com", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 25},
)
with open("competitors.json", "w") as f:
json.dump(resp.json(), f, indent=2)
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
import { writeFileSync } from "node:fs";
const resp = await fetch("https://api.seofetch.com/v1/domains/competitors", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain: "semrush.com", engine: "google", location: 2840, language: "en", device: "desktop", limit: 25 }),
});
const body = await resp.json();
writeFileSync("competitors.json", JSON.stringify(body, null, 2));
{
"id": "doma_mjff5imq36refjlhknksn6ly",
"request_id": "req_k5sgjyoybnb7vbroskyhsoydq4",
"object": "domain_competitors",
"created_at": "2026-08-09T10:06:21Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 30,
"balance": 9857
},
"data": {
"domain": "semrush.com",
"keywords_total": 2834,
"keywords_considered": 200,
"total_competitors": 1743,
"competitors": [
{
"domain": "reddit.com",
"shared_keywords": 106,
"keywords_total": 114768,
"avg_rank": 3.9,
"target_avg_rank": 1,
"overlap": 0.53,
"shared_volume": 370910,
"sample_keywords": [
{
"keyword": "google search console",
"volume": 368000
},
"…"
]
},
"…"
],
"coverage": {
"from": "2026-05-20",
"observations": 200
}
}
}
Ranked by overlap — shared keywords, weighted by how often you actually co-occur. Expect a giant or two at the top: reddit.com intersects everyone, and pruning them is your first edit. Built from the domain's most-recent archived keywords — keywords_considered tells you the sample.
Prune the giants; keep the 3-5 with real overlap.
jq -c '[.data.competitors[] | select(.overlap >= 0.3) | select(.domain as $d | ["reddit.com", "wikipedia.org", "quora.com"] | index($d) | not)]' competitors.json
import json
BLOCKLIST = {"reddit.com", "wikipedia.org", "quora.com"}
competitors = json.load(open("competitors.json"))["data"]["competitors"]
survivors = [c for c in competitors
if c["overlap"] >= 0.3 and c["domain"] not in BLOCKLIST]
print(survivors)
import { readFileSync } from "node:fs";
const BLOCKLIST = new Set(["reddit.com", "wikipedia.org", "quora.com"]);
const { competitors } = JSON.parse(readFileSync("competitors.json")).data;
const survivors = competitors.filter(
(c) => c.overlap >= 0.3 && !BLOCKLIST.has(c.domain));
console.log(survivors);
The blocklist is editorial — you know which platforms aren't really competing with you. What survives is a candidate cohort — the 0.3 floor and the blocklist are editorial defaults, so adjust both for your market.
Size up each survivor.
curl https://api.seofetch.com/v1/domains/overview \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "ahrefs.com", "location": 2840, "language": "en"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/domains/overview",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domain": "ahrefs.com", "location": 2840, "language": "en"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/domains/overview", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domain: "ahrefs.com", location: 2840, language: "en" }),
});
const { data } = await resp.json();
{
"id": "doma_hlstjm46fad2alfsznf2imkz",
"request_id": "req_hctzlupizfftbpd5fumx7zxs6m",
"object": "domain_overview",
"created_at": "2026-08-09T10:06:21Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 10,
"balance": 9857
},
"data": {
"organic": {
"keywords_count": 101,
"traffic_estimate": 70.17,
"traffic_value": 704.38,
"positions": {
"top_3": 0,
"top_10": 3,
"top_20": 11,
"top_100": 101
}
},
"paid": {
"keywords_count": 0,
"traffic_estimate": 0,
"traffic_value": 0,
"positions": {
"top_3": 0,
"top_10": 0,
"top_20": 0,
"top_100": 0
}
}
}
}
Keyword count, traffic estimate, position spread — 10 credits per competitor. Small overlap plus a big footprint reads aspirational, not head-to-head.
Turn the closest one into a keyword list.
curl https://api.seofetch.com/v1/domains/gap \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": ["semrush.com", "ahrefs.com"], "mode": "gap", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 100, "offset": 0}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/domains/gap",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"domains": ["semrush.com", "ahrefs.com"], "mode": "gap", "engine": "google", "location": 2840, "language": "en", "device": "desktop", "limit": 100, "offset": 0},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/domains/gap", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ domains: ["semrush.com", "ahrefs.com"], mode: "gap", engine: "google", location: 2840, language: "en", device: "desktop", limit: 100, offset: 0 }),
});
const { data } = await resp.json();
{
"id": "doma_al45dzebjfr4rjz4hz44htt2",
"request_id": "req_qendynpyyzavfjvq6gzn4r7dzy",
"object": "domain_gap",
"created_at": "2026-08-09T10:06:21Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 15,
"balance": 9857
},
"data": {
"mode": "gap",
"domains": [
"semrush.com",
"ahrefs.com"
],
"items": [
{
"keyword": "search engine optimization",
"volume": 18100,
"ranks": [
null,
{
"rank": 27,
"url": "https://ahrefs.com/blog/what-is-seo/",
"date": "2026-06-28"
}
]
},
"…"
],
"totals": [
2834,
1315
],
"matched_count": 409,
"coverage": {
"from": "2026-06-28",
"observations": 409
},
"limit": 100,
"offset": 0
}
}
mode "gap" is keywords they rank for that you don't — which is exactly where the content-gaps walkthrough picks up.
Run the whole thing.
Each language below is the full pipeline — swap in your domain, run it after exporting SEOFETCH_KEY: the survivors land in cohort.csv, best footprint first, with a gap pull against the closest one already queued up in gap.json.
#!/usr/bin/env bash
# competitors.sh -- who ranks where you rank -> pruned cohort -> gap vs the closest one.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
DOMAIN="${TARGET_DOMAIN:-yourdomain.com}"
api() { curl -sS --fail-with-body "https://api.seofetch.com$1" \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$2"; }
# 1. who ranks where you rank
api /v1/domains/competitors \
"$(jq -cn --arg d "$DOMAIN" '{domain: $d, engine: "google", location: 2840, language: "en", device: "desktop", limit: 25}')" \
> competitors.json
# 2. prune giants, keep real overlap (0.3 floor -- policy knob)
jq -c '[.data.competitors[] | select(.overlap >= 0.3) | select(.domain as $d | ["reddit.com","wikipedia.org","quora.com"] | index($d) | not)]' \
competitors.json > cohort.json
[ "$(jq length cohort.json)" -gt 0 ] || { echo "nothing survives the prune -- lower the floor or widen the blocklist"; exit 0; }
# 3. size up the top 3 survivors by overlap -- best-matched cohort first
SURVIVORS=3 # size up more: +10cr each
top_survivors=$(jq -c "sort_by(-.overlap) | .[0:${SURVIVORS}]" cohort.json)
: > cohort.csv
echo "$top_survivors" | jq -r '.[].domain' | while IFS= read -r d; do
api /v1/domains/overview \
"$(jq -cn --arg d "$d" '{domain: $d, location: 2840, language: "en"}')" > "overview-$d.json"
jq -r --arg d "$d" \
'[$d, (.data.organic.keywords_count // 0), (.data.organic.traffic_estimate // 0)] | @csv' \
"overview-$d.json" >> cohort.csv
done
sort -t, -k3 -rn cohort.csv -o cohort.csv
echo "cohort.csv: domain, organic keywords, traffic estimate -- best first (footprint, not overlap)"
# 4. turn the highest-overlap survivor into a keyword list
top=$(echo "$top_survivors" | jq -r 'sort_by(-.overlap) | .[0].domain')
api /v1/domains/gap \
"$(jq -cn --arg a "$DOMAIN" --arg b "$top" '{domains: [$a, $b], mode: "gap", engine: "google", location: 2840, language: "en", device: "desktop", limit: 100, offset: 0}')" \
> gap.json
echo "gap.json: $(jq '.data.matched_count' gap.json) keywords $top ranks for that $DOMAIN doesn't"
#!/usr/bin/env python3
"""competitors.py -- who ranks where you rank -> pruned cohort -> gap vs the closest one."""
import csv
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
DOMAIN = os.environ.get("TARGET_DOMAIN", "yourdomain.com")
BLOCKLIST = {"reddit.com", "wikipedia.org", "quora.com"}
def api(path, payload):
resp = requests.post(
f"https://api.seofetch.com{path}",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json=payload,
)
resp.raise_for_status()
return resp.json()["data"]
# 1. who ranks where you rank
competitors = api("/v1/domains/competitors",
{"domain": DOMAIN, "engine": "google", "location": 2840, "language": "en",
"device": "desktop", "limit": 25})["competitors"]
# 2. prune giants, keep real overlap (0.3 floor -- policy knob)
cohort = [c for c in competitors if c["overlap"] >= 0.3 and c["domain"] not in BLOCKLIST]
if not cohort:
raise SystemExit("nothing survives the prune -- lower the floor or widen the blocklist")
# 3. size up the top 3 survivors by overlap -- best-matched cohort first
SURVIVORS = 3 # size up more: +10cr each
top_survivors = sorted(cohort, key=lambda c: -c["overlap"])[:SURVIVORS]
rows = []
for c in top_survivors:
d = c["domain"]
overview = api("/v1/domains/overview", {"domain": d, "location": 2840, "language": "en"})
rows.append((d, overview["organic"]["keywords_count"], overview["organic"]["traffic_estimate"]))
rows.sort(key=lambda r: -r[2])
with open("cohort.csv", "w", newline="") as f:
csv.writer(f).writerows(rows)
print("cohort.csv: domain, organic keywords, traffic estimate -- best first (footprint, not overlap)")
# 4. turn the highest-overlap survivor into a keyword list
top = top_survivors[0]["domain"]
gap = api("/v1/domains/gap",
{"domains": [DOMAIN, top], "mode": "gap", "engine": "google", "location": 2840,
"language": "en", "device": "desktop", "limit": 100, "offset": 0})
json.dump(gap, open("gap.json", "w"), indent=2)
print(f"gap.json: {gap['matched_count']} keywords {top} ranks for that {DOMAIN} doesn't")
#!/usr/bin/env node
// competitors.mjs -- who ranks where you rank -> pruned cohort -> gap vs the closest one.
import { writeFileSync } from "node:fs";
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const DOMAIN = process.env.TARGET_DOMAIN || "yourdomain.com";
const BLOCKLIST = new Set(["reddit.com", "wikipedia.org", "quora.com"]);
async function api(path, payload) {
const resp = await fetch(`https://api.seofetch.com${path}`, {
method: "POST",
headers: {
"Authorization": `Bearer ${SEOFETCH_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
return (await resp.json()).data;
}
// 1. who ranks where you rank
const { competitors } = await api("/v1/domains/competitors",
{ domain: DOMAIN, engine: "google", location: 2840, language: "en", device: "desktop", limit: 25 });
// 2. prune giants, keep real overlap (0.3 floor -- policy knob)
const cohort = competitors.filter((c) => c.overlap >= 0.3 && !BLOCKLIST.has(c.domain));
if (!cohort.length) throw new Error("nothing survives the prune -- lower the floor or widen the blocklist");
// 3. size up the top 3 survivors by overlap -- best-matched cohort first
const SURVIVORS = 3; // size up more: +10cr each
const topSurvivors = [...cohort].sort((a, b) => b.overlap - a.overlap).slice(0, SURVIVORS);
const rows = [];
for (const c of topSurvivors) {
const overview = await api("/v1/domains/overview", { domain: c.domain, location: 2840, language: "en" });
rows.push([c.domain, overview.organic.keywords_count, overview.organic.traffic_estimate]);
}
rows.sort((a, b) => b[2] - a[2]);
writeFileSync("cohort.csv", rows.map((r) => r.join(",")).join("\n") + "\n");
console.log("cohort.csv: domain, organic keywords, traffic estimate -- best first (footprint, not overlap)");
// 4. turn the highest-overlap survivor into a keyword list
const top = topSurvivors[0].domain;
const gap = await api("/v1/domains/gap",
{ domains: [DOMAIN, top], mode: "gap", engine: "google", location: 2840, language: "en",
device: "desktop", limit: 100, offset: 0 });
writeFileSync("gap.json", JSON.stringify(gap, null, 2));
console.log(`gap.json: ${gap.matched_count} keywords ${top} ranks for that ${DOMAIN} doesn't`);
Turned your cohort into a shortlist? The content-gaps walkthrough turns your closest competitor into a keyword list you can act on.