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
- A visitor opens
https://heedpost.com/?utm_source=email&utm_campaign=acme. - 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. - It then passes the request through to the site unchanged. The visitor notices nothing.
- 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.waitUntillets the database write happen after the response starts, so the tracker adds no delay for the visitor. -
isPagechecks theAcceptheader fortext/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.devURL 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. 🙂
Top comments (0)