Skip to content

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. 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 ThreatCluster API page with the Get a free key button and the quick start, which begins with creating a key
    The API page. The key itself is created in Settings, under API & Feeds.
  2. 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_here
  3. 3. 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
    A terminal running the export: 84 indicators returned, none pending, 72 domains and 11 IP addresses written to two files
    Run on 29 September 2026. 84 confirmed indicators, none pending.
  4. 4. Check the files

    One value per line, no header, no comments. That is the format firewalls, Pi-hole and most DNS filters read.

    A terminal counting the lines in both files, 72 and 11, and printing the first six domains
    The two files from the run above.
  5. 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

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