A client's domain got hijacked on a Friday night. Nameservers changed, MX records pointed somewhere new, and nobody on our side noticed until email stopped working - which, for a business, is a special kind of silence. No bounce, no error. Messages just went into a void that used to be a mail server.
By the time we got it sorted out, the honest question wasn't "how do we fix this one domain." It was "how many other domains are we one silent DNS change away from losing, and how would we even know?"
That question turned into Expirity - a monitoring service that watches domain expiry, DNS records, nameservers, and SSL certs, and tells you the moment something changes instead of the moment you notice. This post is about the part I actually enjoyed building: the checker engine that makes that possible, and a couple of the deceptively tricky problems that came with it.
The shape of the problem
"Monitor a domain" sounds like a cron job that hits WHOIS once a day. In practice it's three separate, slow, unreliable data sources that need to agree with each other:
- Registration data - who owns it, when it expires, who the registrar is. RDAP is the modern, structured successor to WHOIS, but coverage isn't universal yet - plenty of TLDs still only speak WHOIS's decades-old semi-structured text format.
- DNS records - A, AAAA, MX, CNAME, TXT, NS, CAA, SOA, plus the derived stuff like SPF/DMARC (which just live inside TXT records but deserve their own change-detection logic because they matter differently) and DNSSEC status.
- TLS certificate state - issuer, expiry, chain validity, SANs - which is really a separate check against port 443, unrelated to DNS or registration entirely.
None of these three sources update on the same schedule, none of them are guaranteed to respond, and only one of them (DNS) is fast enough to check every domain every hour without annoying anyone.
RDAP first, WHOIS as the fallback
The naive version of this is "just parse WHOIS." The problem is that WHOIS was never a real protocol - it's closer to a gentleman's agreement that everyone would return vaguely similar plain text, and every registry formats that text slightly differently. Parsing it reliably across 1,500+ TLDs means maintaining a small library of regexes that breaks every time a registry redesigns their output.
RDAP (Registration Data Access Protocol) fixes this - it's JSON, it's structured, it's an actual IETF standard. The catch is that RDAP server coverage, while growing, still isn't universal. So the checker does the obvious thing:
func lookupExpiry(domain string) (*ExpiryData, error) {
if data, err := rdapLookup(domain); err == nil {
return data, nil
}
// No RDAP server for this TLD, or it didn't respond - fall back
return whoisLookup(domain)
}
Simple in outline, less simple in practice - WHOIS fallback needs its own timeout handling, its own rate-limit backoff per registry, and its own "I got a response but I'm not confident I parsed it correctly" signal so a bad parse doesn't get treated as "domain has no expiry date," which is a very different (and alarming) thing to tell a customer.
The change-detection problem nobody warns you about
The actual hard part isn't checking a domain once. It's checking it every day (or every hour, depending on plan) and correctly identifying what changed since last time - without false-positiving on noise or, worse, silently missing a real change.
The approach: every check produces a snapshot (expiry date, registrar, nameservers, SSL fingerprint, DNS record set). Each new snapshot gets diffed against the last one, and every difference becomes an explicit, typed event - nameserver_changed, ssl_issuer_changed, dns_record_added, and so on - rather than just overwriting the old value and hoping someone happens to look.
type ChangeEvent struct {
DomainID string
OccurredAt time.Time
EventType string // "nameserver_changed", "dns_record_added", ...
Field string
OldValue string
NewValue string
}
This sounds obvious in retrospect, but it's the entire point: an "expiry date" field that just gets silently updated in place tells you nothing. A nameserver_changed event with the old and new values, timestamped, alertable, and queryable six months later - that's the thing that would have caught the Friday-night hijack before it became a support call.
The first-check trap
Here's the bug I didn't see coming. The very first time you check a brand new domain, there's no previous snapshot to diff against. Naive code will happily treat "domain has 6 DNS records" as "6 records were just added" and fire six change events and six alerts, for a domain someone added ten seconds ago on purpose.
The fix is a deliberate sentinel: lastCheckedAt == nil means "this is the baseline, not a change," so the first check populates the snapshot silently and only starts diffing from the second check onward. It's a two-line guard clause, but it's the difference between "useful alerts" and "customer immediately mutes all notifications because day one was a spam storm."
if domain.LastCheckedAt == nil {
// First check: record the baseline, emit nothing.
// Everything from here on is a genuine diff.
return persistBaseline(domain, result)
}
return diffAndEmitEvents(domain.LastSnapshot, result)
Why two separate services, not one
Expirity is two codebases talking to one MongoDB database: a PHP console (auth, dashboard, billing, all the human-facing stuff) and a Go service that does nothing but check domains and write results. The PHP side is explicitly forbidden from making live WHOIS/SSL/DNS calls - it only ever reads what Go already wrote, plus one narrow POST /check endpoint on loopback for "check this domain right now" button clicks.
The reasoning: a web request thread has no business blocking on a WHOIS server in Vanuatu that might take 12 seconds to respond or might not respond at all. Keeping the slow, unreliable, external-network-dependent work in a separate process with its own worker pool, timeouts, and retry logic means a flaky registrar server degrades that one check, not the dashboard someone's currently looking at.
What I'd tell someone building the same thing
- Treat "no response" and "no data" as different failure modes. A WHOIS server timing out is not the same fact as a domain having no expiry date, and conflating them will eventually tell a customer something false with total confidence.
- Diff explicitly, don't overwrite silently. If your monitoring tool's core value is "tell me when something changes," the change itself needs to be a first-class, stored, queryable thing — not an inference someone has to make by comparing two timestamps in a UI.
- Guard your baseline case. Anything that diffs against history will misfire gloriously on the very first run if you don't special-case it.
If any of this sounds like a problem you've also hit — WHOIS parsing hell, alert-storm-on-day-one, or the general fun of "monitor something you don't control the schedule of" — I'd genuinely like to compare notes in the comments.
And if you're the kind of person who's ever found out a domain expired from a client instead of a monitor: Expirity does exactly what's described above, free for up to 50 domains, no credit card required.