Domain Expiry Reminder
Track domain RDAP and TLS certificate expiry in D1 with daily cron emails at 30/7/1 days - domain and cert monitoring
Never let a domain or TLS certificate expire. Registration expiry comes from RDAP, certificate expiry from Certificate Transparency logs, watched domains live in D1, and a daily Cron Trigger emails reminders through the send_email binding.
Features
- Registration expiry via RDAP: IANA bootstrap (
data.iana.org/rdap/dns.json, cached in memory for 24h) → registry server, falling back tordap.org - TLS certificate expiry via crt.sh CT search: latest non-expired certificate whose names include the exact hostname
- Reminders at 30, 7, and 1 days left, each sent once per expiry date (deduped in a
reminderstable, so a renewal resets them) - Partial results: a failing source returns
nullwith a reason inerrorsinstead of failing the request - One-off
GET /lookupwithout saving anything
API Reference
POST /domains
Watch a domain.
domain string (required)
Bare hostname such as example.com (no scheme, path, or port).
alertEmail string (required)
Where reminders are sent.
curl -X POST "https://your-worker.workers.dev/domains" \
-H "Content-Type: application/json" \
-d '{"domain":"example.com","alertEmail":"ops@example.com"}'{
"id": 1,
"domain": "example.com",
"alertEmail": "ops@example.com",
"createdAt": "2026-10-01T09:00:00.000Z"
}GET /domains/:id
Runs a live check for a watched domain, stores the expiry dates on the row, and returns the record plus status (same shape as /lookup).
DELETE /domains/:id
Stop watching a domain and delete its reminder history.
GET /lookup
One-off check without saving.
domain string (required)
Bare hostname to check.
curl "https://your-worker.workers.dev/lookup?domain=example.com"{
"domain": "example.com",
"checkedAt": "2026-10-01T09:00:00.000Z",
"registration": {
"expiresAt": "2027-08-13T04:00:00.000Z",
"daysLeft": 315,
"registrar": "RESERVED-Internet Assigned Numbers Authority"
},
"certificate": null,
"errors": {
"certificate": "crt.sh: Request timed out"
}
}Error Codes
400-INVALID_BODY,INVALID_DOMAIN,INVALID_EMAIL,INVALID_ID404-NOT_FOUND500-INTERNAL_ERROR
How reminders work
The cron (0 9 * * *) loads every watched domain, checks both sources, and for each expiry finds the smallest threshold that days-left has crossed (daysLeft <= 30 → 30, <= 7 → 7, <= 1 → 1). If no row exists in reminders for (domain, kind, threshold, expiresAt), it sends the email and records it. A domain added with 5 days left gets the 7-day reminder only, then the 1-day reminder later. When the expiry date changes after a renewal, the thresholds apply again.
Use Cases
- Side-project domains spread across several registrars
- Catch certificates that silently stopped auto-renewing
- Learn D1 + Cron Triggers +
send_emailwith a dedupe table - Ad-hoc expiry checks from scripts via
GET /lookup
Limitations
- RDAP queries the hostname as given; pass the registrable domain (
example.co.uk, notwww.example.co.uk) for registration data - Some ccTLDs have no RDAP service, so
registrationisnull - crt.sh is slow and frequently returns 5xx; requests time out after 10 seconds and the certificate comes back
null - CT logs show issued certificates, not the one your server is actually serving
- Domains are checked one after another in a single cron run, so very large lists can hit Worker time limits
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/domain-expiry-reminder.
import { BOOTSTRAP_CACHE_TTL_MS, IANA_RDAP_BOOTSTRAP_URL, RDAP_FALLBACK_BASE, RDAP_TIMEOUT_MS,} from "../constants/defaults";import type { RdapBootstrap, RdapDomain } from "../types/domain";import { errorMessage, fetchJson } from "./http";type ParsedRegistration = { expiresAt: string; registrar?: string };let bootstrapCache: { data: RdapBootstrap; fetchedAt: number } | null = null;export function resetBootstrapCache(): void { bootstrapCache = null;}export function findRdapServer(bootstrap: RdapBootstrap, tld: string): string | null { for (const [tlds, urls] of bootstrap.services ?? []) { if (!tlds.includes(tld)) continue; const url = urls.find((u) => u.startsWith("https://")) ?? urls[0]; if (!url) return null; return url.endsWith("/") ? url : `${url}/`; } return null;}/** In-memory per isolate; isolates are short-lived so this caps IANA fetches without needing KV. */async function getBootstrap(now: number): Promise<RdapBootstrap> { if (bootstrapCache && now - bootstrapCache.fetchedAt < BOOTSTRAP_CACHE_TTL_MS) { return bootstrapCache.data; } const data = await fetchJson<RdapBootstrap>(IANA_RDAP_BOOTSTRAP_URL, RDAP_TIMEOUT_MS); bootstrapCache = { data, fetchedAt: now }; return data;}export function parseRdapDomain(body: RdapDomain): ParsedRegistration | null { const expiration = body.events?.find((e) => e.eventAction === "expiration")?.eventDate; if (!expiration || Number.isNaN(Date.parse(expiration))) return null; const registrarEntity = body.entities?.find((e) => e.roles?.includes("registrar")); const fn = registrarEntity?.vcardArray?.[1]?.find((prop) => prop[0] === "fn")?.[3]; const result: ParsedRegistration = { expiresAt: new Date(expiration).toISOString() }; if (typeof fn === "string" && fn.trim()) result.registrar = fn.trim(); return result;}async function queryRdap(base: string, domain: string): Promise<ParsedRegistration> { const body = await fetchJson<RdapDomain>( `${base}domain/${domain}`, RDAP_TIMEOUT_MS, "application/rdap+json" ); const parsed = parseRdapDomain(body); if (!parsed) throw new Error("RDAP response has no expiration event"); return parsed;}export async function lookupRegistration( domain: string, now = Date.now()): Promise<{ data: ParsedRegistration | null; error?: string }> { const tld = domain.split(".").pop() ?? ""; let primaryError: string | undefined; try { const server = findRdapServer(await getBootstrap(now), tld); if (server) return { data: await queryRdap(server, domain) }; primaryError = `No RDAP server listed for .${tld}`; } catch (error) { primaryError = errorMessage(error, "RDAP lookup failed"); } try { return { data: await queryRdap(RDAP_FALLBACK_BASE, domain) }; } catch (error) { return { data: null, error: `${primaryError}; fallback: ${errorMessage(error, "rdap.org lookup failed")}`, }; }}Deployment
Apply migrations and configure email
npx wrangler d1 migrations apply domain-expiry-reminder-db --remote
npx wrangler email sending enable yourdomain.comSet ALERT_FROM_EMAIL in wrangler.json to an address on the onboarded domain.
Test your deployment
curl "https://your-worker.workers.dev/lookup?domain=example.com"Local Development
cd apps/experiments/domain-expiry-reminder
npm install
npx wrangler d1 migrations apply domain-expiry-reminder-db --local
npx wrangler dev --test-scheduledcurl "http://localhost:8787/lookup?domain=example.com"
curl "http://localhost:8787/__scheduled?cron=0+9+*+*+*"Configuration
wrangler.json declares:
- D1
DB(migrations/0000_init.sqlcreatesdomainsandreminders) send_emailbindingEMAIL- Cron
0 9 * * * - Var
ALERT_FROM_EMAIL
Cloudflare Features Used
- D1 - Watched domains and sent-reminder log
- Cron Triggers - Daily expiry checks
- Email Sending -
send_emailbinding for reminders