QR Link Tracking

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.

📱
QR Scan
user's phone
Edge Function
greenvilletriumph.club
🎫
Ticket Page
vivenu.com + UTMs

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.

  1. Phone camera reads the QR code, which encodes a URL like greenvilletriumph.club/go/stadium
  2. Request hits Cloudflare's edge at the nearest data center. The request.cf object is automatically populated with the scanner's city, state, country, lat/lon, zip code, timezone, and ISP.
  3. Function looks up the slug in the D1 destinations table. If the slug exists and is active, it gets the destination URL. If not, it falls back to a default Vivenu seller page.
  4. 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.
  5. 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.
  6. 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
);
FieldSourceWhat 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

Why Cloudflare instead of a custom server?
The domain is already on Cloudflare. Pages Functions run at the edge with zero cold start, D1 is free at this volume, and request.cf provides geo data automatically. There's no server to provision, patch, or scale. The entire backend is 3 JavaScript files.
Why redirect URLs instead of linking directly to ticket pages?
Three reasons: (1) destinations can be changed without reprinting QR codes, (2) every scan is logged with full geo data, (3) UTM parameters are appended automatically so ticket purchases are attributable back to specific QR placements.
Why D1 (SQL) instead of KV (key-value)?
Scan data is relational. The dashboard needs 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.
Why waitUntil() instead of await for the INSERT?
The user scanning a QR code should never wait for a database write. 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.
Why is the dashboard on GitHub Pages instead of Cloudflare?
Separation of concerns. The .club domain handles only redirects and API — keeping it lean minimizes risk of breaking QR redirects. The dashboard is a static HTML file that fetches data cross-origin, so it can live anywhere. GitHub Pages gives free hosting with git-push deploys.

Technology

Cloudflare Pages
Hosting + edge functions
Cloudflare D1
SQLite database at the edge
Chart.js 4.4.7
Charts + data visualization
Leaflet 1.9.4
Interactive map + GeoJSON
GitHub Pages
Dashboard hosting
Vanilla JS
Zero build step, no frameworks