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
Step by step
1. Get a key
Sign in, open Settings, then API & Feeds, and select Generate API key. The key is shown once, so copy it. Every account has one and the free key covers this recipe.

The API page. The key itself is created in Settings, under API & Feeds. 2. Download the script and set the key
The script is one file with one dependency. It reads the key from the environment, so the key never goes into the file.
curl -sO https://raw.githubusercontent.com/Jam0k/Cyber-Threat-Intelligence-API/main/examples/recipes/ioc-blocklist-export.py pip install requests export THREATCLUSTER_API_KEY=tc_live_your_key_here3. Run it
The script pulls the last 7 days of confirmed indicators and writes two plain lists, one for domains and one for IP addresses. Indicators still waiting for validation are counted and left out.
python3 ioc-blocklist-export.py
Run on 29 September 2026. 84 confirmed indicators, none pending. 4. Check the files
One value per line, no header, no comments. That is the format firewalls, Pi-hole and most DNS filters read.

The two files from the run above. 5. Feed a firewall or Pi-hole
Write straight into the folder your tool reads, on a schedule. A pull costs 3 credits, so every 4 hours spends 18 of the free key's 100 a day.
python3 ioc-blocklist-export.py --hours 24 --out-dir /etc/pihole/lists # crontab -e, every 4 hours 0 */4 * * * THREATCLUSTER_API_KEY=... /usr/bin/python3 /opt/tc/ioc-blocklist-export.py --out-dir /etc/pihole/lists
The code
ioc-blocklist-export.py, 116 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).
# Keys without a daily cap send no remaining-credits header.
left = ", remaining today: %s" % _credits["remaining"] if _credits["remaining"] is not None else ""
sys.stderr.write("[threatcluster] credits used this run: %d%s\n" % (_credits["used"], left))
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 29 September 2026. Yours will differ as the corpus moves.
Window: last 168 hours, confidence filter: confirmed Indicators returned: 84 (high=75, medium=9) Still pending validation (not exported): 0 domains 72 -> ./blocklist-domains.txt ips 11 -> ./blocklist-ips.txt first domains: 0b48fafd6fbe.skyleen.fr, 10729e014d0e.skyleen.fr, 1cvvts.cyou, 266297c6df27.skyleen.fr, 2nftgz.cyou first ips: 103.101.85.123, 104.219.234.138, 134.122.155.135, 172.94.9.194, 193.178.158.61 [threatcluster] credits used this run: 0
The file on GitHub: ioc-blocklist-export.pyand 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.