Notifio is a desktop app that watches rental search pages, and most of its website is pages about other people's websites. /alerts/rightmove explains what Rightmove's own property alerts do. /compare/rentbird puts our one-off price next to a competitor's subscription. Those pages make factual claims about companies that did not ask to be written about, and that have every right to change their product the week after I publish.
So the content types make a citation mandatory next to the parts that can go stale:
// server/src/lib/alerts/types.ts /** * 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; }; There are three near-identical versions of that type, one per content family: Source for the site pages, CompareSource for the comparison pages, ArticleSource for the guides. I had never counted what they add up to.
The census
Every citation in the repository lives in a data file, so this is a grep rather than a crawl:
grep -rho 'url: "http[^"]*"' src/lib/alerts/sites/*.ts \ src/lib/compare/targets.ts src/lib/articles/*.ts | wc -l - 45 citation entries
- 34 distinct URLs, so 11 entries are a URL that is already cited somewhere else
- 26 distinct hosts
By content family: 25 on the fifteen /alerts pages, 6 on the five /compare pages, 14 across the eight /guides and /for articles.
Per page, the distribution is flatter than I expected. Every one of the fifteen site pages carries at least one. The most cited page is a guide, how fast do rental listings go, with four. The only pages with none are two guides, which is the gap I wrote about in every long-form page has a required sources array, and two of them are empty. Still two, still the same two, and both are pages of our own advice rather than claims about anybody else, which is the reason I have not fixed it and not a good enough one.
Rendered, those 45 entries become most of the outbound linking on the site. Across all 38 live pages there are 51 distinct external links on 28 pages, and the only ones that are not citations are four sibling apps on /about and two subprocessor privacy policies in /privacy. No ads, no widgets, no embeds. If a Notifio page links off-site, it is because a sentence on it needs backing up.
43 of 45 point at the company the page is about
This is the part of the census I liked. Group the 45 by whether the host belongs to the subject of the page:
| Kind | Count |
|---|---|
| The company the page is about | 43 |
| An independent authority | 2 |
The two exceptions are both on /guides/rental-scams-netherlands: the Dutch national police and the national fraud helpline. Everywhere else, the only source we use for a claim about a portal is that portal.
That is a policy, not an accident, and it comes out of a rule I have written about before: never put a latency figure on a page that we cannot attribute. It is easy to write "their email alerts take hours" because it matches what users tell us, and it is unprovable and it is the one sentence that would discredit a page full of otherwise checkable detail. What you can do instead is describe the chain structurally. A portal alert is published, indexed, picked up by a scheduled notification job, handed to a bulk email provider, queued, delivered, and polled by a mail client. Every hop is somebody's cron job running at a volume measured in hundreds of thousands of messages a day. You do not need a number to explain why a loop on the user's own machine beats it; you need the shape, and the shape is something the portals describe themselves.
Which is why the two most-cited URLs in the whole repository are these:
- SpareRoom's own explainer of their Early Bird tier, cited on three pages
- A Funda engineering blog post titled "how we enhanced the saved search feature with OpenSearch", cited on three pages
The second one is a company's engineering team explaining their saved-search infrastructure in public, in their own words, with no incentive to undersell it. For the argument our pages need to make, there is no better source in the world than that, and we did not have to characterise it: we quote it and link it.
The broken one
Then I checked the hosts against the entries they sit in. Each comparison target declares the competitor's domain as a field:
{ slug: "rentola", name: "Rentola", domain: "rentola.com", // ... sources: [ { label: "Rentola – how it works", url: "https://rentola.com/how-it-works" }, { label: "Rentola – terms and conditions", url: "https://rentola.co.com/en/terms-and-conditions" }, ], } Look at the second URL. rentola.co.com, not rentola.com. One of the 26 hosts in our citation list is a host that no page on our site names as anybody's domain.
Now the part that makes this worth a post. Here is what the three candidate URLs actually do today:
| URL | Status |
|---|---|
https://rentola.co.com/en/terms-and-conditions | 200, 737 KB of HTML |
https://rentola.com/en/terms-and-conditions | 404 |
https://rentola.com/terms-and-conditions | 200 |
The citation on /compare/rentola is alive. It would pass any link checker I have ever written, because the only thing those check is whether a request comes back without an error. The URL that looks correct is the one that 404s, because the path is wrong too: the real page has no /en/ prefix.
So a reader who clicks "Rentola, terms and conditions" under a price we quoted leaves our site for a host that is not the host we told them Rentola uses, and arrives at a page that answers 200. Nothing in our build, our tests or our monitoring has an opinion about that, and nothing would have until somebody read the string.
The check that was missing
A status check asks "is this URL alive". The question a citation needs answered is "does this URL belong to who I said it does". For the 43 self-referential citations, that is one assertion, because the entry already declares the domain:
// the shape of the test, in the suite that already guards metadata length for (const target of COMPARE_TARGETS) { for (const source of target.sources) { const host = new URL(source.url).hostname; assert.ok( host === target.domain || host.endsWith(`.${target.domain}`), `${target.slug}: citation host ${host} is not ${target.domain}` ); } } Rightmove's three citations come from faq.rightmove.co.uk and customerfaq.rightmove.co.uk, so subdomains have to pass. The two authority links on the scams guide need a deliberate allowlist, which is the right amount of friction: adding a source that is not the subject of the page should take an edit in two files and a moment's thought about whether the claim needs an independent source.
This runs offline, in the same suite as the metadata length limits, and it never makes a network request. It cannot tell me a citation has gone stale, which is the failure mode a human has to catch. It can tell me a citation points at the wrong company, which is the failure mode a human will not catch, because a working link looks like a working citation.
The fix for Rentola is one line. The reason I am writing 1,200 words about one line is that this site has been through a link audit that found nothing here, and it found nothing because of the definition I had chosen to measure.
The pages are at /compare and /alerts, and every citation on them is rendered where you can click it. The app itself is on /download.
