DNS takes minutes or hours, and a verified domain is re-checked every day. Alerts are how someone finds out what happened without keeping the page open.
Six moments, and nothing else
An is an interruption, so the rule for sending one is strict: the claim's status has to change, and the change has to be one the owner didn't just make themselves. That leaves 6 kinds: verified, at_risk, recovered, lapsed, expired, transferred.
15 legal moves, 7 of them send an alertRuns the real alertForTransition
Email subject and notification title
example.com is verified
Body
We found your record everywhere we looked. Keep it in place: we check it every day.
View domain
The moves come from TRANSITIONS in packages/core/src/claim.ts. The alert, or its absence, comes from alertForTransition in packages/core/src/alerts.ts, called with an example.com claim.
Predict it first
A pending claim fails 40 checks in a row because the record has a typo. How many alerts go out?
From a state change to a notification
What happens after a claim movesStep through it
claim-service
Postgres
core
alerts.ts
Resend, push
Step 1 of 8
The change commits first
The status change and its audit row are written in one transaction. Whatever happens next, the claim is already in its new state and the page already says so.
apps/web/lib/server/claim-service.ts
One helper, so no path forgets
// apps/web/lib/server/claim-service.tsasyncfunction move(claim, event, actor, expectedVersion?) {
const moved = await applyTransition(db, { claimId: claim.id, event, actor, ... })
if (moved.ok) await alerts.statusChanged(claim.status, moved.value)
return moved
}
The service used to call applyTransition directly in six places. Now all six go through move, which applies the event and then reports it. A new code path that uses it gets the alert without anyone remembering to add it.
What changes the claim
Goes through
Reported to alerts?
A check passes, fails or finds the record gone
move
Yes
The worker expires a code, or ends a grace period
move
Yes
The owner stops a claim
move
Yes. core answers null, so nothing is sent
A check passes and another account owns the name
transferOwnership, then statusChanged twice by hand
Yes. Once for the new owner, once for the old one
The owner gets a new code
rotateChallenge
No. It can only land on pending, which never alerts
Predict it first
A domain moves from one account to another. Who is told?
Two channels, both optional
Email
mailer.ts, emails.tsx
Sent through Resend, with templates written in React Email. On when RESEND_API_KEY is set. Works on every device.
Web Push
push.ts, public/sw.js
A notification from the browser, even with the page closed. On when both VAPID keys are set. Installable, with push covers the browser's side.
With a channel's keys missing, the container holds null for it and everything that sends checks for that. GET /api/notifications reports what's available in capabilities, and the alerts card on the claim page only offers what's on. With neither, the card doesn't render. Nothing pretends to send.
An address has to be confirmed first
Anyone can type any address. So an address gets exactly one message, the confirmation link, until whoever reads that inbox clicks it. This is .
From typed to confirmedStep through it
You
Prufy
Postgres
The inbox
Step 1 of 7
You type an address
It needs a session. If the server has no RESEND_API_KEY the answer is a 503 that says email isn't set up. Changes are limited to 5 an hour per account, because each one sends a message.
apps/web/lib/server/notification-service.ts
Predict it first
Someone enters a stranger's address in the alerts card. What does the stranger receive?
The link opens /confirm-email, and components/email-action.tsx shows one button. Pressing it POSTs the token. It used to be a GET that confirmed by being opened, and mail scanners open every link in a message, so one could have said yes for someone. If the confirmation email can't be sent at all, setEmail clears the address it just saved.
Leaving is one click, the way Have I Been Pwned does it
Every alert carries a way out, twice. A link in the footer opens /unsubscribe, which is the same button page. And two headers, List-Unsubscribe and List-Unsubscribe-Post, let Gmail, Apple Mail and Yahoo show their own Unsubscribe button next to the sender. That button POSTs straight to /api/notifications/email/unsubscribe?token=… (RFC 8058).
The endpoint a browser hands us is untrusted
To send a push, the server makes a POST to a URL the browser gave it when subscribing. Without a check, anyone could subscribe with a URL of their choosing and make the server call it. That's . isPushEndpoint in notification-service.ts closes it: the URL has to be https, and its host has to be one of four push services or a subdomain of one.
Which endpoints the server will POST toClick one
The address cloud servers use for their own credentials. This is what the attack usually aims at.
These eight are the cases in apps/web/lib/server/alerts-and-shares.test.ts. A refused endpoint is a 400, and nothing is stored.
Domain Connect has the same shape of problem with a different fix, in Trust and safety: there the host can be anything public, so the check is on what it resolves to. Here the set of legitimate hosts is tiny, so a list is simpler and stricter.
Alerts are best effort
An alert is sent once, after the change commits. If Resend or a push service is down at that moment, that alert is lost. The page is still right, and the next change sends its own.
Why a failed alert isn't retriedCompare the two
09:00
The record goes missing. The at-risk alert fails to send.
Logged, and dropped
09:20
The record is back. The recovered alert sends.
Delivered
Later
The last thing the owner heard is that the record is back.
True
An outbox with retries is the durable way to send. It was left out because ordering has to be solved with it, and that's its own piece of work.
Predict it first
Resend returns an error while a domain is being verified. What status does the claim end in?
Details that are easy to miss
A test alert exists.POST /api/notifications/test sends one everywhere the account asked for, limited to 10 an hour, so someone can see it arrive before relying on it.
A push endpoint belongs to whoever subscribed last. It's unique, and saving it again moves the row. One account can't unsubscribe another's browser, because the delete is scoped to the account.
Every email has a plain-text twin with the same words, so nothing depends on a client rendering HTML.
The alert links to the claim, built from PUBLIC_BASE_URL. The session cookie is what opens it, so it works in the browser that started the claim.
The decision is in docs/decisions/0009-alerts-and-setup-links.md.