Find backlink donors
Mine competitors' referring domains into a ranked candidate list for vetting.
Three of these four steps are seofetch API calls with a fixed JSON envelope; the middle step runs locally against the saved responses — a few lines in whichever language you're reading this in, just narrowing competitors' donor lists down to the domains they share.
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 referring domains for each of 2–3 competitors.
curl https://api.seofetch.com/v1/backlinks/domains \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"target": "competitor1.com", "limit": 100, "offset": 0}' \
-o comp1.json
import json
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/backlinks/domains",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"target": "competitor1.com", "limit": 100, "offset": 0},
)
with open("comp1.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/backlinks/domains", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ target: "competitor1.com", limit: 100, offset: 0 }),
});
const body = await resp.json();
writeFileSync("comp1.json", JSON.stringify(body, null, 2));
{
"id": "refe_spkai6b7z5fioovpnu5magzj",
"request_id": "req_6majcv6pmjecnfgzvgjfrr4zoe",
"object": "referring_domains",
"created_at": "2026-08-09T09:58:37Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 50,
"balance": 9857
},
"data": {
"target": "competitor1.com",
"total_count": 3160,
"limit": 100,
"offset": 0,
"items": [
{
"domain": "caglrc.cc",
"backlinks": 526,
"first_seen": "2023-11-02",
"lost_at": null,
"authority": 27,
"spam_score": 0
},
"…"
]
}
}
Run it once per competitor (50 credits each) and save each response to a file. You get the first 100 domains per call — check total_count and page with offset if you want the rest.
Intersect the lists — a domain linking to two or more competitors is far likelier to be a niche linker than a one-off friend-of-the-founder link.
jq -s '[.[].data.items[].domain] | group_by(.) | map(select(length > 1) | .[0])' comp1.json comp2.json comp3.json
import json
from collections import Counter
domains = []
for path in ("comp1.json", "comp2.json", "comp3.json"):
domains += [item["domain"] for item in json.load(open(path))["data"]["items"]]
candidates = sorted({d for d, n in Counter(domains).items() if n > 1})
print(candidates)
import { readFileSync } from "node:fs";
const files = ["comp1.json", "comp2.json", "comp3.json"];
const domains = files.flatMap(
(f) => JSON.parse(readFileSync(f)).data.items.map((i) => i.domain));
const counts = domains.reduce((m, d) => m.set(d, (m.get(d) || 0) + 1), new Map());
const candidates = [...new Set([...counts].filter(([, n]) => n > 1).map(([d]) => d))].sort();
console.log(candidates);
The intersection is your candidate list — candidates, not vetted donors. Directories and link networks intersect too; the next two steps are the filter.
Qualify each candidate before outreach: real organic footprint or not?
curl https://api.seofetch.com/v1/domains/overview \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "donorsite.io", "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": "donorsite.io", "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: "donorsite.io", location: 2840, language: "en" }),
});
const { data } = await resp.json();
{
"id": "doma_hixpevdimian2jkhz7k6levn",
"request_id": "req_b3iyr66tfjcgxmpq4u3ys5pse4",
"object": "domain_overview",
"created_at": "2026-08-09T09:58:37Z",
"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
}
}
}
}
Filter by keyword count, traffic estimate, and ranking spread (10 credits per candidate) — the cheap call that decides who's worth the expensive one.
Profile the donor you're about to pitch: the anchors pointing at them sketch what they publish and what gets cited.
curl https://api.seofetch.com/v1/backlinks/anchors \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d '{"target": "donorsite.io", "limit": 100, "offset": 0}'
import os
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
resp = requests.post(
"https://api.seofetch.com/v1/backlinks/anchors",
headers={"Authorization": f"Bearer {SEOFETCH_KEY}"},
json={"target": "donorsite.io", "limit": 100, "offset": 0},
)
data = resp.json()["data"]
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
const resp = await fetch("https://api.seofetch.com/v1/backlinks/anchors", {
method: "POST",
headers: { "Authorization": `Bearer ${SEOFETCH_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ target: "donorsite.io", limit: 100, offset: 0 }),
});
const { data } = await resp.json();
{
"id": "back_4m2ae2mkqavdd3rpzzftgluc",
"request_id": "req_p7cz32xo6jbgrpfa7vygsvedva",
"object": "backlink_anchors",
"created_at": "2026-08-09T09:58:37Z",
"elapsed_ms": 180,
"cache": "miss",
"credits": {
"charged": 50,
"balance": 9857
},
"data": {
"target": "donorsite.io",
"total_count": 3316,
"limit": 100,
"offset": 0,
"items": [
{
"anchor": "running shoes",
"backlinks": 597726,
"referring_domains": 247,
"first_seen": "2023-05-14"
},
{
"anchor": null,
"backlinks": 1201,
"referring_domains": 88,
"first_seen": "2024-01-09"
},
"…"
]
}
}
Commercial anchors usually mean listicles and comparisons; brand anchors lean news and mentions. A signal about what to pitch, not a guarantee of the page type.
Run the whole thing.
Each language below is the full pipeline — swap in your competitors, run it after exporting SEOFETCH_KEY: every candidate lands in donors.csv, sorted by estimated organic traffic — a triage order, not a donor-quality score, and the anchors pull is queued up for the donor you pick.
#!/usr/bin/env bash
# donors.sh -- competitor referring domains -> shared candidates -> qualified list.
set -euo pipefail
: "${SEOFETCH_KEY:?export SEOFETCH_KEY first}"
COMPETITORS=("competitor1.com" "competitor2.com" "competitor3.com") # swap in yours
api() { curl -sS --fail-with-body "https://api.seofetch.com$1" \
-H "Authorization: Bearer $SEOFETCH_KEY" \
-H "Content-Type: application/json" \
-d "$2"; }
# 1. referring domains per competitor -- first 100 each; page with offset
# toward .data.total_count when you want the full lists
i=0
for c in "${COMPETITORS[@]}"; do
i=$((i+1))
api /v1/backlinks/domains \
"$(jq -cn --arg t "$c" '{target: $t, limit: 100, offset: 0}')" > "comp$i.json"
done
# 2. the intersection is the candidate list
jq -s '[.[].data.items[].domain] | group_by(.) | map(select(length > 1) | .[0])' \
comp*.json > candidates.json
# 3. qualify every candidate: organic keywords + traffic estimate, sorted by
# estimated organic traffic -- a triage order, not a donor-quality score
: > donors.csv
jq -r '.[]' candidates.json | 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" >> donors.csv
done
sort -t, -k3 -rn donors.csv -o donors.csv
echo "donors.csv: domain, organic keywords, traffic estimate -- sorted by estimated organic traffic, a triage order not a donor-quality score"
# 4. before writing the pitch, see how your pick gets linked
d="donorsite.io" # a top row of donors.csv
api /v1/backlinks/anchors \
"$(jq -cn --arg t "$d" '{target: $t, limit: 100, offset: 0}')" > "anchors-$d.json"
jq '.data.items[:5]' "anchors-$d.json"
#!/usr/bin/env python3
"""donors.py -- competitor referring domains -> shared candidates -> qualified list."""
import csv
import os
from collections import Counter
import requests
SEOFETCH_KEY = os.environ["SEOFETCH_KEY"]
COMPETITORS = ["competitor1.com", "competitor2.com", "competitor3.com"] # swap in yours
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. referring domains per competitor -- first 100 each; page with offset
# toward total_count when you want the full lists
all_domains = []
for c in COMPETITORS:
data = api("/v1/backlinks/domains", {"target": c, "limit": 100, "offset": 0})
all_domains += [item["domain"] for item in data["items"]]
# 2. the intersection is the candidate list
candidates = sorted({d for d, n in Counter(all_domains).items() if n > 1})
# 3. qualify every candidate: organic keywords + traffic estimate, sorted by
# estimated organic traffic -- a triage order, not a donor-quality score
rows = []
for d in candidates:
data = api("/v1/domains/overview", {"domain": d, "location": 2840, "language": "en"})
rows.append((d, data["organic"]["keywords_count"], data["organic"]["traffic_estimate"]))
rows.sort(key=lambda r: -r[2])
with open("donors.csv", "w", newline="") as f:
csv.writer(f).writerows(rows)
print("donors.csv: domain, organic keywords, traffic estimate -- sorted by estimated organic traffic, a triage order not a donor-quality score")
# 4. before writing the pitch, see how your pick gets linked
donor = "donorsite.io" # a top row of donors.csv
anchors = api("/v1/backlinks/anchors", {"target": donor, "limit": 100, "offset": 0})
print(anchors["items"][:5])
#!/usr/bin/env node
// donors.mjs -- competitor referring domains -> shared candidates -> qualified list.
import { writeFileSync } from "node:fs";
const SEOFETCH_KEY = process.env.SEOFETCH_KEY;
if (!SEOFETCH_KEY) throw new Error("export SEOFETCH_KEY first");
const COMPETITORS = ["competitor1.com", "competitor2.com", "competitor3.com"]; // swap in yours
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. referring domains per competitor -- first 100 each; page with offset
// toward total_count when you want the full lists
let allDomains = [];
for (const c of COMPETITORS) {
const data = await api("/v1/backlinks/domains", { target: c, limit: 100, offset: 0 });
allDomains = allDomains.concat(data.items.map((i) => i.domain));
}
// 2. the intersection is the candidate list
const counts = allDomains.reduce((m, d) => m.set(d, (m.get(d) || 0) + 1), new Map());
const candidates = [...new Set([...counts].filter(([, n]) => n > 1).map(([d]) => d))].sort();
// 3. qualify every candidate: organic keywords + traffic estimate, sorted by
// estimated organic traffic -- a triage order, not a donor-quality score
const rows = [];
for (const d of candidates) {
const data = await api("/v1/domains/overview", { domain: d, location: 2840, language: "en" });
rows.push([d, data.organic.keywords_count, data.organic.traffic_estimate]);
}
rows.sort((a, b) => b[2] - a[2]);
writeFileSync("donors.csv", rows.map((r) => r.join(",")).join("\n") + "\n");
console.log("donors.csv: domain, organic keywords, traffic estimate -- sorted by estimated organic traffic, a triage order not a donor-quality score");
// 4. before writing the pitch, see how your pick gets linked
const donor = "donorsite.io"; // a top row of donors.csv
const anchors = await api("/v1/backlinks/anchors", { target: donor, limit: 100, offset: 0 });
console.log(anchors.items.slice(0, 5));