Matt Novak logo Matt Novak

Blog

pytracking-go: a tiny, stateless open/click/unsubscribe tracker in Go

2025-09-30 • 4 min
go email tracking open click unsubscribe

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, and unsub
  • 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/http in ~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 id is 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 open
  • GET /c?t=…302 Redirect to the original url and logs the click
  • GET /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 events row 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/demo spins up /o, /c, /u and 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?