We're a two-person team building HeedPost, a feedback board for SaaS and apps. A few weeks ago we started doing cold outreach, and we hit a simple question:

Did anyone actually click the link in our email?

The usual answers didn't sit right with us:

  • Email open tracking is mostly noise. Apple Mail preloads images, so "opens" fire even when nobody reads anything. Tracking pixels can also push you towards spam.
  • Google Analytics (or a similar tool) would answer the question, but it means cookies, a consent banner, and a third party watching every visitor to our site. We'd rather not.

All we really needed was: "someone opened heedpost.com/?utm_campaign=acme, from roughly this country, on this kind of device, at this time." Nothing more.

Our site already runs behind Cloudflare, so a Worker in front of it was the obvious place to do this.

How it works

  1. A visitor opens https://heedpost.com/?utm_source=email&utm_campaign=acme.
  2. The Worker sees the utm_ parameters and, if the request looks like a real person loading a page, writes one row to a D1 (SQLite) database.
  3. It then passes the request through to the site unchanged. The visitor notices nothing.
  4. A small, password-protected stats page groups the rows by campaign.

No cookies, no client-side JavaScript, no third parties.

The Worker

const UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"]; const BOT_UA = /bot|crawl|spider|slurp|preview|scanner|headless|python|curl|wget|httpclient|java\/|go-http|facebookexternalhit|proofpoint|mimecast|barracuda|microsoft office|safelinks|google-?read|feedfetcher/i; export default { async fetch(req, env, ctx) { const url = new URL(req.url); const utm = Object.fromEntries(UTM_KEYS.map((k) => [k, url.searchParams.get(k) || ""])); const ua = req.headers.get("user-agent") || ""; const isPage = req.method === "GET" && (req.headers.get("accept") || "").includes("text/html"); if (isPage && (utm.utm_source || utm.utm_campaign) && !BOT_UA.test(ua)) { ctx.waitUntil(logVisit(req, env, url, utm, ua)); } return fetch(req); // pass through to the real site }, }; 

A few details that matter:

  • ctx.waitUntil lets the database write happen after the response starts, so the tracker adds no delay for the visitor.
  • isPage checks the Accept header for text/html, so images, scripts and API calls on the page don't get counted as extra visits.
  • The bot filter is the important part for cold email. Corporate mail security tools (Microsoft Safe Links, Proofpoint, Mimecast and friends) "click" every link to scan it. Without filtering them out, every email looks like it was clicked within seconds of sending.

Counting people without storing IPs

We still wanted to tell "one person clicked three times" apart from "three people clicked once". So instead of storing the IP address, we store a salted hash of IP + user agent:

async function sha256(s) { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } const ip = req.headers.get("cf-connecting-ip") || ""; const visitor = (await sha256(`${env.HASH_SALT}|${ip}|${ua}`)).slice(0, 16); 

HASH_SALT is a secret set with wrangler secret put, so the hash can't be reversed by guessing IPs. We also keep only the first 16 characters. The raw IP never touches the database.

Country comes from req.cf.country, which Cloudflare provides for free, and "device" is just a rough mobile/desktop guess from the user agent.

The database

One table in D1:

CREATE TABLE IF NOT EXISTS visits ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, source TEXT, medium TEXT, campaign TEXT, term TEXT, content TEXT, path TEXT, country TEXT, device TEXT, visitor TEXT, referrer TEXT ); CREATE INDEX IF NOT EXISTS idx_visits_campaign ON visits (campaign, source); 

And the stats query is plain SQL:

SELECT campaign, source, COUNT(DISTINCT visitor) AS people, COUNT(*) AS visits, MAX(ts) AS last FROM visits GROUP BY campaign, source ORDER BY last DESC; 

The Worker serves that as a small HTML table on a hidden path, protected by a secret key. If the key is wrong it returns a plain 404, so the page doesn't even admit it exists.

Wiring it up

wrangler.toml:

name = "utm-tracker" main = "src/index.js" compatibility_date = "2026-10-01" workers_dev = false routes = [ { pattern = "example.com/*", zone_name = "example.com" } ] [[d1_databases]] binding = "DB" database_name = "utm" database_id = "<from wrangler d1 create>" 

Deploy:

npx wrangler d1 create utm npx wrangler d1 execute utm --remote --file=schema.sql npx wrangler secret put STATS_KEY npx wrangler secret put HASH_SALT npx wrangler deploy 

Two gotchas we hit:

  • Your domain must be proxied through Cloudflare (orange cloud), or the route never runs.
  • The workers.dev URL won't show anything. The tracker only runs on the real domain's route. We spent a few confused minutes looking at the wrong URL.

What we use it for

Every outreach email gets its own campaign:

https://heedpost.com/?utm_source=email&utm_medium=outreach&utm_campaign=acme https://heedpost.com/?utm_source=linkedin&utm_medium=dm&utm_campaign=acme 

When acme shows up on the stats page, we know someone actually looked, and from which channel. That's enough to decide who to follow up with, without tracking anyone around the web.

Limits, honestly

  • It only counts visits that arrive with UTM tags. It's not a full analytics tool, and that's the point.
  • The bot filter catches most scanners, but not all. A "click" a few seconds after sending is still probably a robot.
  • The hashed fingerprint changes if someone switches networks or browsers, so "people" is an estimate.

For our use case (a few dozen hand-written emails a week) it's exactly enough, it costs nothing on Cloudflare's free tier, and our visitors don't get a cookie banner.

We're building HeedPost, feedback boards for SaaS and apps with one fixed price and no per-user billing. Yes, that link is tracked with the Worker above. 🙂