Blog
pytracking-go: a tiny, stateless open/click/unsubscribe tracker in Go
pytracking-go: a tiny, stateless open/click/unsubscribe tracker in Go🔗
I've been using pytracking patterns for years to instrument email opens, link clicks, and unsubscribes. This week I rewrote the core idea in Go — lightweight, dependency-minimal, and easy to drop behind a CDN. It's on my private server right now; I'll push it to GitHub shortly as pytracking-go.
What you get:
- Stateless tracking links for
open,click, andunsub - Optional AES-256-GCM encryption of tokens (no tampering, no PII leakage)
- Zero cookies and no server-side session state — the token carries everything
- 1×1 transparent PNG for opens, 302 redirect for clicks, and a clean HTML unsubscribe confirm page
- SQLite event logging (pure Go driver) + optional webhooks for your pipeline
- Tiny handlers you can wire to
net/httpin ~5 minutes
Why roll my own?🔗
I wanted something I could ship as a single static binary, scale horizontally without sticky state, and wire into Go services without juggling Python environments. I also wanted the token format to be explicit and portable, and the crypto to be boring (in the best way).
Design (in one picture)🔗
Token (JSON) → base64 (and optionally AES-GCM) → URL param t
{
"id": "b04a0e0f-23e8-4b93-9c2f-3e9e3b0b8a47", // uuid v4
"kind": "open | click | unsub",
"url": "https://example.com/landing", // clicks/unsub only
"metadata": { "user_id": 42, "campaign": "fall" },
"ts": 1730000000000000000 // unix nanos
}
- Stateless: The server doesn't need to look anything up to answer the request.
- Encrypted (recommended): If you provide a 32-byte key, tokens are sealed with AES-256-GCM (nonce || ciphertext). If you don't, the token is base64-encoded JSON (fine for local demos; don't do that in prod).
- Idempotent logging: Event
idis the token's UUID; duplicate inserts are ignored.
Minimal API🔗
Create a tracker, point it at your public endpoints, and mint URLs for your templates:
package main
import (
"net/url"
"os"
"time"
"pytracking-go/tracker"
)
func mustURL(s string) *url.URL {
u, _ := url.Parse(s); return u
}
func example() {
t, _ := tracker.New(
// 32-byte key as hex (64 chars). Use base64 or raw bytes if you prefer.
tracker.WithAESKeyHex(os.Getenv("PYTRACKING_KEY")),
tracker.WithBaseURLs(
mustURL("https://t.example.com/o"),
mustURL("https://t.example.com/c"),
),
tracker.WithUnsubURL(mustURL("https://t.example.com/u")),
// Optional: POST every event as JSON to your backend with a timeout
tracker.WithWebhook(mustURL("https://hooks.example.com/pytrack"), 2*time.Second),
)
// Attach these to your email
pixelURL, _ := t.OpenURL(map[string]any{"user_id": 42, "campaign": "fall"})
clickURL, _ := t.ClickURL("https://example.com/landing", map[string]any{"user_id": 42})
unsubURL, _ := t.UnsubURL("https://example.com/unsubbed", map[string]any{"user_id": 42})
_ = pixelURL; _ = clickURL; _ = unsubURL
}
Template usage:
<!-- open -->
<img src="{{ .PixelURL }}" width="1" height="1" alt="" style="display:block;">
<!-- click -->
<a href="{{ .ClickURL }}">Read the guide</a>
<!-- unsubscribe -->
<a href="{{ .UnsubURL }}">Unsubscribe</a>
The three handlers🔗
The demo wires up standard net/http routes:
GET /o?t=…→ returns a transparent 1×1 PNG and (async) logs the openGET /c?t=…→ 302 Redirect to the originalurland logs the clickGET /u?t=…→ logs the unsubscribe and returns a simple, styled "You've been unsubscribed" page
Under the hood they:
- Decode (and decrypt) the token.
- Persist an
eventsrow via a tiny SQLite store (pure Go:modernc.org/sqlite). - Optionally POST the token JSON to your webhook (with a per-request timeout). If your hook responds non-2xx, it's logged — retry/backoff is on my roadmap.
Example webhook payload:
{
"id": "…",
"kind": "click",
"url": "https://example.com/landing",
"metadata": {"user_id": 42, "campaign": "fall"},
"ts": 1730000000000000000
}
Security model🔗
- AES-256-GCM with a 12-byte nonce (RFC-recommended). Keys can be provided as raw bytes, hex, or base64.
- If you omit the key, tokens are readable and forgeable — great for demos, not for production.
- Webhook payloads are plain JSON right now. I'll likely add body signatures (HMAC) and retries with backoff as optional features.
Storage🔗
By default there's a tiny Store that creates an events table and inserts idempotently:
create table if not exists events (
id text primary key,
kind text not null,
url text,
metadata text,
remote_ip text,
ua text,
ts integer not null
)
It's intentionally boring so you can swap in Postgres/BigQuery/Kafka with minimal friction.
Demo + test spammer🔗
cmd/demospins up/o,/c,/uand writes to SQLite.- There's also a little slammer utility that fires lots of open/click/unsub triplets — handy to verify the whole chain (encryption → handler → store → webhook) and to eyeball latency.
Why this approach works well🔗
- CDN-friendly: the pixel and redirect endpoints are cache-safe while still logging server-side.
- No migrations / no lookups: the token has everything the server needs.
- Portable: you can mint links in one service and serve them from another.
- Small: standard library + pure-Go SQLite +
slog.
Roadmap🔗
- Optional HMAC signatures on webhook bodies
- Retry/backoff + DLQ for webhooks
- TTL/expiry on tokens
- A pluggable Store interface with a Postgres example
- Middleware snippets for chi, gin, fiber
- A tiny CLI for key generation and URL minting
If you're into email infrastructure, tracking, or just like small, sharp tools, this is for you. I'll publish the repo soon; until then, if you want to kick the tires or peek at the code, ping me.
AI-assisted writing
I draft and edit all articles myself, and I use AI as an assistant for outlining, phrasing, and cleanup. Curious how I use it—and where I draw the lines?