• Aug 3, 2026
  • 7 min read

Contact Forms with Cloudflare Workers KV and Self-Hosted ntfy Alerts

My sites are small and every enquiry matters. I wanted three things: the message must never be lost, I want to know within seconds, and I don’t want a form SaaS script on every page. The result is a Cloudflare Worker that writes each submission to Workers KV and then pings my phone through a self-hosted ntfy server.

It replaced a third-party form/survey tool on one site and a form-to-email service on another. The whole flow:

visitor submits form
  → Worker POST /api/forms/submit
      1. validate (honeypot, kind, email, consent)
      2. write the lead to KV            ← the record of truth
      3. answer 202 to the visitor
      4. in the background: POST an alert to ntfy
  → ntfy (homelab) → phone notification

Why KV for a lead inbox

Workers KV
Servers to runNone
Cost at this scaleFree tier (1,000 writes per day)
Where the data livesMy own Cloudflare account
Reading itOne CLI command (wrangler kv key list)
Lock-inLow: JSON values, exportable
What it lacksNo UI, no statuses, no notifications

I compared it with a hosted form tool, PocketBase, Supabase and a FastAPI + SQLite service on the homelab. For “a few messages a week, one person” KV won on zero operations. The missing pieces, notifications and an inbox view, are exactly what the rest of this post adds.

One rule from that comparison stuck: never let the browser post straight to the homelab. A home power or ISP outage would silently lose leads. Keep a durable capture at the edge, and let everything else be best effort.

Store first, alert second

The order of operations in the handler is the whole design:

const key = leadKey(lead, crypto.randomUUID());
try {
  await env.LEADS.put(key, JSON.stringify(lead), {
    ...(ttl ? { expirationTtl: ttl } : {}),
    metadata: { site: lead.site, profile: lead.profile, kind: lead.kind, email: lead.email, project: lead.project ?? "" },
  });
} catch (error) {
  console.error("kv put failed", error);
  return json({ error: "storage_failed" }, 502, cors);
}

// Alert only after the lead is stored; never block or fail the response on it.
ctx.waitUntil(notify(env, lead).catch((error) => console.error("ntfy", error)));
return json({ ok: true }, 202, cors);
  • If KV fails, the visitor gets an error (502) and the form shows my email address as a fallback. A failed save is never reported as success.
  • If ntfy is slow or down, the visitor doesn’t notice. waitUntil lets the Worker finish the alert after the response is sent, and the .catch keeps a failed alert from becoming an unhandled rejection.

Both cases have a test: KV failure returns 502 and sends no alert; an unreachable ntfy still returns 202 with the lead stored. Self-hosted alerts mean no alerts while the homelab is down, which is acceptable precisely because the lead is already safe.

The key and the metadata

Each lead is one JSON value under a descriptive key:

<site>:<kind>:<ISO timestamp>:<uuid>
fossengineer.com:enquiry:2026-10-04T19:29:51.123Z:4f1c…

Sorting by key sorts by site, then kind, then time. Listing a prefix like fossengineer.com:newsletter: gives one site’s subscribers, and the UUID keeps two submissions in the same millisecond apart.

KV metadata is the underrated part. list returns each key’s metadata (up to 1 KB) without reading the values, so I store the fields I need for a table view there: site, profile, kind, email, project. A listing is one API call no matter how many leads exist.

When I moved from a per-site function to a central Worker, the key format gained the site prefix. Old keys (<kind>:<iso>:<uuid>) stayed, and every reader understands both: if the first segment contains a dot, it’s a site.

Retention is set at write time as a TTL (expirationTtl), so enquiries disappear after the period my privacy policy promises without a cleanup job.

A small reader instead of a dashboard

KV has no admin UI, and I don’t need one. A Python script wraps wrangler kv key list and prints a table through make targets:

make leads              # time, site, kind, status, email, project
make lead KEY=<key>     # one lead in full
make lead-delete KEY=<key>
make leads-test         # POST a test enquiry to the live endpoint
make leads-clean-tests  # delete test leads (@example.com)

make leads-test plus make leads-clean-tests is my end-to-end check after every change: real form endpoint, real KV, real phone notification, then clean up.

The alert: enough to triage, nothing personal

Notifications travel through more systems than the lead itself: the ntfy server, the phone’s push service, the lock screen. So the alert carries the site, the kind, the project, the email domain and the country. Never the name, the full address or the message.

export function alertFor(lead: Lead) {
  const domain = lead.email.split("@")[1] ?? "unknown";
  const headers = {
    // Header values must stay ASCII (an em dash triggers a Workers warning).
    Title: `New ${lead.kind} - ${lead.site}`,
    Tags: lead.kind === "enquiry" ? "incoming_envelope" : lead.kind === "ebook" ? "books" : "newspaper",
    Priority: lead.kind === "enquiry" ? "high" : "default",
  };
  const parts = [lead.project, `from @${domain}`, lead.country].filter(Boolean);
  return { headers, body: `${parts.join(" · ")}\nDetails: make leads` };
}

On the phone that reads:

New enquiry - multibodysystemsdynamics.com
Solver validation · from @acme.com · PL
Page: /contact/
Details: make leads

@acme.com versus @gmail.com is all I need to decide whether to look now. The body also names the page that converted (path only, no query string, since UTMs can carry tags), so I can see which posts actually bring subscribers.

Enquiries get high priority; ebook and newsletter sign-ups use the default.

The ASCII header gotcha

My first title was New enquiry — multibodysystemsdynamics.com with an em dash. The alert arrived, but the Workers log showed a warning that the Title header contains a non-ASCII character. HTTP header values are expected to be ASCII; how a non-ASCII value is handled depends on every hop in between. The fix was a plain hyphen in headers, while the body (where the · lives) can be any Unicode. There is now a test asserting every header value matches /^[\x20-\x7e]*$/.

Publishing to ntfy securely

The Worker publishes with a bearer token over HTTPS:

const response = await fetch(env.NTFY_URL, { method: "POST", headers, body });
// NTFY_URL = https://ntfy.example.com/<topic>, NTFY_TOKEN sent as Authorization: Bearer

Both values are Worker secrets, set with wrangler secret put so they never appear in git or a Makefile. If NTFY_URL is unset, notify() does nothing, which keeps local development quiet.

The token belongs to a machine user that can only publish to one topic: it can’t read alerts or touch anything else. The server itself denies everything by default, and I verified that from outside (anonymous publish, anonymous read and random topics all return 403). That setup, behind a Cloudflare Tunnel, deserves its own post.

Gotchas I hit

SymptomCauseFix
Secrets set, no alerts (Pages Function era)Pages applies secrets only to new deploymentsRedeploy after pages secret put
Workers log: header contains non-ASCIIEm dash in TitleASCII in headers, Unicode in the body
Laptop ntfy unreachable from CloudflareCloudflare can’t reach a laptopPublic hostname + tunnel on an always-on box
wrangler pages deployment tail errors in scriptsIt can’t pick a deployment non-interactivelyPass the deployment ID

What I’d do again

  • Write to durable storage before anything else, and never report a failed save as success.
  • Put side effects in waitUntil so a slow homelab can’t slow the visitor.
  • Use KV metadata for listing and a descriptive key that sorts by time.
  • Keep personal data out of notifications. The email domain is enough to triage.
  • Least privilege for the publishing token, and prove deny-all from outside.
  • ASCII in HTTP headers, tested.

This started as a function inside one site. Once three sites needed the same thing, I moved it into one Worker that every site reaches on its own domain. Next: one forms Worker for every site, with same-origin routes over Cloudflare Pages.