Skip to content

Build with the API / Sector ransomware watch for Slack

Sector ransomware watch for Slack

New dark-web ransomware victims for a sector/country, as a Slack message.

What you get

Lists leak-site victims posted in the last N days (free tier: up to 7), optionally filtered to one sector and/or country, and formats them as a Slack mrkdwn message with the week's most active groups underneath. If SLACK_WEBHOOK_URL is set the message is posted; otherwise it is printed so you can see exactly what would go out.

Endpoints
/darkweb/ransomware/victims /darkweb/ransomware/victims/facets
Credits per run
2
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/sector-ransomware-watch.py
    pip install requests
    export THREATCLUSTER_API_KEY=tc_live_your_key_here
  3. 3. Run it

    Without a Slack webhook set, the script prints the message it would send. Sector names are the ones the API returns. A wrong name prints the valid list.

    python3 sector-ransomware-watch.py --sector Healthcare
    A terminal running the script for the Healthcare sector and printing a Slack-formatted list of leak-site victims with the date, group, name and country
    Run on 29 September 2026. 22 healthcare organisations were listed on leak sites in the 7 days before. Each line links to the listing's page.
  4. 4. Read up on a group

    The message ends with the week's most active groups. Each has a page with its history, its leak-site addresses and the coverage it appears in.

    The ThreatCluster page for the Qilin ransomware group showing its status, tracked victims, a written summary and its leak-site addresses
    The page for Qilin, one of the groups in the run above.
  5. 5. Send it to Slack every morning

    Create an incoming webhook in Slack, set it in the environment, and schedule the script. A listing is the group's claim, so keep the word "listed" if you forward it.

    export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/here
    python3 sector-ransomware-watch.py --sector Healthcare --days 1
    
    # crontab -e, every day at 08:00
    0 8 * * * THREATCLUSTER_API_KEY=... SLACK_WEBHOOK_URL=... /usr/bin/python3 /opt/tc/sector-ransomware-watch.py --sector Healthcare --days 1

The code

sector-ransomware-watch.py, 134 lines. Python 3, needs requests.

#!/usr/bin/env python3
"""sector-ransomware-watch: new dark-web ransomware victims for a sector/country, as a Slack message.

What it does
  Lists leak-site victims posted in the last N days (free tier: up to 7), optionally
  filtered to one sector and/or country, and formats them as a Slack mrkdwn message
  with the week's most active groups underneath. If SLACK_WEBHOOK_URL is set the
  message is posted; otherwise it is printed so you can see exactly what would go out.

Endpoints
  GET /darkweb/ransomware/victims/facets   (1 credit)  sector/country/group counts
  GET /darkweb/ransomware/victims          (1 credit)  the victim rows

Cost: 2 credits per run. Works on the free tier (7-day window, 25 rows per list).

Usage
  python3 sector-ransomware-watch.py --sector Healthcare
  python3 sector-ransomware-watch.py --sector "Financial Services" --country US --days 3
  Sector names are the values returned by the facets endpoint (printed on a bad name).
"""
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="New ransomware victims for a sector/country -> Slack message")
    ap.add_argument("--sector", help="sector name exactly as the facets endpoint returns it")
    ap.add_argument("--country", help="ISO-2 country code, e.g. US, GB, DE")
    ap.add_argument("--days", type=int, default=7, help="lookback in days (free tier caps at 7)")
    ap.add_argument("--limit", type=int, default=25, help="max victims to list (free tier caps at 25)")
    args = ap.parse_args()

    facets = api_get("/darkweb/ransomware/victims/facets", days=args.days).json()
    sectors = {s["value"]: s["count"] for s in facets.get("sectors", [])}
    if args.sector and args.sector not in sectors:
        sys.exit("Unknown sector %r. Sectors seen in the window: %s" % (args.sector, ", ".join(sorted(sectors))))

    time.sleep(PACE_SECONDS)
    filters = {}
    if args.sector:
        filters["sector"] = args.sector
    if args.country:
        filters["country"] = args.country.upper()
    data = api_get("/darkweb/ransomware/victims", days=args.days, limit=args.limit, **filters).json()
    victims = data.get("victims", [])

    scope = " / ".join(x for x in [args.sector, args.country and args.country.upper()] if x) or "all sectors"
    lines = ["*ThreatCluster ransomware watch: %s, last %d days*" % (scope, data.get("lookback_days") or args.days)]
    if not victims:
        lines.append("No new leak-site victims matched.")
    else:
        shown = "%d new leak-site victims" % len(victims)
        if len(victims) >= args.limit:
            shown += " (showing the first %d)" % args.limit
        lines.append(shown)
        for v in victims:
            posted = (v.get("discovered") or "")[:10]
            name = v.get("name") or v.get("victim") or "?"
            where = v.get("country") or "--"
            sector = "" if args.sector else "  [%s]" % (v.get("sector") or "unknown sector")
            lines.append("• %s  %s  →  <%s/dark-web/victim/%s|%s>  (%s)%s"
                         % (posted, v.get("group", "?"), SITE, v.get("id", ""), name, where, sector))

    top_groups = ", ".join("%s %d" % (g["value"], g["count"]) for g in facets.get("groups", [])[:5])
    top_sectors = ", ".join("%s %d" % (s["value"], s["count"]) for s in facets.get("sectors", [])[:5])
    lines.append("_Most active groups this window: %s_" % top_groups)
    lines.append("_Most hit sectors this window: %s_" % top_sectors)
    lines.append("Source: %s/dark-web/victims" % SITE)
    message = "\n".join(lines)

    webhook = os.environ.get("SLACK_WEBHOOK_URL")
    if webhook:
        resp = requests.post(webhook, json={"text": message}, timeout=30)
        if resp.status_code >= 400:
            sys.exit("Slack webhook returned HTTP %d: %s" % (resp.status_code, resp.text[:200]))
        print("Posted %d victims to Slack." % len(victims))
    else:
        print(message)
    report_credits()


if __name__ == "__main__":
    main()

The shell one-liner, sector-ransomware-watch.sh:

#!/usr/bin/env bash
# sector-ransomware-watch.sh: leak-site victims for a sector (arg 1, optional) in the last 7 days. 1 credit. Free tier OK.
# usage: ./sector-ransomware-watch.sh Healthcare
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'*}"; }
SECTOR="${1:-}"
tc_get /darkweb/ransomware/victims --data-urlencode days=7 --data-urlencode limit=25 --data-urlencode "sector=$SECTOR" \
 | jq -r --arg site "$SITE" '"ThreatCluster ransomware watch: \(.count) victims", (.victims[] | "• \(.discovered[:10])  \(.group)  →  \(.name)  (\(.country // "--"))  \($site)/dark-web/victim/\(.id)")'

Output

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

*ThreatCluster ransomware watch: Healthcare, last 7 days*
22 new leak-site victims
• 2026-09-29  chaos  →  <https://threatcluster.io/dark-web/victim/1afc831517e1ddb8|carolinaasthma.com>  (US)
• 2026-09-28  threeam  →  <https://threatcluster.io/dark-web/victim/f372319927cb9c35|coosalud.com>  (CO)
• 2026-09-28  play  →  <https://threatcluster.io/dark-web/victim/db685052c5c55bf5|Ever Ready First Aid>  (US)
• 2026-09-28  qilin  →  <https://threatcluster.io/dark-web/victim/d8965ebc638d8433|New World Diagnostics>  (PH)
• 2026-09-28  interlock  →  <https://threatcluster.io/dark-web/victim/5491aae79305a0f7|The Center for Kidney Care>  (US)
• 2026-09-28  safepay  →  <https://threatcluster.io/dark-web/victim/142e2f42a92073b2|bio-strath.com>  (CH)
• 2026-09-28  panzer  →  <https://threatcluster.io/dark-web/victim/35a3656a6b7ed5ff|SMCare>  (--)
• 2026-09-26  thegentlemen  →  <https://threatcluster.io/dark-web/victim/08178a531633ff94|ANP Health>  (BR)
• 2026-09-25  metaencryptor  →  <https://threatcluster.io/dark-web/victim/f2b116f8b7ff0f0c|Platinum Healthcare Staffing>  (US)
• 2026-09-25  everest  →  <https://threatcluster.io/dark-web/victim/611d3567b69c4ccb|Morula IVF>  (ZA)
• 2026-09-25  wallstreet  →  <https://threatcluster.io/dark-web/victim/d155e77beca536c4|Breast Implant Center of Hawaii>  (US)
• 2026-09-25  lockbit5  →  <https://threatcluster.io/dark-web/victim/fb3570c7f749402c|taspenlife.com>  (KZ)
• 2026-09-25  incransom  →  <https://threatcluster.io/dark-web/victim/d1ba2b0c039b2d62|pharma5.ma>  (MA)
• 2026-09-24  pear  →  <https://threatcluster.io/dark-web/victim/5258e768e55fc0ac|Westside GI>  (US)
• 2026-09-24  pear  →  <https://threatcluster.io/dark-web/victim/289639c13808c3d0|Indroj Medical Group Inc.>  (US)
• 2026-09-24  incransom  →  <https://threatcluster.io/dark-web/victim/24f992c5887d6cb5|welgenone.com>  (US)
• 2026-09-24  zawoo  →  <https://threatcluster.io/dark-web/victim/c07f06565c5fb9f3|www.francare.com>  (FR)
• 2026-09-24  dragonforce  →  <https://threatcluster.io/dark-web/victim/e4c093b3ad61bb46|Arizona Vascular Medical Equipment, Inc>  (US)
• 2026-09-23  booba project  →  <https://threatcluster.io/dark-web/victim/bbfa80af274a4c4b|Smart Eye Care>  (--)
• 2026-09-22  shinyhunters  →  <https://threatcluster.io/dark-web/victim/fa93743f6499bc63|Fresenius Medical Care>  (DE)
• 2026-09-22  titan  →  <https://threatcluster.io/dark-web/victim/c0d3e4d5158b5dac|Grupo Hospifar S.R.L.>  (AR)
• 2026-09-22  titan  →  <https://threatcluster.io/dark-web/victim/f8a683a8d1118949|Sherman Chan, DDS, Inc.>  (--)
_Most active groups this window: clop 23, thegentlemen 20, qilin 16, incransom 10, safepay 9_
_Most hit sectors this window: Manufacturing 32, Not Found 28, Professional Services 28, Technology 23, Healthcare 22_
Source: https://threatcluster.io/dark-web/victims
[threatcluster] credits used this run: 2

The file on GitHub: sector-ransomware-watch.pyand sector-ransomware-watch.sh.

Goes well with

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