Every scan tells a story.
Here's how we capture it.
A zero-infrastructure tracking system that turns printed QR codes into rich analytics — city, zip code, device, and timestamp — without any client-side scripts or cookies.
Three layers, two domains
The system splits redirect logic, data storage, and visualization across Cloudflare's edge network and GitHub Pages.
Redirect Layer
A Cloudflare Pages Function at /go/{slug} looks up the destination URL, appends UTM parameters for attribution, logs the scan to D1, and returns a 302 redirect. The entire round-trip happens at the edge — no origin server.
API Layer
Two serverless endpoints: /api/scans returns aggregated analytics (totals, daily breakdown, geo, zip codes) and /api/destinations provides CRUD for managing links. Mutations require an admin key.
Database
Cloudflare D1 (SQLite at the edge) stores two tables: destinations maps slugs to URLs, and scans records one row per scan with 12 data fields. SQL enables aggregation queries that KV storage can't handle.
Dashboard
A static HTML page on GitHub Pages fetches live data cross-origin from the .club API. Chart.js renders charts, Leaflet renders the map with GeoJSON zip code boundaries. Auto-refreshes every 60 seconds.
What happens when someone scans a QR code
From phone camera to ticket page in under 100ms. Here's every step.
- Phone camera reads the QR code, which encodes a URL like
greenvilletriumph.club/go/stadium - Request hits Cloudflare's edge at the nearest data center. The
request.cfobject is automatically populated with the scanner's city, state, country, lat/lon, zip code, timezone, and ISP. - Function looks up the slug in the D1
destinationstable. If the slug exists and is active, it gets the destination URL. If not, it falls back to a default Vivenu seller page. - UTM parameters are appended to the destination URL:
utm_source=qr,utm_medium=print,utm_content={slug}. This makes every QR scan attributable in Vivenu and HubSpot. - Scan is logged non-blocking via
waitUntil(). The database INSERT happens in the background — the user never waits for it. If the write fails, the redirect still succeeds. - 302 redirect fires. The user's phone opens the ticket page with UTM tags intact. Total time from scan to redirect: typically <50ms.
What's captured on every scan
12 fields are logged per scan. No cookies, no JavaScript on the user's device, no tracking pixels. All data comes from Cloudflare's edge network and HTTP headers.
-- One row per scan INSERT INTO scans ( slug, ts, city, region, country, lat, lon, ua, referer, timezone, postal_code, as_org ) VALUES ( 'stadium', -- which QR code '2026-03-22T14:32:01Z', -- when 'Greenville', -- where (city) 'South Carolina', -- where (state) 'US', -- where (country) 34.8526, -82.3940, -- coordinates 'Mozilla/5.0 ...', -- device info null, -- referrer (usually null for QR) 'America/New_York', -- timezone '29601', -- zip code 'Charter Comms' -- ISP / network );
| Field | Source | What It Tells You |
|---|---|---|
| slug | URL path | Which QR code / table topper was scanned |
| ts | server clock | When the scan happened (UTC ISO 8601) |
| city | CF edge | Scanner's city (IP geolocation) |
| region | CF edge | State or province |
| country | CF edge | Two-letter country code |
| lat / lon | CF edge | Approximate coordinates for map plotting |
| ua | HTTP header | Device and browser (iPhone/Android, Chrome/Safari) |
| referer | HTTP header | Where the click came from (usually null for QR scans) |
| timezone | CF edge | IANA timezone of the scanner |
| postal_code | CF edge | Zip code — enables neighborhood-level analysis |
| as_org | CF edge | ISP or network operator (e.g., Verizon, Charter, Spectrum) |
Why it's built this way
request.cf provides geo data automatically. There's no server to provision, patch, or scale. The entire backend is 3 JavaScript files.GROUP BY slug, date range filters, geographic aggregation, and zip code breakdowns. SQL handles these natively. With KV, you'd have to read all records into memory and aggregate in JavaScript — slow and expensive at scale.waitUntil() instead of await for the INSERT?waitUntil() lets the 302 redirect return immediately while the INSERT continues in the background. If D1 is slow or the write fails, the redirect still works. Analytics are best-effort; redirects are critical path.