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
- Get a free key at /api. Every account has one; it covers the endpoints above.
export THREATCLUSTER_API_KEY=… your key.python3 ioc-blocklist-export.py
The file on GitHub: ioc-blocklist-export.py and ioc-blocklist-export.sh.
Goes well with
- SIEM indicator enrichment: What does ThreatCluster know about this indicator?
- A lookup tool for LLM agents: A tool-calling function `threatcluster_lookup(query)` for LLM agents.
- Brand and domain dark-web monitor: Are my brands or domains showing up on the dark web?
- Splunk integration: scripted inputs and lookup blocklists
All recipes: /build. Endpoint reference: /api/public/v1/docs.