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.

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:
| Code | Meaning | What to do |
|---|---|---|
| 200 | Report returned | Read metrics |
| 401 | Key present but wrong | Check the variable, then the dashboard |
| 422 | Key header missing | Add -H "x-api-key: ..." |
| 429 | Hourly or daily cap hit | Wait for that window to reset, then retry |
| 405 | You sent a GET | Make 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.
| Block | The question it answers | Read this first |
|---|---|---|
Domain_Summary | How many exposed address entries per verified domain? | The one number. Your baseline. |
Detailed_Breach_Info | What leaked, when, and how were passwords stored? | password_risk, then xposed_data |
Breaches_Details | Which address is in which breach? | Every row. This is the work list. |
Yearly_Metrics | Old problem or live one? | The last three years |
Top10_Breaches | What do I tell the boss? | The top three names |
Breach_Summary | How 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.)

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.

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.jsonsomewhere 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.
Sources and links, dated
- Endpoint reference, headers, error codes: xposedornot.com/api_doc, section 5, read 11 September 2026.
- Endpoint source (key-to-account resolution, the six blocks, counting by address entry, years from 2007, empty blocks on a clean domain, the four
password_riskvalues, the rate limit string): api/v1/domain_breaches.py in the open-source API repo, read 11 September 2026. - Breach entries used in the worked sample (LinkedIn 2012, Dropbox 2012, Dubsmash 2018, Canva 2019, AerServ 2014, with their record counts and
password_risk): api.xposedornot.com/v1/breaches, pulled 11 September 2026. The domain and address counts in the sample are invented. - Rate limits for /v1/domain-breaches (2 per second, 25 per hour, 50 per day, per IP address): the free-tier limits table on the same page, same date.
- Keyless-call response (HTTP 422, field required) and GET response (HTTP 405): executed 11 September 2026 against the live endpoint.
- Catalog count, 783 breaches and 11,621,497,431 records: api.xposedornot.com/v1/breaches, pulled 11 September 2026. Records, not people.
- Domain verification routes and the five mailbox addresses: xposedornot.com/domain, read 11 September 2026.
- Earlier posts: Monitor every domain you own for breaches, at zero cost (setup), Reading the CxO dashboard (the panels), DNS verification, HTML verification, Breach alerts for your whole org, free.
- Public breach directory: xposedornot.com/xposed. Interactive API docs: api.xposedornot.com/docs.
—
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.
- AntiPublic Combo List: 457M Emails Still Fuelling Attacks
- Ask Claude If You’ve Been Breached: A 5-Minute Setup
- Stop Changing Passwords Every 90 Days. Do These 3 Things Once.
- Gmail Data Breach? Why It’s Usually Not Gmail That Leaked
- How Do Data Breaches Happen? Third Parties and Salesforce Lead in 2026
- Email Breach History: Find and Delete the Accounts You Forgot





