Aggregation API
GET /v1/codes/:id/scans β time series + top countries / devices / OS for a single code.
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.
Each scan inserts a row into the scans table:
| Field | Source | Example |
|---|---|---|
id | Server-generated UUID | scn_d1f8β¦ |
code_id | ID of the scanned code | qr_a1b2c3d4 |
workspace_id | Workspace owning the code | ws_xxx |
country | Cloudflare cf.country | AT |
region | Cloudflare cf.region | Vienna |
city | Cloudflare cf.city | Vienna |
device_type | User-Agent parsing | mobile, tablet, desktop |
os | User-Agent parsing | iOS, Android, macOS, Windows |
browser | User-Agent parsing | Safari, Chrome, Firefox |
referer | HTTP Referer header | https://example.com/landing |
language | First language in Accept-Language | en |
redirected_to | Actual redirect target | https://example.com |
ip_hash | SHA-256(IP + daily salt) β non-reversible | 8f3aβ¦ |
scanned_at | ISO-8601 timestamp | 2026-05-12T14:32:11.000Z |
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:
city is not always populated (VPNs, mobile carriers, small regions). Expect null and handle it in your dashboards.GET /v1/codes/:id/scansReturns aggregated analytics for a single QR code over a configurable window.
curl "https://qr3.app/v1/codes/qr_a1b2c3d4/scans?days=30" \ -H "Authorization: Bearer qr3_sk_..."const analytics = await qr3.scans.get('qr_a1b2c3d4', { days: 30 });console.log(analytics.period_scans, analytics.top_countries);qr3 scans qr_a1b2c3d4 --days 30Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
days | integer | 30 | Window 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:
| Field | Description |
|---|---|
total_scans | Lifetime scan count for the code (independent of the days window) |
period_scans | Scan count inside the selected window |
scans_by_day | Day buckets, ascending by date; days with zero scans are omitted |
top_countries | Top 8 countries in the window, descending |
top_devices | Top 5 device types (mobile, tablet, desktop) |
top_os | Top 5 operating systems |
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.
To process scan events as they happen β for live dashboards, lead tracking, or CRM integration β subscribe to the qr.scanned event:
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.
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:
Retention is plan-dependent:
| Plan | Retention |
|---|---|
| Free | 7 days |
| Pro | 90 days |
| Business / Agency | 1 year |
| Enterprise | Custom (SLA) |
A daily cron job (purgeOldScans) removes older records automatically β the total_scans counter on the code object is preserved.
days=365) can run into multiple KB. Cache client-side with a short TTL (e.g. 60 s).top_countries, top_devices, top_os are capped server-side at 8 and 5 entries respectively. For deeper analysis, export the raw data.nulls: country, region, city, referer, language can be null. Treat them as βUnknownβ in your reporting.