Notifio's website makes a lot of claims about other companies. Fifteen pages under /alerts describe how a given rental portal's own email alerts behave, and five pages under /compare quote competitors' prices. That is only publishable if every claim carries a citation, so the type behind those pages makes the citation mandatory, with this above it:
/** * A claim about a third party's product, with the page it came from. * * Anything we say about how another company's alerts behave carries one of * these. The citation is rendered on the page, which keeps us honest and gives * a reader a way to check a detail that may have changed since we wrote it. */ export type Source = { label: string; url: string }; Each alert page also prints one real search URL, so a reader can see the shape of the thing they are being asked to copy:
setup: { exampleUrl: "https://www.zoopla.co.uk/to-rent/property/manchester/?beds_min=2&price_max=1400&results_sort=newest_listings", exampleCaption: "A Manchester rental search, two beds minimum, £1,400 ceiling, newest first. ...", ... } Add it up and the catalogues hold 60 url: fields pointing off-site, 49 of them distinct: 15 example search URLs and 34 citations. Every one of those is a hardcoded string pointing at a website somebody else redesigns on their own schedule. I had never checked a single one, so I checked all of them.
curl -s -o /dev/null -L --max-time 20 \ -w "%{http_code} %{num_redirects} %{url_effective}\n" \ -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ..." "$url" The scoreboard, which is useless
200 403 401 400 404 405 examples 7 4 1 1 1 1 citations 23 9 1 1 0 0 30 of 49, which looks like a site rotting in public. It is not, and this is the part worth internalising before you wire a link checker into CI.
Thirteen of the nineteen non-200s are 403. Nine of those are citations: a help-centre article about email alerts, a membership page, a company's own news index. Those pages are not gone. They load perfectly in a browser. They answer 403 to a request from a command-line client in a datacentre, because that is what a lot of commercial sites do, and a 403 from a host that serves humans fine is not evidence about the URL at all. It is evidence about the client.
The rest of the failure column is the same story in other digits. Two 401s where a page requires a logged-in session. Two 400s, both Facebook, which answers 400 to an unauthenticated request for a Marketplace URL and then redirects to a login page. One 405, from a host that does not like the request shape. Not one of those six tells me a single word about whether the content I cited is still there.
So a status-code link checker, pointed at a site whose outbound links all go to commercial property portals, produces a nineteen-row alert list in which every row needs a human to open a browser. That is not a check. That is a chore generator, and the first time it fires everyone learns to ignore it.
Where the real decay was
Six of the 49 URLs redirected. That column is the one that mattered, and every single entry in it answered 200 at the end.
A cited pricing page that moved. The comparison page for RentSlam cites their pricing page. rentslam.com/en/pricing/ now 301s to rentslam.com/en/what-it-costs/. The citation still works, the reader still lands on prices, and the URL in our repository is stale. Nothing in a 200-means-pass checker will ever tell me that.
A citation that now resolves to a login form. A claim about Kamernet's paid tier cites kamernet.nl/en/premium-account-payment. That URL now takes three hops and ends up at an identity provider:
https://id.kamernet.nl/nl/login?authRequestID=...&prevUrl=...%2Fpremium-account-payment&target=tenant Status 200, zero evidentiary value. A citation whose job is to let a reader verify a price cannot be a login screen. This one is not a broken link, it is a broken argument, and it is the worst result in the audit precisely because it looks fine.
Two example URLs quietly rewritten by their owners. kamernet.nl/en/for-rent/rooms-amsterdam 301s to .../for-rent/properties-amsterdam, and spotahome.com/madrid/for-rent:studios 301s to spotahome.com/s/madrid/for-rent:studios. Both land on the right results. Both mean the URL printed on our page is the previous generation of their URL scheme, which is a slightly embarrassing thing to show a reader in a step that says "this is what a search URL looks like".
The one genuine 404, and it is a redirect's fault
The example URL on /alerts/wg-gesucht is:
https://www.wg-gesucht.de/wg-zimmer-in-Berlin.8.0.1.0.html?offer_filter=1&city_id=8&rent_type=2&rMax=700 That URL does not 404. It returns a 301:
first hop: 301 location: https://www.wg-gesucht.de/wg-zimmer-in-Berlin.8..1.0.html?... Look at the path. .8.0.1.0.html has become .8..1.0.html. Their redirect rule dropped a path segment, and the URL it points at is the one that 404s.
I enjoy this result more than I should, because it is a nice demonstration that "whose fault is it" and "whose problem is it" are different questions. The link we publish is well-formed and was correct when it was written. The 404 is produced by someone else's rewrite rule. And it is still entirely our problem, because a reader following the example on our page gets a 404 page, and the only thing they conclude is that we do not check our own site.
What I am replacing the checker with
The lesson is not "do not check links". It is that a status code is not the assertion you want. Three rules, from the results above:
- Do not follow redirects, and treat any 3xx as a finding.
curlwithout-L, assert on the first hop. A 301 on a URL you typed deliberately is the owner telling you their canonical form changed, and that is exactly the edit you want to make. Four of my six findings were in this bucket and all four were invisible with-Lon. - Assert on content, not status, for the citations that carry a claim. Each
Sourceexists to support one sentence. The useful check is whether the page still contains the thing being cited, which at minimum means storing a fingerprint, a title or an expected substring next to the URL, and at best means re-reading the page by hand on a schedule. The Kamernet login redirect passes every automated check I could write that only looks at the response code. - Make 401, 403 and 405 a third state, not a failure. Call it "unverifiable from here" and report it separately with a count. Nineteen failures that are mostly refusals will be ignored; "26 fine, 4 moved, 1 broken, 18 unverifiable from here" is a report somebody reads.
The quoted prices on the comparison pages have a related honesty problem, and it is handled by a different mechanism: the file holding them exports a single PRICES_CHECKED = "September 2026", which is rendered on the page next to every figure. That one does not pretend to be automated. It is a date a human has to go and earn again, and the sitemap even gives those five pages a higher changeFrequency than their edit rate deserves for exactly that reason.
The audit itself is reproducible in about a minute if you want to check my numbers. Every URL in it is printed on a live page: the examples are in the setup step on each of the fifteen pages under notifio.app/alerts, and the citations are in the sources list at the bottom of those and of the five pages under notifio.app/compare.