# Domain Expiry Reminder

URL: /docs/experiments/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
Tags: network, monitoring, tls, rdap, email, cron, certificates
Bindings: D1, CRON, EMAIL
Markdown: /llms.mdx/experiments/domain-expiry-reminder/content.md



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 [#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](https://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 [#api-reference]

### POST /domains [#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.

```bash
curl -X POST "https://your-worker.workers.dev/domains" \
  -H "Content-Type: application/json" \
  -d '{"domain":"example.com","alertEmail":"ops@example.com"}'
```

```json
{
  "id": 1,
  "domain": "example.com",
  "alertEmail": "ops@example.com",
  "createdAt": "2026-10-01T09:00:00.000Z"
}
```

### GET /domains/:id [#get-domainsid]

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 [#delete-domainsid]

Stop watching a domain and delete its reminder history.

### GET /lookup [#get-lookup]

One-off check without saving.

**`domain`** `string` (required)

Bare hostname to check.

```bash
curl "https://your-worker.workers.dev/lookup?domain=example.com"
```

```json
{
  "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 [#error-codes]

* `400` - `INVALID_BODY`, `INVALID_DOMAIN`, `INVALID_EMAIL`, `INVALID_ID`
* `404` - `NOT_FOUND`
* `500` - `INTERNAL_ERROR`

## How reminders work [#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 [#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 [#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 [#use-in-your-project]

<UseInYourProject
  experiment="domain-expiry-reminder"
  bindings="[]"
  platform="[&#x22;Fetch API&#x22;, &#x22;AbortSignal.timeout&#x22;]"
  files="[
  &#x22;src/lib/rdap.ts&#x22;,
  &#x22;src/lib/crtsh.ts&#x22;,
  &#x22;src/lib/expiry.ts&#x22;,
  &#x22;src/lib/http.ts&#x22;,
  &#x22;src/lib/validate.ts&#x22;,
  &#x22;src/constants/defaults.ts&#x22;,
  &#x22;examples/usage.ts&#x22;,
]"
  sources="[{&#x22;path&#x22;:&#x22;src/lib/rdap.ts&#x22;,&#x22;title&#x22;:&#x22;rdap.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import {\n  BOOTSTRAP_CACHE_TTL_MS,\n  IANA_RDAP_BOOTSTRAP_URL,\n  RDAP_FALLBACK_BASE,\n  RDAP_TIMEOUT_MS,\n} from \&#x22;../constants/defaults\&#x22;;\nimport type { RdapBootstrap, RdapDomain } from \&#x22;../types/domain\&#x22;;\nimport { errorMessage, fetchJson } from \&#x22;./http\&#x22;;\n\ntype ParsedRegistration = { expiresAt: string; registrar?: string };\n\nlet bootstrapCache: { data: RdapBootstrap; fetchedAt: number } | null = null;\n\nexport function resetBootstrapCache(): void {\n  bootstrapCache = null;\n}\n\nexport function findRdapServer(bootstrap: RdapBootstrap, tld: string): string | null {\n  for (const [tlds, urls] of bootstrap.services ?? []) {\n    if (!tlds.includes(tld)) continue;\n    const url = urls.find((u) => u.startsWith(\&#x22;https://\&#x22;)) ?? urls[0];\n    if (!url) return null;\n    return url.endsWith(\&#x22;/\&#x22;) ? url : `${url}/`;\n  }\n  return null;\n}\n\n/** In-memory per isolate; isolates are short-lived so this caps IANA fetches without needing KV. */\nasync function getBootstrap(now: number): Promise<RdapBootstrap> {\n  if (bootstrapCache && now - bootstrapCache.fetchedAt < BOOTSTRAP_CACHE_TTL_MS) {\n    return bootstrapCache.data;\n  }\n  const data = await fetchJson<RdapBootstrap>(IANA_RDAP_BOOTSTRAP_URL, RDAP_TIMEOUT_MS);\n  bootstrapCache = { data, fetchedAt: now };\n  return data;\n}\n\nexport function parseRdapDomain(body: RdapDomain): ParsedRegistration | null {\n  const expiration = body.events?.find((e) => e.eventAction === \&#x22;expiration\&#x22;)?.eventDate;\n  if (!expiration || Number.isNaN(Date.parse(expiration))) return null;\n\n  const registrarEntity = body.entities?.find((e) => e.roles?.includes(\&#x22;registrar\&#x22;));\n  const fn = registrarEntity?.vcardArray?.[1]?.find((prop) => prop[0] === \&#x22;fn\&#x22;)?.[3];\n\n  const result: ParsedRegistration = { expiresAt: new Date(expiration).toISOString() };\n  if (typeof fn === \&#x22;string\&#x22; && fn.trim()) result.registrar = fn.trim();\n  return result;\n}\n\nasync function queryRdap(base: string, domain: string): Promise<ParsedRegistration> {\n  const body = await fetchJson<RdapDomain>(\n    `${base}domain/${domain}`,\n    RDAP_TIMEOUT_MS,\n    \&#x22;application/rdap+json\&#x22;\n  );\n  const parsed = parseRdapDomain(body);\n  if (!parsed) throw new Error(\&#x22;RDAP response has no expiration event\&#x22;);\n  return parsed;\n}\n\nexport async function lookupRegistration(\n  domain: string,\n  now = Date.now()\n): Promise<{ data: ParsedRegistration | null; error?: string }> {\n  const tld = domain.split(\&#x22;.\&#x22;).pop() ?? \&#x22;\&#x22;;\n  let primaryError: string | undefined;\n\n  try {\n    const server = findRdapServer(await getBootstrap(now), tld);\n    if (server) return { data: await queryRdap(server, domain) };\n    primaryError = `No RDAP server listed for .${tld}`;\n  } catch (error) {\n    primaryError = errorMessage(error, \&#x22;RDAP lookup failed\&#x22;);\n  }\n\n  try {\n    return { data: await queryRdap(RDAP_FALLBACK_BASE, domain) };\n  } catch (error) {\n    return {\n      data: null,\n      error: `${primaryError}; fallback: ${errorMessage(error, \&#x22;rdap.org lookup failed\&#x22;)}`,\n    };\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/crtsh.ts&#x22;,&#x22;title&#x22;:&#x22;crtsh.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { CRTSH_BASE, CRTSH_TIMEOUT_MS } from \&#x22;../constants/defaults\&#x22;;\nimport type { CrtShEntry } from \&#x22;../types/domain\&#x22;;\nimport { errorMessage, fetchJson } from \&#x22;./http\&#x22;;\n\ntype ParsedCertificate = { expiresAt: string; issuer: string };\n\n/** crt.sh timestamps are UTC without a zone suffix. */\nfunction parseCrtShDate(value: string | undefined): number {\n  if (!value) return Number.NaN;\n  return Date.parse(/[zZ]|[+-]\\d{2}:?\\d{2}$/.test(value) ? value : `${value}Z`);\n}\n\nexport function formatIssuer(issuerName: string | undefined): string {\n  if (!issuerName) return \&#x22;unknown\&#x22;;\n  const org = /(?:^|,\\s*)O=(\&#x22;?)([^,\&#x22;]+)\\1/.exec(issuerName)?.[2];\n  const cn = /(?:^|,\\s*)CN=(\&#x22;?)([^,\&#x22;]+)\\1/.exec(issuerName)?.[2];\n  return [org, cn].filter(Boolean).join(\&#x22; - \&#x22;) || issuerName;\n}\n\nexport function pickLatestCertificate(\n  entries: CrtShEntry[],\n  domain: string,\n  now = Date.now()\n): ParsedCertificate | null {\n  let best: { notAfter: number; issuer: string } | null = null;\n\n  for (const entry of entries) {\n    const names = (entry.name_value ?? \&#x22;\&#x22;)\n      .split(\&#x22;\\n\&#x22;)\n      .map((n) => n.trim().toLowerCase())\n      .concat((entry.common_name ?? \&#x22;\&#x22;).toLowerCase());\n    if (!names.includes(domain)) continue;\n\n    const notAfter = parseCrtShDate(entry.not_after);\n    const notBefore = parseCrtShDate(entry.not_before);\n    if (Number.isNaN(notAfter) || notAfter <= now) continue;\n    if (!Number.isNaN(notBefore) && notBefore > now) continue;\n\n    if (!best || notAfter > best.notAfter) {\n      best = { notAfter, issuer: formatIssuer(entry.issuer_name) };\n    }\n  }\n\n  return best ? { expiresAt: new Date(best.notAfter).toISOString(), issuer: best.issuer } : null;\n}\n\nexport async function lookupCertificate(\n  domain: string,\n  now = Date.now()\n): Promise<{ data: ParsedCertificate | null; error?: string }> {\n  const url = `${CRTSH_BASE}?q=${encodeURIComponent(domain)}&output=json&exclude=expired`;\n  try {\n    const entries = await fetchJson<CrtShEntry[]>(url, CRTSH_TIMEOUT_MS);\n    const data = pickLatestCertificate(Array.isArray(entries) ? entries : [], domain, now);\n    return data ? { data } : { data: null, error: \&#x22;No valid certificate found in CT logs\&#x22; };\n  } catch (error) {\n    return { data: null, error: `crt.sh: ${errorMessage(error, \&#x22;lookup failed\&#x22;)}` };\n  }\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/expiry.ts&#x22;,&#x22;title&#x22;:&#x22;expiry.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { REMINDER_THRESHOLDS_DAYS } from \&#x22;../constants/defaults\&#x22;;\nimport type { DomainStatus, ExpiryKind } from \&#x22;../types/domain\&#x22;;\nimport { lookupCertificate } from \&#x22;./crtsh\&#x22;;\nimport { lookupRegistration } from \&#x22;./rdap\&#x22;;\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nexport function daysUntil(expiresAt: string, now = Date.now()): number {\n  return Math.floor((Date.parse(expiresAt) - now) / DAY_MS);\n}\n\n/** Smallest threshold that `daysLeft` has crossed, or null when still outside every window. */\nexport function dueThreshold(\n  daysLeft: number,\n  thresholds: readonly number[] = REMINDER_THRESHOLDS_DAYS\n): number | null {\n  const crossed = thresholds.filter((t) => daysLeft <= t);\n  return crossed.length ? Math.min(...crossed) : null;\n}\n\nexport async function checkDomain(domain: string, now = Date.now()): Promise<DomainStatus> {\n  const [registration, certificate] = await Promise.all([\n    lookupRegistration(domain, now),\n    lookupCertificate(domain, now),\n  ]);\n\n  const status: DomainStatus = {\n    domain,\n    checkedAt: new Date(now).toISOString(),\n    registration: registration.data\n      ? { ...registration.data, daysLeft: daysUntil(registration.data.expiresAt, now) }\n      : null,\n    certificate: certificate.data\n      ? { ...certificate.data, daysLeft: daysUntil(certificate.data.expiresAt, now) }\n      : null,\n  };\n\n  const errors: Partial<Record<ExpiryKind, string>> = {};\n  if (registration.error) errors.registration = registration.error;\n  if (certificate.error) errors.certificate = certificate.error;\n  if (Object.keys(errors).length) status.errors = errors;\n\n  return status;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/http.ts&#x22;,&#x22;title&#x22;:&#x22;http.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { USER_AGENT } from \&#x22;../constants/defaults\&#x22;;\n\nexport async function fetchJson<T>(\n  url: string,\n  timeoutMs: number,\n  accept = \&#x22;application/json\&#x22;\n): Promise<T> {\n  const res = await fetch(url, {\n    headers: { Accept: accept, \&#x22;User-Agent\&#x22;: USER_AGENT },\n    signal: AbortSignal.timeout(timeoutMs),\n  });\n  if (!res.ok) {\n    throw new Error(`HTTP ${res.status} from ${new URL(url).hostname}`);\n  }\n  return (await res.json()) as T;\n}\n\nexport function errorMessage(error: unknown, fallback: string): string {\n  if (error instanceof Error) {\n    return error.name === \&#x22;TimeoutError\&#x22; ? \&#x22;Request timed out\&#x22; : error.message;\n  }\n  return fallback;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/lib/validate.ts&#x22;,&#x22;title&#x22;:&#x22;validate.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;import { MAX_DOMAIN_LENGTH } from \&#x22;../constants/defaults\&#x22;;\n\nconst LABEL_PATTERN = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;\nconst TLD_PATTERN = /^(?:[a-z]{2,63}|xn--[a-z0-9-]{1,59})$/;\nconst EMAIL_PATTERN = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/** Accepts a bare hostname (no scheme, path, or port); returns it lowercased without a trailing dot. */\nexport function validateDomain(input: unknown): string | null {\n  if (typeof input !== \&#x22;string\&#x22;) return null;\n  const domain = input.trim().toLowerCase().replace(/\\.$/, \&#x22;\&#x22;);\n  if (!domain || domain.length > MAX_DOMAIN_LENGTH) return null;\n\n  const labels = domain.split(\&#x22;.\&#x22;);\n  if (labels.length < 2) return null;\n  if (!labels.every((label) => LABEL_PATTERN.test(label))) return null;\n  if (!TLD_PATTERN.test(labels[labels.length - 1])) return null;\n  return domain;\n}\n\nexport function validateEmail(input: unknown): string | null {\n  if (typeof input !== \&#x22;string\&#x22;) return null;\n  const trimmed = input.trim();\n  if (!EMAIL_PATTERN.test(trimmed)) return null;\n  return trimmed.toLowerCase();\n}\n\nexport function parseId(input: string | undefined): number | null {\n  if (!input || !/^\\d+$/.test(input)) return null;\n  const id = Number.parseInt(input, 10);\n  return Number.isSafeInteger(id) && id > 0 ? id : null;\n}&#x22;},{&#x22;path&#x22;:&#x22;src/constants/defaults.ts&#x22;,&#x22;title&#x22;:&#x22;defaults.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;export const IANA_RDAP_BOOTSTRAP_URL = \&#x22;https://data.iana.org/rdap/dns.json\&#x22;;\nexport const RDAP_FALLBACK_BASE = \&#x22;https://rdap.org/\&#x22;;\nexport const CRTSH_BASE = \&#x22;https://crt.sh/\&#x22;;\n\nexport const BOOTSTRAP_CACHE_TTL_MS = 24 * 60 * 60 * 1000;\nexport const RDAP_TIMEOUT_MS = 8_000;\nexport const CRTSH_TIMEOUT_MS = 10_000;\n\n/** Descending; a reminder fires for the smallest threshold that days-left has crossed. */\nexport const REMINDER_THRESHOLDS_DAYS = [30, 7, 1] as const;\n\nexport const DEFAULT_ALERT_FROM = \&#x22;reminders@example.com\&#x22;;\nexport const USER_AGENT = \&#x22;Cloudflare-Experiments-DomainExpiry/1.0\&#x22;;\nexport const MAX_DOMAIN_LENGTH = 253;&#x22;},{&#x22;path&#x22;:&#x22;examples/usage.ts&#x22;,&#x22;title&#x22;:&#x22;usage.ts&#x22;,&#x22;lang&#x22;:&#x22;ts&#x22;,&#x22;code&#x22;:&#x22;/**\n * Minimal paste example for docs (\&#x22;Use in your project\&#x22;).\n * Not part of the Worker runtime — copy into your own project.\n *\n * One-off expiry check: GET /?domain=example.com\n */\nimport { checkDomain } from \&#x22;../src/lib/expiry\&#x22;;\nimport { validateDomain } from \&#x22;../src/lib/validate\&#x22;;\n\nexport default {\n  async fetch(request: Request): Promise<Response> {\n    const domain = validateDomain(new URL(request.url).searchParams.get(\&#x22;domain\&#x22;));\n    if (!domain) {\n      return Response.json(\n        { error: \&#x22;Missing or invalid domain\&#x22;, code: \&#x22;INVALID_DOMAIN\&#x22; },\n        { status: 400 }\n      );\n    }\n    return Response.json(await checkDomain(domain));\n  },\n};&#x22;}]"
/>

## Deployment [#deployment]

<Steps>
  <Step>
    ### Click the deploy button [#click-the-deploy-button]

    [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shrinathsnayak/cloudflare-experiments/tree/main/apps/experiments/domain-expiry-reminder)
  </Step>

  <Step>
    ### Apply migrations and configure email [#apply-migrations-and-configure-email]

    ```bash
    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.
  </Step>

  <Step>
    ### Test your deployment [#test-your-deployment]

    ```bash
    curl "https://your-worker.workers.dev/lookup?domain=example.com"
    ```
  </Step>
</Steps>

## Local Development [#local-development]

```bash
cd apps/experiments/domain-expiry-reminder
npm install
npx wrangler d1 migrations apply domain-expiry-reminder-db --local
npx wrangler dev --test-scheduled
```

```bash
curl "http://localhost:8787/lookup?domain=example.com"
curl "http://localhost:8787/__scheduled?cron=0+9+*+*+*"
```

## Configuration [#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 [#cloudflare-features-used]

* **[D1](https://developers.cloudflare.com/d1/)** - Watched domains and sent-reminder log
* **[Cron Triggers](https://developers.cloudflare.com/workers/configuration/cron-triggers/)** - Daily expiry checks
* **[Email Sending](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/)** - `send_email` binding for reminders
