Find the content you're missing
Keywords a competitor ranks for that you don't — volume-enriched, with the questions people actually ask.
A competitor's rankings are a to-do list someone else wrote for you. This walkthrough turns one gap pull into outlines, ordered by volume and reported difficulty.
Don't know who to run the gap against? Start there — it ends with the closest competitor already picked out.
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
Pull the gap: what do they rank for that you don't?
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}' \
-o gap.json
import json
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},
)
with open("gap.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/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 body = await resp.json();
writeFileSync("gap.json", JSON.stringify(body, null, 2));
{
"id": "doma_454rp572kvkr36hjpl3dlf2n",
"request_id": "req_zwvkh7kcibeyvd22yhfvxw6qii",
"object": "domain_gap",
"created_at": "2026-08-09T10:09:31Z",
"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
}
}
Volume-enriched out of the box. A null on your side of ranks is the whole point — that's the article you haven't written.
Floor it by volume and take the top of the list.
jq -c '[.data.items[] | select(.volume >= 1000)] | sort_by(-.volume) | .[:20]' gap.json
import json
items = json.load(open("gap.json"))["data"]["items"]
loud = sorted((i for i in items if i["volume"] >= 1000), key=lambda i: -i["volume"])
top20 = loud[:20]
print(top20)
import { readFileSync } from "node:fs";
const { items } = JSON.parse(readFileSync("gap.json")).data;
const top20 = items.filter((i) => i.volume >= 1000)
.sort((a, b) => b.volume - a.volume).slice(0, 20);
console.log(top20);
The 1,000 floor is policy, not physics — niche sites live happily below it.
Pull the questions people actually ask for your top gap keyword — shown here for a keyword from another niche; same call, any keyword.
curl https://api.seofetch.com/v1/keywords/questions \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"keyword": "running shoes", "location": 2840, "language": "en", "limit": 50}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/keywords/questions",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"keyword": "running shoes", "location": 2840, "language": "en", "limit": 50},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/keywords/questions", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keyword: "running shoes", location: 2840, language: "en", limit: 50 }),
});
const { data } = await resp.json();
{
"id": "keyw_76w7m6gfgdu455x672aezufq",
"request_id": "req_qdpkaz6pwjfftfr5x6mn6xa5ze",
"object": "keyword_questions",
"created_at": "2026-08-09T10:09:31Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 20,
"balance": 9857
},
"data": {
"keyword": "running shoes",
"items": [
{
"question": "How often should you replace running shoes?",
"first_seen": "2026-06-11",
"last_seen": "2026-07-25",
"times_seen": 18
},
{
"question": "Are carbon plate shoes worth it?",
"first_seen": "2026-07-02",
"last_seen": "2026-07-27",
"times_seen": 7
},
"…"
],
"count": 42,
"coverage": {
"from": "2026-07-01",
"observations": 130
}
}
}
People-Also-Ask, deduplicated, with first/last-seen dates — an outline that answers what searchers ask beats one that guesses.
Score the shortlist; write the winnable ones first.
curl https://api.seofetch.com/v1/keywords/difficulty \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"keywords": ["buy running shoes", "cold keyword"], "location": 2840, "language": "en"}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/keywords/difficulty",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"keywords": ["buy running shoes", "cold keyword"], "location": 2840, "language": "en"},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/keywords/difficulty", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ keywords: ["buy running shoes", "cold keyword"], location: 2840, language: "en" }),
});
const { data } = await resp.json();
{
"id": "keyw_7yosnzba57zbix7tdff2qv2r",
"request_id": "req_ktzaxdibuzftpg7ampc6eigcra",
"object": "keyword_difficulty",
"created_at": "2026-08-09T10:09:31Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 110,
"balance": 9857
},
"data": {
"items_count": 2,
"items": [
{
"keyword": "buy running shoes",
"difficulty": 42,
"status": "available"
},
{
"keyword": "cold keyword",
"difficulty": null,
"status": "pending"
}
]
}
}
Difficulty and volume order the shortlist — difficulty is a comparative score, not a promise; your authority and intent match decide the rest. A keyword still status "pending" gets its score on a later call — don't drop it, re-ask.
Run the whole thing.
Each language below is the full pipeline — swap in your domain and a competitor's, run it after exporting SEOFETCH_KEY: gaps.json comes out sorted by ascending difficulty, pending scores last, questions attached to your probed pick.
#!/usr/bin/env bash
# gaps.sh -- gap pull -> volume floor/top 20 -> questions on top pick -> difficulty batch -> gaps.json.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
YOU="${TARGET_DOMAIN:-yourdomain.com}"
THEM="${COMPETITOR_DOMAIN:-competitor.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. what do they rank for that you don't
api /v1/domains/gap \
"$(jq -cn --arg y "$YOU" --arg t "$THEM" '{domains: [$y, $t], mode: "gap", engine: "google", location: 2840, language: "en", device: "desktop", limit: 100, offset: 0}')" \
> gap.json
# 2. floor by volume (1000 -- policy knob), top 20
jq -c '[.data.items[] | select(.volume >= 1000)] | sort_by(-.volume) | .[:20]' gap.json > top20.json
[ "$(jq length top20.json)" -gt 0 ] || { echo "nothing clears the volume floor"; exit 0; }
# 3. questions for the top pick
PICKS=1 # probe more picks: +20cr each
: > questions.jsonl
jq -r ".[0:${PICKS}][].keyword" top20.json | while IFS= read -r kw; do
api /v1/keywords/questions \
"$(jq -cn --arg k "$kw" '{keyword: $k, location: 2840, language: "en", limit: 50}')" |
jq -c --arg k "$kw" '{keyword: $k, questions: [.data.items[].question]}' >> questions.jsonl
done
# 4. difficulty on the whole shortlist
api /v1/keywords/difficulty \
"$(jq -s '{keywords: [.[].keyword], location: 2840, language: "en"}' top20.json)" > difficulty.json
# 5. merge: keyword, volume, difficulty, questions[] -- winnable first
jq -n --slurpfile top20 top20.json --slurpfile diff difficulty.json --slurpfile qs <(jq -s '.' questions.jsonl) '
($diff[0].data.items | map({(.keyword): .}) | add) as $dmap |
($qs[0] | map({(.keyword): .questions}) | add // {}) as $qmap |
[$top20[0][] | {
keyword, volume,
difficulty: ($dmap[.keyword].difficulty // null),
status: ($dmap[.keyword].status // "pending"),
questions: ($qmap[.keyword] // [])
}] | sort_by(if .difficulty == null then 999 else .difficulty end)
' > gaps.json
echo "gaps.json: $(jq length gaps.json) keywords, winnable first"
#!/usr/bin/env python3
"""gaps.py -- gap pull -> volume floor/top 20 -> questions on top pick -> difficulty batch -> gaps.json."""
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
YOU = os.environ.get("TARGET_DOMAIN", "yourdomain.com")
THEM = os.environ.get("COMPETITOR_DOMAIN", "competitor.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. what do they rank for that you don't
gap = api("/v1/domains/gap",
{"domains": [YOU, THEM], "mode": "gap", "engine": "google", "location": 2840,
"language": "en", "device": "desktop", "limit": 100, "offset": 0})
# 2. floor by volume (1000 -- policy knob), top 20
top20 = sorted((i for i in gap["items"] if i["volume"] >= 1000), key=lambda i: -i["volume"])[:20]
if not top20:
raise SystemExit("nothing clears the volume floor")
# 3. questions for the top pick
PICKS = 1 # probe more picks: +20cr each
questions = {}
for item in top20[:PICKS]:
kw = item["keyword"]
data = api("/v1/keywords/questions", {"keyword": kw, "location": 2840, "language": "en", "limit": 50})
questions[kw] = [q["question"] for q in data["items"]]
# 4. difficulty on the whole shortlist
difficulty = api("/v1/keywords/difficulty",
{"keywords": [i["keyword"] for i in top20], "location": 2840, "language": "en"})
scores = {i["keyword"]: i for i in difficulty["items"]}
# 5. merge: keyword, volume, difficulty, questions[] -- winnable first
gaps = [
{"keyword": i["keyword"], "volume": i["volume"],
"difficulty": scores[i["keyword"]]["difficulty"],
"status": scores[i["keyword"]]["status"],
"questions": questions.get(i["keyword"], [])}
for i in top20
]
gaps.sort(key=lambda g: g["difficulty"] if g["difficulty"] is not None else 999)
json.dump(gaps, open("gaps.json", "w"), indent=2)
print(f"gaps.json: {len(gaps)} keywords, winnable first")
#!/usr/bin/env node
// gaps.mjs -- gap pull -> volume floor/top 20 -> questions on top pick -> difficulty batch -> gaps.json.
import { writeFileSync } from "node:fs";
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const YOU = process.env.TARGET_DOMAIN || "yourdomain.com";
const THEM = process.env.COMPETITOR_DOMAIN || "competitor.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. what do they rank for that you don't
const gap = await api("/v1/domains/gap",
{ domains: [YOU, THEM], mode: "gap", engine: "google", location: 2840, language: "en",
device: "desktop", limit: 100, offset: 0 });
// 2. floor by volume (1000 -- policy knob), top 20
const top20 = gap.items.filter((i) => i.volume >= 1000).sort((a, b) => b.volume - a.volume).slice(0, 20);
if (!top20.length) throw new Error("nothing clears the volume floor");
// 3. questions for the top pick
const PICKS = 1; // probe more picks: +20cr each
const questions = {};
for (const item of top20.slice(0, PICKS)) {
const data = await api("/v1/keywords/questions",
{ keyword: item.keyword, location: 2840, language: "en", limit: 50 });
questions[item.keyword] = data.items.map((q) => q.question);
}
// 4. difficulty on the whole shortlist
const difficulty = await api("/v1/keywords/difficulty",
{ keywords: top20.map((i) => i.keyword), location: 2840, language: "en" });
const scores = Object.fromEntries(difficulty.items.map((i) => [i.keyword, i]));
// 5. merge: keyword, volume, difficulty, questions[] -- winnable first
const gaps = top20.map((i) => ({
keyword: i.keyword, volume: i.volume,
difficulty: scores[i.keyword].difficulty, status: scores[i.keyword].status,
questions: questions[i.keyword] || [],
})).sort((a, b) => (a.difficulty ?? 999) - (b.difficulty ?? 999));
writeFileSync("gaps.json", JSON.stringify(gaps, null, 2));
console.log(`gaps.json: ${gaps.length} keywords, winnable first`);