Skip to content

Scan tracking & analytics

Overview

Every scan of a dynamic QR code creates a scan record in your workspace. Capture happens entirely at the Cloudflare edge β€” no third-party trackers, no cookies, and the original IP never reaches your database.

Three ways to consume scan data:

Aggregation API

GET /v1/codes/:id/scans β€” time series + top countries / devices / OS for a single code.

Webhooks (real-time)

qr.scanned event per scan, signed with HMAC-SHA256. See Webhooks.

Dashboard

Visualisation in the qr3.app dashboard β€” no API call required.


What gets captured

Each scan inserts a row into the scans table:

FieldSourceExample
idServer-generated UUIDscn_d1f8…
code_idID of the scanned codeqr_a1b2c3d4
workspace_idWorkspace owning the codews_xxx
countryCloudflare cf.countryAT
regionCloudflare cf.regionVienna
cityCloudflare cf.cityVienna
device_typeUser-Agent parsingmobile, tablet, desktop
osUser-Agent parsingiOS, Android, macOS, Windows
browserUser-Agent parsingSafari, Chrome, Firefox
refererHTTP Referer headerhttps://example.com/landing
languageFirst language in Accept-Languageen
redirected_toActual redirect targethttps://example.com
ip_hashSHA-256(IP + daily salt) β€” non-reversible8f3a…
scanned_atISO-8601 timestamp2026-05-12T14:32:11.000Z

Geo lookup at the edge

All geo fields (country, region, city) come from Cloudflare’s cf object and are derived from Cloudflare’s built-in geo-IP database. No external geo-IP services are called β€” resolution happens inside the same worker that performs the redirect.

Three consequences:

  1. Low latency β€” no extra hop, no DNS lookup against a third party.
  2. Privacy β€” the IP never leaves Cloudflare and is never shared with a third party.
  3. Limited granularity β€” city is not always populated (VPNs, mobile carriers, small regions). Expect null and handle it in your dashboards.

Querying scan analytics

GET /v1/codes/:id/scans

Returns aggregated analytics for a single QR code over a configurable window.

Terminal window
curl "https://qr3.app/v1/codes/qr_a1b2c3d4/scans?days=30" \
-H "Authorization: Bearer qr3_sk_..."

Query parameters:

ParameterTypeDefaultDescription
daysinteger30Window length in days (1–365)

Response (HTTP 200):

{
"data": {
"code_id": "qr_a1b2c3d4",
"short_code": "r7f3Kx",
"total_scans": 1842,
"period_days": 30,
"period_scans": 367,
"scans_by_day": [
{ "date": "2026-04-13", "count": 12 },
{ "date": "2026-04-14", "count": 18 }
],
"top_countries": [
{ "value": "AT", "count": 142 },
{ "value": "DE", "count": 98 },
{ "value": "CH", "count": 41 }
],
"top_devices": [
{ "value": "mobile", "count": 281 },
{ "value": "desktop", "count": 72 },
{ "value": "tablet", "count": 14 }
],
"top_os": [
{ "value": "iOS", "count": 158 },
{ "value": "Android", "count": 123 },
{ "value": "macOS", "count": 48 }
]
},
"meta": { "request_id": "req_xyz123" }
}

Response fields:

FieldDescription
total_scansLifetime scan count for the code (independent of the days window)
period_scansScan count inside the selected window
scans_by_dayDay buckets, ascending by date; days with zero scans are omitted
top_countriesTop 8 countries in the window, descending
top_devicesTop 5 device types (mobile, tablet, desktop)
top_osTop 5 operating systems

Aggregating across multiple codes

Workspace-wide rollups (e.g. monthly scans across every code) are available via GET /v1/workspaces/:id as the scans_this_month field. For cross-code analyses we recommend the dashboard or a periodic export.


Real-time consumption via webhooks

To process scan events as they happen β€” for live dashboards, lead tracking, or CRM integration β€” subscribe to the qr.scanned event:

Terminal window
curl -X POST https://qr3.app/v1/webhooks \
-H "Authorization: Bearer qr3_sk_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/qr3",
"events": ["qr.scanned"],
"secret": "my-secret-key-min-16-chars"
}'

Payload:

{
"id": "evt_abc123xyz",
"type": "qr.scanned",
"created": "2026-05-12T14:32:11.000Z",
"data": {
"code_id": "qr_a1b2c3d4",
"short_code": "r7f3Kx",
"scan_id": "scn_d1f8...",
"country": "AT",
"device_type": "mobile",
"os": "iOS"
}
}

Full docs on signature verification, retry policy, and delivery logs in API β†’ Webhooks.


GDPR & privacy

Scan records contain no personal IP addresses. The original IP is replaced inside the edge worker by a SHA-256 hash with a daily-rotating salt. This means:

  • No re-identification, even if the algorithm is known
  • No cross-day correlation of the same visitor
  • The original IP never reaches the D1 database

Retention is plan-dependent:

PlanRetention
Free7 days
Pro90 days
Business / Agency1 year
EnterpriseCustom (SLA)

A daily cron job (purgeOldScans) removes older records automatically β€” the total_scans counter on the code object is preserved.


Best practices

  • Polling cadence: the aggregation API is cheap, but not designed for sub-minute polling. For live updates always prefer webhooks and use the API for backfills / dashboards.
  • Caching: the response is a time series β€” large windows (days=365) can run into multiple KB. Cache client-side with a short TTL (e.g. 60 s).
  • Top-N caps: top_countries, top_devices, top_os are capped server-side at 8 and 5 entries respectively. For deeper analysis, export the raw data.
  • Tolerate nulls: country, region, city, referer, language can be null. Treat them as β€œUnknown” in your reporting.