Build

Build a Data Breach Monitor With a Free API

Published 21 September 2026 · 9 min read · Python 3.8+, standard library only

A breach monitor is a small program: fetch a list, compare it to the last one you saw, tell someone about the difference. The hard part is not the code, it is deciding what deserves an alert, because a monitor that cries wolf gets muted, and a muted monitor is worse than none at all. Here is a working one, and the reasoning behind each decision in it.

What you need

No API key, no account, no signup. This uses MyRecon's public data API: static JSON on a CDN, rebuilt daily, CORS enabled, free under CC BY 4.0.

Python 3.8 or newer with the standard library. No packages to install. The whole monitor is about forty lines.

Step 1: see what you are working with

curl -s https://www.myrecon.xyz/data/v1/latest.json

The envelope is the same on every endpoint, api_version, generated, licence, attribution: and then the payload. A breach record carries a slug (your stable key), a band (Severe, Serious, Moderate), an accounts count, a domain, and data_classes listing what was exposed.

The full field reference is on the API page. Three fields do most of the work in a monitor: slug, added_date and band.

Step 2: the monitor

Save this as breach_monitor.py. It keeps a small state file of slugs it has already reported, so it only tells you about genuinely new entries.

#!/usr/bin/env python3
"""Alert on new breaches at domains I care about."""

import json
import pathlib
import urllib.request

API   = "https://www.myrecon.xyz/data/v1/breaches.json"
STATE = pathlib.Path("seen_breaches.json")

# The domains you actually have accounts with. An empty set means
# "tell me about everything", which you will regret by Thursday.
WATCH = {"linkedin.com", "dropbox.com", "adobe.com"}

# Alert on anything this severe regardless of the watch list.
MIN_BAND = {"Severe"}


def fetch():
    req = urllib.request.Request(API, headers={"User-Agent": "breach-monitor/1.0"})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)


def load_seen():
    if not STATE.exists():
        return set()
    return set(json.loads(STATE.read_text()))


def save_seen(seen):
    STATE.write_text(json.dumps(sorted(seen)))


def interesting(b):
    return b.get("domain") in WATCH or b.get("band") in MIN_BAND


def main():
    data = fetch()
    seen = load_seen()

    # First run: record everything and alert on nothing. Otherwise the
    # first execution pages you about eight years of history.
    first_run = not seen

    new = [b for b in data["breaches"]
           if b["slug"] not in seen and interesting(b)]

    for b in data["breaches"]:
        seen.add(b["slug"])
    save_seen(seen)

    if first_run:
        print(f"Baseline recorded: {len(seen)} breaches known. No alerts sent.")
        return

    for b in sorted(new, key=lambda x: x.get("added_date", "")):
        print(f"[{b['band']}] {b['title']} - {b.get('accounts', 0):,} accounts")
        print(f"    exposed: {', '.join(b.get('data_classes', [])[:5])}")
        print(f"    {b['url']}")

    if not new:
        print(f"Nothing new. Archive generated {data['generated']}.")


if __name__ == "__main__":
    main()

Run it once to take a baseline, then on a schedule:

python breach_monitor.py            # first run: records, alerts nothing
crontab -e                          # then, daily at 09:00
0 9 * * * /usr/bin/python3 /path/to/breach_monitor.py

Step 3: the decisions that matter

Alert on less than you think

The instinct is to alert on every new record. Resist it. A monitor that fires constantly gets muted within a week, and a muted monitor is worse than no monitor, because you believe you have coverage you do not have.

The WATCH set and MIN_BAND above are the whole point. Alert on the services you actually use, plus anything severe enough to be worth knowing regardless. Everything else can wait for you to go and look.

Handle the first run separately

Without the first_run branch, the first execution treats eight years of archive as breaking news and sends you several hundred alerts. Every monitor ever written has made this mistake once. Take a silent baseline, then alert on the delta.

Poll daily, not hourly

The archive rebuilds once a day. Polling every minute fetches the identical bytes fourteen hundred times and finds nothing a daily check would miss. Read generated and skip the work entirely if it has not moved:

if data["generated"] == last_generated:
    return          # nothing rebuilt since we last looked

Fail loudly at yourself, quietly at your users

If the fetch fails, the monitor should complain in its own logs, but if this runs on a page other people see, render nothing rather than an error. A visitor to your site should never learn that a third-party feed you chose is having a bad day.

Cache on your side

If you are serving a page, fetch on a schedule into your own store and render from that. Do not call an upstream API per page view: it is slower for your users, it breaks when the upstream does, and it is the behaviour that gets free services rate-limited for everyone.

Keep verified. Some records are unverified, nobody has confirmed the corpus is genuine. If your monitor drops that field, an unconfirmed claim reaches your users looking exactly like a confirmed incident. If your alert has room for one flag, make it this one.

Step 4: putting it on a page

If what you want is a breach list on your site rather than an alert in your terminal, the widget does the rendering for you:

<div id="myrecon-breaches"></div>
<script src="https://www.myrecon.xyz/assets/js/embed.js"
        data-count="5" data-theme="auto" async></script>

It renders inside a shadow root, so your CSS cannot break it and it cannot leak into your page. It sets no cookies and stores nothing. And it draws the attribution itself, which matters, see below.

Or fetch it yourself in the browser, since CORS is open:

const r = await fetch("https://www.myrecon.xyz/data/v1/latest.json");
const { breaches } = await r.json();
document.querySelector("#list").textContent =
  breaches.map(b => `${b.title} (${b.band})`).join("\n");

What this cannot do

This API serves breach metadata: which incidents happened, what was exposed, how severe. It holds no personal records and cannot be queried by email address. A monitor built on it tells you "a service you use was breached", which you then act on. It cannot tell you "your address appeared in this dump".

That is a deliberate boundary, not a gap. Per-address checking has entirely different privacy implications and belongs behind consent, not on an open endpoint. If you need it programmatically, HIBP's API is built for exactly that.

Attribution

The data is Have I Been Pwned's, licensed CC BY 4.0. If your monitor is private, nothing is required. If you publish the data, a dashboard, a site, a bot others read, attribution is a condition of the licence, and it is owed to Have I Been Pwned, not to us. One line does it:

Breach data from Have I Been Pwned (CC BY 4.0), via MyRecon.

Every payload carries an attribution object saying the same thing, so it travels with the data whether or not anyone reads this page.

Common questions

Is there a free breach API with no key?

Yes, this one. No account, no key, no rate limit beyond fair use, CORS enabled. The data comes from Have I Been Pwned under CC BY 4.0, so credit them if you publish it.

How often should it poll?

Once or twice a day. The archive rebuilds daily, so more frequent polling returns identical bytes. Check the generated field to skip work when nothing has changed.

Can I monitor a specific email address?

Not with this API, it holds metadata, not records. Use HIBP's own API for per-address checks. A metadata monitor tells you a service you use was breached, which is a different and still useful signal.

What is the most common mistake?

Alerting on everything. A monitor that fires constantly gets muted, and a muted monitor is worse than none because you think you are covered. Filter before you notify.

Related

← API documentation