example

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.

language
prerequisites

Export your key once.

$ setupcurl
export SEOFETCH_KEY=sof_live_…
# the scripts below use jq -- brew install jq / apt-get install jq
step 1

Pull the gap: what do they rank for that you don't?

→ requestcurl
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
← response200
{
  "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.

step 2

Floor it by volume and take the top of the list.

$ localrun locally
jq -c '[.data.items[] | select(.volume >= 1000)] | sort_by(-.volume) | .[:20]' gap.json

The 1,000 floor is policy, not physics — niche sites live happily below it.

step 3

Pull the questions people actually ask for your top gap keyword — shown here for a keyword from another niche; same call, any keyword.

→ requestcurl
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}'
← response200
{
  "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.

step 4

Score the shortlist; write the winnable ones first.

→ requestcurl
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"}'
← response200
{
  "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.

script

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.

→ rungaps.sh
#!/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"