Developers
Pull QR scan data with the OpenQR analytics API
By OpenQR · 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. On the free plan the window is additionally clamped to 7 days, and by_device/by_referrer are omitted from the response entirely rather than sent empty; Pro returns the window you asked for. 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 scan", "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 when there is no data. topDevice needs detailed analytics, so it is always null on the free plan. |
| analytics.days_window | The window actually used: your days value clamped to 1–365, and to your plan's window (free = 7 days). |
| 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 scan"; 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 (300 codes per hour, per user), 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.