Build with the API / IOC blocklist export

IOC blocklist export

Validated malicious domains and IPs as plain blocklists.

What you get

Pulls the confirmed indicators extracted from the last N hours of reporting and writes two plain files, one indicator per line and nothing else, ready for a firewall object group or a Pi-hole adlist: blocklist-domains.txt and blocklist-ips.txt. Counts and the number of indicators still pending validation are printed.

Endpoints
/iocs/export
Credits per run
3
Key
Works on the free key

The code

ioc-blocklist-export.py, 115 lines. Python 3, needs requests.

#!/usr/bin/env python3
"""ioc-blocklist-export: validated malicious domains and IPs as plain blocklists.

What it does
  Pulls the confirmed indicators extracted from the last N hours of reporting and writes
  two plain files, one indicator per line and nothing else, ready for a firewall object
  group or a Pi-hole adlist: blocklist-domains.txt and blocklist-ips.txt. Counts and the
  number of indicators still pending validation are printed.

Endpoints
  GET /iocs/export?types=domain,ip&format=json   (3 credits)

Cost: 3 credits per run. Works on the free tier (window capped at 168 hours).

Usage
  python3 ioc-blocklist-export.py                      # last 7 days into ./
  python3 ioc-blocklist-export.py --hours 24 --out-dir /etc/pihole/lists
"""
import argparse
import json
import os
import sys
import time

import requests

API_BASE = os.environ.get("THREATCLUSTER_API_BASE", "https://threatcluster.io/api/public/v1").rstrip("/")
API_KEY = os.environ.get("THREATCLUSTER_API_KEY", "")
SITE = os.environ.get("THREATCLUSTER_SITE", "https://threatcluster.io")  # where the linked pages live
PACE_SECONDS = 2.1  # free keys get 30 requests/min; ~28/min keeps loops clear of 429s
_credits = {"used": 0, "remaining": None}


def _auth_headers():
    # A tc_... key is sent as X-API-Key; anything else is a short-lived bearer (e.g. from `tc login`).
    if not API_KEY:
        sys.exit("THREATCLUSTER_API_KEY is not set. Free keys: https://threatcluster.io/about/api")
    if API_KEY.startswith("tc_"):
        return {"X-API-Key": API_KEY}
    return {"Authorization": "Bearer " + API_KEY}


def api_get(path, _allow=(), **params):
    """GET one endpoint. Exits non-zero with the API's own error body, unless the
    status is listed in _allow (per-item lookups where a 404 is itself an answer)."""
    r = requests.get(API_BASE + path, headers=_auth_headers(), params=params, timeout=60)
    if "X-Request-Cost" in r.headers:
        _credits["used"] += int(r.headers["X-Request-Cost"])
    if "X-RateLimit-Remaining" in r.headers:
        _credits["remaining"] = r.headers["X-RateLimit-Remaining"]
    if r.status_code >= 400 and r.status_code not in _allow:
        try:
            body = json.dumps(r.json())
        except ValueError:
            body = r.text[:500]
        sys.exit("HTTP %d from %s: %s" % (r.status_code, path, body))
    return r


def cluster_url(cluster):
    # The free payload carries `slug` (canonical page) and `cluster_id`; the last 8 hex
    # chars of the id also resolve, via a 301 to the slug, when slug is missing.
    ident = cluster.get("slug") or cluster.get("short_id") or cluster.get("cluster_id", "").replace("-", "")[-8:]
    return "%s/cluster/%s" % (SITE, ident)


def report_credits():
    # Stderr, so it never pollutes piped output (brief.md, blocklists, JSON).
    remaining = _credits["remaining"] if _credits["remaining"] is not None else "n/a"
    sys.stderr.write("[threatcluster] credits used this run: %d, remaining today: %s\n" % (_credits["used"], remaining))


def main():
    ap = argparse.ArgumentParser(description="Export confirmed domains/IPs as blocklists")
    ap.add_argument("--hours", type=int, default=168, help="lookback in hours (free tier caps at 168)")
    ap.add_argument("--confidence", default="confirmed", help="confirmed (default) or all")
    ap.add_argument("--out-dir", default=".", help="directory for the two list files")
    args = ap.parse_args()

    data = api_get("/iocs/export", types="domain,ip", format="json", hours=args.hours, confidence=args.confidence).json()
    domains, ips = set(), set()
    by_conf = {}
    for ioc in data.get("iocs", []):
        by_conf[ioc.get("confidence")] = by_conf.get(ioc.get("confidence"), 0) + 1
        t, v = ioc.get("type"), (ioc.get("value") or "").strip().lower()
        if not v:
            continue
        if t == "domain":
            domains.add(v)
        elif t in ("ipv4", "ipv6", "ip"):
            ips.add(v)

    os.makedirs(args.out_dir, exist_ok=True)
    paths = {}
    for name, values in (("blocklist-domains.txt", domains), ("blocklist-ips.txt", ips)):
        path = os.path.join(args.out_dir, name)
        with open(path, "w", encoding="utf-8") as fh:
            fh.write("\n".join(sorted(values)) + ("\n" if values else ""))
        paths[name] = path

    print("Window: last %s hours, confidence filter: %s" % (data.get("hours", args.hours), data.get("confidence_filter", args.confidence)))
    print("Indicators returned: %d (%s)" % (data.get("count", len(domains) + len(ips)),
                                           ", ".join("%s=%d" % kv for kv in sorted(by_conf.items(), key=lambda kv: str(kv[0])))))
    print("Still pending validation (not exported): %s" % data.get("pending_count", 0))
    print("%-22s %5d  ->  %s" % ("domains", len(domains), paths["blocklist-domains.txt"]))
    print("%-22s %5d  ->  %s" % ("ips", len(ips), paths["blocklist-ips.txt"]))
    if domains:
        print("first domains: " + ", ".join(sorted(domains)[:5]))
    if ips:
        print("first ips:     " + ", ".join(sorted(ips)[:5]))
    report_credits()


if __name__ == "__main__":
    main()

The shell one-liner, ioc-blocklist-export.sh:

#!/usr/bin/env bash
# ioc-blocklist-export.sh: confirmed malicious domains and IPs from the last 7 days, one per line, into two files. 3 credits. Free tier OK.
# usage: ./ioc-blocklist-export.sh [out-dir]
set -euo pipefail
: "${THREATCLUSTER_API_KEY:?set THREATCLUSTER_API_KEY (free keys: https://threatcluster.io/about/api)}"
BASE="${THREATCLUSTER_API_BASE:-https://threatcluster.io/api/public/v1}"
SITE="${THREATCLUSTER_SITE:-https://threatcluster.io}"
# A tc_... key goes in X-API-Key; anything else is a bearer (e.g. from `tc login`).
if [[ "$THREATCLUSTER_API_KEY" == tc_* ]]; then AUTH="X-API-Key: $THREATCLUSTER_API_KEY"; else AUTH="Authorization: Bearer $THREATCLUSTER_API_KEY"; fi
# tc_get <path> [curl -G args...]: prints the body; on HTTP >= 400 prints the API error to stderr and fails.
tc_get() { local path=$1; shift; local out code
  out=$(curl -sS -G -H "$AUTH" -w $'\n%{http_code}' "$BASE$path" "$@"); code=${out##*$'\n'}
  if [ "$code" -ge 400 ]; then echo "HTTP $code from $path: ${out%$'\n'*}" >&2; return 1; fi; printf '%s' "${out%$'\n'*}"; }
OUT="${1:-.}"; mkdir -p "$OUT"
tc_get /iocs/export --data-urlencode types=domain,ip --data-urlencode format=json --data-urlencode hours=168 > "$OUT/.iocs.json"
jq -r '.iocs[] | select(.type=="domain") | .value' "$OUT/.iocs.json" | sort -u > "$OUT/blocklist-domains.txt"
jq -r '.iocs[] | select(.type=="ipv4" or .type=="ipv6") | .value' "$OUT/.iocs.json" | sort -u > "$OUT/blocklist-ips.txt"
echo "domains: $(wc -l < "$OUT/blocklist-domains.txt")  ips: $(wc -l < "$OUT/blocklist-ips.txt")  pending validation: $(jq .pending_count "$OUT/.iocs.json")"
rm -f "$OUT/.iocs.json"

Output

From a run on 3 September 2026. Yours will differ as the corpus moves.

[threatcluster] credits used this run: 0, remaining today: n/a
Window: last 168 hours, confidence filter: confirmed
Indicators returned: 94 (high=78, medium=16)
Still pending validation (not exported): 7
domains                   78  ->  /home/james/Desktop/threatcluster/tc-testing/threatcluster-api/examples/recipes/_validation/blocklist/blocklist-domains.txt
ips                       14  ->  /home/james/Desktop/threatcluster/tc-testing/threatcluster-api/examples/recipes/_validation/blocklist/blocklist-ips.txt
first domains: advancedplacyncement.vu, api.datalayerservice.com, api.technodatabase.net, arandasoftzfdware.vu, avisoretentiunionllc.vu
first ips:     103.45.66.18, 144.31.53.78, 176.65.148.184, 178.16.54.253, 185.254.222.105

Run it

  1. Get a free key at /api. Every account has one; it covers the endpoints above.
  2. export THREATCLUSTER_API_KEY=… your key.
  3. python3 ioc-blocklist-export.py

The file on GitHub: ioc-blocklist-export.py and ioc-blocklist-export.sh.

Goes well with

All recipes: /build. Endpoint reference: /api/public/v1/docs.