This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Network & Monitoring

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 to rdap.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 reminders table, so a renewal resets them)
  • Partial results: a failing source returns null with a reason in errors instead of failing the request
  • One-off GET /lookup without 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_ID
  • 404 - NOT_FOUND
  • 500 - 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_email with 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, not www.example.co.uk) for registration data
  • Some ccTLDs have no RDAP service, so registration is null
  • 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.

DependenciesNoneBindingsNonePlatformFetch API, AbortSignal.timeout
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

Click the deploy button

Deploy to Cloudflare Workers

Apply migrations and configure email

npx wrangler d1 migrations apply domain-expiry-reminder-db --remote
npx wrangler email sending enable yourdomain.com

Set 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-scheduled
curl "http://localhost:8787/lookup?domain=example.com"
curl "http://localhost:8787/__scheduled?cron=0+9+*+*+*"

Configuration

wrangler.json declares:

  • D1 DB (migrations/0000_init.sql creates domains and reminders)
  • send_email binding EMAIL
  • Cron 0 9 * * *
  • Var ALERT_FROM_EMAIL

Cloudflare Features Used

On this page