Skip to content

Backlink data as JSON: $100 free credit, no card, Google sign-in.

SEOMasters.dev
SEOMasters.devSign up

Bulk DR for a client list, as a 40-line Python job

You have a spreadsheet of domains and a dashboard that needs one number per row. A backlink domain checker for that job is mostly plumbing, and this script is the plumbing.

The script

It reads domains.txt, sends 200 domains per request, keeps each raw response in SQLite, and prints what the run cost. We store the whole payload because field names are best copied from a real response, not from a blog post.

python
import os, sqlite3, time, json, requests

BASE = "https://seomasters.dev/api.php"
KEY = os.environ["SEO_API_KEY"]
BATCH = 200

db = sqlite3.connect("dr.db")
db.execute("create table if not exists raw (ts int, batch text, body text)")

domains = [d.strip() for d in open("domains.txt") if d.strip()]
spent = 0
for i in range(0, len(domains), BATCH):
    chunk = domains[i:i + BATCH]
    for attempt in range(4):
        try:
            r = requests.get(BASE, timeout=30,
                params={"action": "bulk_dr", "domains": ",".join(chunk)},
                headers={"Authorization": "Bearer " + KEY})
            break
        except requests.RequestException:
            time.sleep(2 ** attempt)
    else:
        raise SystemExit(f"network gave up at batch {i}")
    if r.status_code in (401, 402):
        raise SystemExit(f"HTTP {r.status_code}: fix the key or top up, do not retry")
    body = r.json()
    if not body.get("ok"):
        raise SystemExit(f"ok=false at batch {i}: {json.dumps(body)[:300]}")
    db.execute("insert into raw values (?,?,?)", (int(time.time()), str(i), r.text))
    db.commit()
    print(body.get("_credits"))

What each part is doing

action=bulk_dr

One credit per call. The documented ceiling is 2,000 domains per call.

domains=

A GET parameter here. The endpoint also accepts a POST body, which is the better choice once lists get long. Test the separator with two domains before a full run.

BATCH = 200

Our habit, not a limit: 200 short domains keep the URL well under common proxy limits.

_credits

Present on authenticated responses: the cost of the call and what remains. Printing it each batch is the cheapest audit trail.

Raw body column

You parse later, once, against a stored sample. A schema change on our side then costs you a query, not a rerun.

A hypothetical run, with the arithmetic

This is an example, not a customer story. Imagine an agency that tracks 1,800 client and prospect domains in a Google Sheet and wants DR refreshed every Monday.

At 200 domains per batch that is 9 requests. Nine requests at 1 credit each is 9 credits, or $0.0009 per run, and over 52 Mondays the total is 468 credits, which rounds to under five cents.

Cost is not the constraint. Freshness is. The index rebuilds about monthly, so weekly runs will show the same values three weeks out of four, and a monthly run captures the same movement for roughly a quarter of the calls.

So run it monthly and keep the weekly sheet for human edits. Details on why the score can sit still are in the DR reference.

Things that go wrong in this script

SymptomCauseFix
SystemExit: HTTP 402Balance is zeroTop up from the account page, rerun; finished batches are already in SQLite
SystemExit: HTTP 401Env var empty or key typoPrint len(KEY) and check the prefix sk_
ok=false on batch 3Bad value in the list, such as a full URLStrip scheme and path before sending, see the error table
Same DR as last weekIndex has not rebuilt yetExpected; compare monthly
Long URL rejectedBatch too large for GETLower BATCH or move to a POST body

Three extensions worth the afternoon

Store the batch number next to the timestamp. It is already in the table above, and it lets you rerun only the batch that failed after a 402 mid-run.

Write a small view over the raw table once you have inspected a real response: domain, score, fetched-at. Dashboards read the view, and the raw column stays as your evidence if a number is ever questioned.

Run it from GitHub Actions or a plain cron on a small VPS. Either is fine. What matters is that the job exits non-zero on any failure, because a silent failure looks like a stale chart three weeks later.

Sorting the output is a front-end matter. Rank the portfolio by score, bucket it, or compare it with dr_distribution (1 credit) to see where each client sits among all 181,132,097 domains.

Before you schedule it

  • Dedupe and lowercase the list first
  • Decide once, in one function, whether www. stays or goes
  • Keep the key in a secret store, not in crontab
  • Alert on a non-zero exit code
  • Run the credit budget numbers for your own volume