OpenQR

Developers

Pull QR scan data with the OpenQR analytics API

By Sam Moreton · updated 28 June 2026

Every dynamic QR code redirects through OpenQR, and every redirect is logged. The analytics API turns that log into structured data you can chart, export and alert on — without ever opening the dashboard. This guide is a deep dive on a single endpoint, 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 } ]
  }
}
FieldMeaning
scans.totalLifetime scans — ignores the days window.
scans.last7Scans in the trailing 7 days (fixed, regardless of days).
scans.topCountry / topDeviceThe single leading country and device class, or null if no scans.
analytics.days_windowEchoes the days you requested (clamped to 1–365).
analytics.totalLifetime scans — identical to scans.total.
analytics.window_totalScans inside the days window only.
analytics.dailyOne { day, n } bucket per day in the window, oldest → newest, zero-filled.
analytics.by_country / by_device / by_referrerTop 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 /scans doesn't touch the dynamic-code creation rate limit (the 20/hour cap), which only applies to creating codes.
  • Deleting a code erases its dataDELETE removes the code and its scans become unqueryable. Export anything you need first.
Get a free API keySign in via magic link, create a key, and start reading scan data. Dynamic codes, analytics and the API are all free.
scans.total (and the identical analytics.total) is lifetime — every scan the code has ever had, regardless of the days parameter. window_total only counts scans inside the days window you requested. Use total for an all-time headline number and window_total for a recent-activity figure.

Related reading