Breach research, Product updates

Domain Breach Check by API: Verify Once, Pull the Report Anytime

September 11, 2026
Brand-blue banner: Verify once. Pull the report anytime. A white request card showing POST /v1/domain-breaches/ with a masked x-api-key header, six coloured tabs for the six response blocks

The dashboard is a good place to look at your domain’s breach exposure. It’s a bad place to keep looking. Nobody opens a dashboard every Monday for a year.

A script will, though.

This post is about the one endpoint on the XposedOrNot API built for that. Prove you own a domain once. Get a key. After that, one POST returns every breach in our index that touches an address on your domain, as JSON, whenever your script asks. Free. On 11 September 2026 that index stood at 783 breaches and 11,621,497,431 records. (One record is one leaked row. Not one person.)

Happy reading the dashboard once a month? Stop here. Come back the day you want the result in a ticket, a spreadsheet or a Slack channel, with no human clicking.

What you need before you start

  • A domain you control. Not a client’s, not a partner’s. Yours.
  • A way to prove it’s yours. One of the mailboxes security@, admin@, webmaster@, postmaster@ or hostmaster@. Or DNS access for a TXT record. Or the ability to drop a file on the web root.
  • About ten minutes for the setup.
  • curl, or Python 3. Nothing else to install.

That’s it. No account, no card.

Step one: verify the domain (once)

Go to xposedornot.com/domain, type the domain, pick a route.

Email is the quickest. A one-time link goes to one of the five mailboxes above. You have 30 minutes to click it. No such mailbox? Add a TXT record instead; the DNS guide shows the exact record and how long propagation takes. If DNS belongs to someone else, upload a file to the web root.

You’ll also be asked for a monitoring address on the same domain. Breach alerts go there from now on, so pick a mailbox somebody reads.

The setup post has screenshots of every step if you want them.

Step two: get the key

Once the domain shows as verified, the API key is waiting in the CxO dashboard. Ask for the passwordless sign-in link using an address on the verified domain (the monitoring address is the safe choice). The FAQ entry on keys walks through the click path.

One key per account, and it covers every domain you’ve verified under it. Seven domains, one key, one call. (I read that in the endpoint’s source: the key resolves to your account, then the report loops over all your verified domains.)

Put the key in an environment variable and leave it there:

export XON_API_KEY="paste-the-key-here"

Not in the script. Not in a shell alias you’ll commit by mistake. We’ve seen keys turn up in public gists. It’s never a fun email to send.

Request to response: verify the domain once on xposedornot.com, take the key from the dashboard, POST it to /v1/domain-breaches/, get six JSON blocks back

Step three: the one call

curl -s -L -X POST \
  -H "x-api-key: $XON_API_KEY" \
  -H "Content-Length: 0" \
  https://api.xposedornot.com/v1/domain-breaches/

Three things trip people up here.

It’s a POST with no body. No domain parameter, no domain in the URL. The key carries your account, and your account carries the verified domains, so there’s nothing to send. Which also means you can’t aim it at a domain you haven’t verified. Deliberate.

The docs mark Content-Length: 0 as required, so send it. (The endpoint answered me without it today, but the docs are the contract, and a proxy somewhere will be stricter than curl.)

Forget the header and the error isn’t the 401 you’d expect. It’s a 422. The key is a required field, so a missing one fails validation before authentication even runs:

{"detail":[{"type":"missing","loc":["header","x-api-key"],"msg":"Field required","input":null}]}

That’s the real response from a keyless call on 11 September 2026. Here’s every answer the endpoint gives:

CodeMeaningWhat to do
200Report returnedRead metrics
401Key present but wrongCheck the variable, then the dashboard
422Key header missingAdd -H "x-api-key: ..."
429Hourly or daily cap hitWait for that window to reset, then retry
405You sent a GETMake it a POST

A verified domain with no breaches isn’t an error. You get a 200, Domain_Summary shows the domain with 0, and the other blocks come back empty. The script below handles that without a special case.

The success response is one object called metrics with six blocks in it, plus a status field. Below is a worked sample. The domain and the address counts are invented; the breach names, dates, record counts and password_risk values are real entries from the catalog, and the shape is taken from the endpoint’s source, not from a live pull.

The high-level view, four blocks of counts:

{
  "status": "success",
  "metrics": {
    "Domain_Summary":  { "example.com": 41 },
    "Breach_Summary":  { "LinkedIn": 14, "Dropbox": 11, "Dubsmash": 9, "Canva": 5, "AerServ": 2 },
    "Top10_Breaches":  { "LinkedIn": 14, "Dropbox": 11, "Dubsmash": 9, "Canva": 5, "AerServ": 2 },
    "Yearly_Metrics":  { "2007": 0, "2012": 25, "2014": 2, "2018": 9, "2019": 5, "2026": 0 }
  }
}

(Yearly_Metrics really runs every year from 2007 to the current one; the zero years are cut here to save space.)

The detailed view, shortened to one breach and three address rows:

{
  "metrics": {
    "Detailed_Breach_Info": {
      "Dubsmash": {
        "breached_date": "2018-12-01T00:00:00+00:00",
        "logo": "Dubsmash.png",
        "password_risk": "plaintext",
        "searchable": true,
        "xposed_data": "Email addresses;Usernames;Passwords",
        "xposed_records": 161835382,
        "xposure_desc": "Dubsmash is a video-sharing app that suffered a data breach in December 2018 ..."
      }
    },
    "Breaches_Details": [
      { "email": "a.rao@example.com",   "domain": "example.com", "breach": "Dubsmash" },
      { "email": "a.rao@example.com",   "domain": "example.com", "breach": "LinkedIn" },
      { "email": "j.okoro@example.com", "domain": "example.com", "breach": "Dropbox" }
    ]
  }
}

Notice a.rao appears twice. One address, two breaches, two rows. That’s why Domain_Summary says 41 for a company that might have 30 exposed people: it counts address entries, not people.

Reading the six blocks

The API returns the blocks in alphabetical order. Don’t read them that way.

BlockThe question it answersRead this first
Domain_SummaryHow many exposed address entries per verified domain?The one number. Your baseline.
Detailed_Breach_InfoWhat leaked, when, and how were passwords stored?password_risk, then xposed_data
Breaches_DetailsWhich address is in which breach?Every row. This is the work list.
Yearly_MetricsOld problem or live one?The last three years
Top10_BreachesWhat do I tell the boss?The top three names
Breach_SummaryHow many of my addresses in each breach?The biggest count

Domain_Summary first. One row per verified domain, and the number is address entries across every breach. Forty and four thousand need different scripts, and this number tells you which one you’re writing.

Then password_risk inside Detailed_Breach_Info. It has four values in the source: plaintext, easytocrack, hardtocrack and unknown. That’s how the breached site stored passwords. An address in a breach that leaked only emails is a phishing risk. The same address in a plaintext or easytocrack breach is a reset-today risk. That one field decides the order of work.

Breaches_Details is the flat list, one row per address per breach. It’s the block your script loops over, and it’s the one that grows, so it’s the one to diff.

Yearly_Metrics is the by-year count of address entries, every year from 2007 to now. The ones I’ve seen bulge around 2012 and again around 2017, then flatten. Old bulge, old passwords. Does anyone on the team still use one from back then? (Someone does.)

Top10_Breaches and Breach_Summary overlap. Top10 is the ranked cut, ready for a slide.

One thing about dates. breached_date comes back as an ISO 8601 string, 2018-12-01T00:00:00+00:00, not a number. Python’s datetime.fromisoformat reads it as-is. (The older sample on the docs page shows a different date style; the current API serialises ISO.)

Annotated JSON anatomy of the domain-breaches response: the six metrics blocks labelled with the question each answers and the one field to read first

Step four: the weekly script

Pull the report, keep last week’s copy, print only what’s new. Nothing new, no output. This is the script that replaces “someone should check the dashboard”.

import json, os, urllib.request

KEY = os.environ["XON_API_KEY"]
URL = "https://api.xposedornot.com/v1/domain-breaches/"
LAST = "domain-breaches-last.json"

req = urllib.request.Request(URL, method="POST",
      headers={"x-api-key": KEY, "Content-Length": "0"})
with urllib.request.urlopen(req, timeout=30) as r:
    now = json.load(r)["metrics"]

rows = {(d["email"], d["breach"]) for d in now["Breaches_Details"]}
seen = set()
if os.path.exists(LAST):
    seen = {tuple(x) for x in json.load(open(LAST))}

new = sorted(rows - seen)
for email, breach in new:
    info = now["Detailed_Breach_Info"].get(breach, {})
    print(f"NEW  {email}  in {breach}  ({info.get('breached_date','?')[:16]}, passwords: {info.get('password_risk','?')})")
if not new:
    print("nothing new since last run")

json.dump(sorted(rows), open(LAST, "w"))

Against the worked sample above, the first run prints three rows and the second prints one line:

NEW  a.rao@example.com  in Dubsmash  (2018-12-01T00:00, passwords: plaintext)
NEW  a.rao@example.com  in LinkedIn  (2012-05-01T00:00, passwords: easytocrack)
NEW  j.okoro@example.com  in Dropbox  (2012-07-01T00:00, passwords: hardtocrack)
nothing new since last run

Run it once a week from cron. Monday morning, before anyone’s coffee:

0 7 * * 1  cd /opt/xon && XON_API_KEY=... python3 check.py | mail -s "domain breach check" security@your-company.example

Swap mail for a Slack webhook, a ticket API, whatever your team already reads. The script doesn’t care where the text goes.

Several domains under one account? Still one call. Domain_Summary gets a row per domain and Breaches_Details carries the domain on every row, so the diff above already covers all of them.

Terminal card: the weekly diff script's output on two consecutive runs, the first listing new rows, the second reporting nothing new

Two other shapes get built on this, both about twenty lines. A monthly CSV: flatten Breaches_Details, join each row to its breached_date and password_risk, hand it to a manager who’ll sort it in a spreadsheet. A triage filter: only rows whose password_risk says plaintext or easytocrack go to whoever runs resets today. The rest wait for the monthly pass. That filter, in my experience, cuts the do-it-today list to a fifth.

The limits

2 requests a second. 25 an hour. 50 a day. Per IP address (the one Cloudflare sees), not per key, so every script on one machine shares the same budget.

That’s plenty for a weekly job and far too little for a dashboard that refreshes, which is deliberate. If you want it more often than hourly, you want the dashboard we already built.

Go over and you’ll get a 429. Wait out the window that tripped, an hour or a day, then carry on. Keep hammering through 429s and the source shows what happens next: the edge puts your IP behind a challenge for 24 hours, and a repeat inside a week is a 24-hour block. A weekly cron job will never get near that. A retry loop with no back-off will.

Handle the output with care

The response lists real addresses on your domain, and which breaches they sit in. That’s personal data about your colleagues. Three habits:

  • Keep domain-breaches-last.json somewhere only the security team can read. It’s a list of your staff’s exposed addresses.
  • Send the weekly output to a private channel or mailbox, never a company-wide one.
  • Don’t paste the raw JSON into a ticket that the whole helpdesk can see. Paste the row that matters.

When something new shows up

The script prints a row. Now what?

Look at password_risk for that breach first. plaintext or easytocrack: reset that account’s password today, and anywhere the person reused it. hardtocrack, unknown, or emails only: warn them that targeted phishing is likelier, and move on.

Then check whether the address still exists. Old breaches often name people who left years ago. Those rows are noise; a filter against your directory clears them.

The dashboard reading post covers the monthly version of this, panel by panel, and the org-alerts post covers the email alert that fires at the same time as the API row appears.

What this endpoint won’t do

It won’t tell you about domains you don’t own. That’s the point of verification. If you look after other people’s domains as a service, that’s a different, paid product, and not what this post is about.

It won’t return passwords. password_risk says how a site stored them. The passwords themselves aren’t in any response, on any endpoint.

And don’t confuse it with the public catalog filter. /v1/breaches?domain= finds breaches of a domain (the company that got breached). This endpoint finds addresses on your domain across every breach. Nearly everyone wants the second one. The API docs cover both, and the Swagger playground lets you try the public endpoints with no key at all.

Questions people ask before wiring this up

Is the domain breach API free? Yes. Verify a domain you own at xposedornot.com/domain, take the key from the dashboard, and the calls are free within the limits above. No card, no plan.

How fresh is the report? As fresh as the catalog. A breach appears in your report once it’s verified and indexed. It’s the same catalog the public breach directory reads from and the same one the alert emails are built from. The API isn’t ahead of the dashboard or behind it.

Can I test before verifying a domain? Not this endpoint. The public ones, yes: the Swagger playground runs the keyless checks in the browser, so you can see the response style before you set anything up.

Does the report include subdomains? I haven’t tested that with a clean example, so I won’t guess. Verify the exact domain your staff use in their addresses.

Does one key cover all my domains? Yes. The key belongs to your account, and the report covers every domain verified under it. One call, one row per domain in Domain_Summary.

What if the key leaks? Treat it like a password: it reads your staff’s exposure list. Replace it from the dashboard, then update the environment variable.

Start with the one number

Run the call once. Read Domain_Summary. Write the number down. That’s your baseline. Everything after that is a script that tells you when it moves.

Never checked your own inbox against the index? That’s the homepage, one field, nothing stored: xposedornot.com.

If you build something on this endpoint, open an issue in the Python SDK repo and say what. A star there helps the next person find it, and the issues are where the next feature comes from.

—

Get alerted when your email shows up in a new breach. Set up free breach alerts, it takes two minutes.


Check out some of our posts for you.