Developers
Pull QR scan data with the OpenQR analytics API
By Sam Moreton · updated 28 June 2026
GET /v1/dynamic/{id}/scans: every field it returns, what's coarse and what's exact, and worked code for the three things people actually build with it — a dashboard, a CSV/Sheets export, and a scheduled Slack summary.11 min read · Updated 28 June 2026
If you've not minted a key yet, start with getting started with the OpenQR API. Every call here is Authorization: Bearer oqr_… against the base URL https://openqr.uk. For the conceptual side — why only dynamic codes can be tracked at all — read how to track QR code scans. This guide assumes you already have a dynamic code and want its data in code.
Only dynamic codes have scans
A static code points straight at its destination with no server in between, so there's nothing to count. The analytics endpoint only exists for dynamic codes created via POST /v1/dynamic. If you need numbers, you need a dynamic code — see static vs dynamic QR codes for the trade-off.
The endpoint and its one parameter
GET /v1/dynamic/{id}/scans?days=. The single query parameter days ranges 1–365 (default 30) and bounds the daily series and the breakdowns — but not the lifetime totals, which always count every scan ever. Pass the code's id (the one returned at creation), not its slug.
curl "https://openqr.uk/v1/dynamic/b1c2…/scans?days=30" \
-H "Authorization: Bearer oqr_live_yourkey"The full response, field by field
The body has two siblings: a compact scans summary (the four numbers you'd show in a header) and a richer analytics object (everything you'd chart).
{
"id": "b1c2…",
"slug": "spring-sale",
"short_url": "https://oqr.to/spring-sale",
"destination": "https://example.com/spring/live",
"scans": {
"total": 1840,
"last7": 263,
"topCountry": "GB",
"topDevice": "mobile"
},
"analytics": {
"days_window": 30,
"total": 1840,
"window_total": 612,
"daily": [ { "day": "2026-05-30", "n": 0 }, { "day": "2026-05-31", "n": 14 }, … ],
"by_country": [ { "value": "GB", "n": 421 }, { "value": "IE", "n": 88 }, … ],
"by_device": [ { "value": "mobile", "n": 540 }, { "value": "desktop", "n": 72 } ],
"by_referrer": [ { "value": "Direct", "n": 580 }, { "value": "t.co", "n": 21 } ]
}
}| Field | Meaning |
|---|---|
| scans.total | Lifetime scans — ignores the days window. |
| scans.last7 | Scans in the trailing 7 days (fixed, regardless of days). |
| scans.topCountry / topDevice | The single leading country and device class, or null if no scans. |
| analytics.days_window | Echoes the days you requested (clamped to 1–365). |
| analytics.total | Lifetime scans — identical to scans.total. |
| analytics.window_total | Scans inside the days window only. |
| analytics.daily | One { day, n } bucket per day in the window, oldest → newest, zero-filled. |
| analytics.by_country / by_device / by_referrer | Top entries (up to 8 each) as { value, n }, within the window. |
What a scan record actually contains
Each scan stores a timestamp plus a coarse country, device class (mobile/desktop/tablet) and referrer host, all derived from Cloudflare request headers at redirect time. No IP addresses, no fingerprints, no per-person identifiers. A missing referrer shows as "Direct"; an unknown country or device shows as "Unknown". It's enough to see where and on what, never who.
Two consequences worth designing around. First, the daily array is zero-filled — every day in the window is present even with no scans, so you can plot it straight onto a time axis without gap-filling. Second, breakdowns are capped at the top 8 per dimension; the long tail is not returned, so don't expect the country list to sum to window_total.
Worked example 1: a tiny dashboard summary
The most common job: fetch a code's stats and render a header — lifetime total, window total, busiest day, top country and referrer. This is the shape every dashboard widget starts from.
const KEY = process.env.OPENQR_KEY;
const id = "b1c2…";
const res = await fetch(
`https://openqr.uk/v1/dynamic/${id}/scans?days=30`,
{ headers: { Authorization: `Bearer ${KEY}` } }
);
const { scans, analytics } = await res.json();
const busiest = analytics.daily.reduce(
(a, b) => (b.n > a.n ? b : a),
{ day: "—", n: 0 }
);
const top = (arr) => (arr[0] ? `${arr[0].value} (${arr[0].n})` : "none");
console.log(`Lifetime: ${scans.total} · 30d: ${analytics.window_total}`);
console.log(`Busiest day: ${busiest.day} (${busiest.n})`);
console.log(`Top country: ${top(analytics.by_country)}`);
console.log(`Top referrer: ${top(analytics.by_referrer)}`);Worked example 2: export the daily series to CSV
The zero-filled daily array maps one-to-one onto CSV rows or a Google Sheet. Request the longest window you need (up to 365 days) and write it out:
import { writeFileSync } from "node:fs";
const id = "b1c2…";
const res = await fetch(
`https://openqr.uk/v1/dynamic/${id}/scans?days=365`,
{ headers: { Authorization: `Bearer ${process.env.OPENQR_KEY}` } }
);
const { analytics } = await res.json();
const csv = ["day,scans", ...analytics.daily.map((d) => `${d.day},${d.n}`)].join("\n");
writeFileSync("scans.csv", csv);
console.log(`Wrote ${analytics.daily.length} rows.`);Aggregating across many codes
There's no single fleet-wide analytics endpoint — scans are per-code. To build a campaign rollup, GET /v1/dynamic to list your codes, then call /scans for each id and sum the window_totals (or merge their daily arrays by day). For dozens of codes, fetch them concurrently and respect a sensible polling interval.
Worked example 3: a scheduled Slack summary
Polling on a cron and posting to Slack is the classic "how's the campaign doing?" automation. Run this daily (a cron job, a GitHub Action, a Cloudflare scheduled worker) and it posts yesterday's numbers to an incoming webhook:
const id = "b1c2…";
const res = await fetch(
`https://openqr.uk/v1/dynamic/${id}/scans?days=7`,
{ headers: { Authorization: `Bearer ${process.env.OPENQR_KEY}` } }
);
const { slug, scans, analytics } = await res.json();
const yesterday = analytics.daily.at(-1); // newest bucket
const top = (arr) => (arr[0] ? arr[0].value : "—");
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text:
`*oqr.to/${slug}* — ${yesterday.n} scans on ${yesterday.day}\n` +
`7-day total: ${analytics.window_total} · lifetime: ${scans.total}\n` +
`Top country: ${top(analytics.by_country)} · top referrer: ${top(analytics.by_referrer)}`,
}),
});Prefer no code? The same poll-and-post pattern drops straight into a no-code automation tool — see QR codes in n8n, QR codes in Make or QR codes in Zapier for HTTP-module versions. To let an AI assistant answer "how many scans did the spring code get?" in plain English, the same data is a tool on the OpenQR MCP server (note: over MCP, get_scans always reports a fixed 30-day window).
Freshness, polling and limits
- Freshness — scans are logged at redirect time and read live, so a poll reflects very recent activity. There's no aggregation delay to wait out.
- Polling cadence — for dashboards, fetch on load or on a short interval. For rollups, a few-minute cron is plenty; this data doesn't change second-to-second for most campaigns.
- No write cost — reading
/scansdoesn't touch the dynamic-code creation rate limit (the 20/hour cap), which only applies to creating codes. - Deleting a code erases its data —
DELETEremoves the code and its scans become unqueryable. Export anything you need first.