OpenQR

Developers

Editable QR codes and scan analytics with the OpenQR API

A dynamic QR code doesn't bake your real URL into the pattern — it encodes a short oqr.to link that redirects through OpenQR. That indirection is the whole point: you can change the destination after the code is printed, and every scan passes through a server that can count it. This guide covers the data layer — creating, editing, listing and deleting dynamic codes over the REST API, plus reading scan analytics in depth.

10 min read · Updated 26 June 2026

If you've not set up an API key yet, start with getting started with the OpenQR API — every call here needs a free key sent as Authorization: Bearer oqr_…. The base URL is https://openqr.uk. For the conceptual difference between the two kinds of code, see static vs dynamic QR codes.

What a dynamic code actually is

When you create a dynamic code you get back a slug and a short_url of the form https://oqr.to/<slug>. That short URL is what gets encoded into the QR image. A scan hits the oqr.to redirect worker, which looks up the current destination and issues a 302 to it. Because the real destination lives server-side, you can repoint it whenever you like — the printed code never changes.

Why this enables tracking

A static code points straight at its destination with nothing in between, so it cannot be tracked — there's no server to log anything. A dynamic code's redirect is exactly that missing server. See "how to track QR code scans" for the honest version of this trade-off.

Honesty first: if you don't need to edit destinations or see analytics, a static code is simpler, free, works offline forever, and has no dependency on anyone's redirect staying online. Reach for dynamic codes when you specifically need editability or scan data. More on that in how to track QR code scans.

Create a dynamic code

POST /v1/dynamic with a JSON body of { destination, label? }. The destination must be a public http(s) URL — private or internal hosts and oqr.to self-loops are rejected as an abuse guard. A successful call returns 201 with the new code's id, slug, short_url and destination.

bash
curl -X POST https://openqr.uk/v1/dynamic \
  -H "Authorization: Bearer oqr_live_yourkey" \
  -H "Content-Type: application/json" \
  -d '{
    "destination": "https://example.com/spring-launch",
    "label": "Spring poster — bus stops"
  }'

# 201 Created
# {
#   "id": "b1c2…",
#   "slug": "k7Pm2qR",
#   "short_url": "https://oqr.to/k7Pm2qR",
#   "destination": "https://example.com/spring-launch"
# }

Now render that short_url as a QR image (via /v1/qr, covered in the getting-started guide), print it, and you're live. To generate the code image straight from the short URL:

bash
curl "https://openqr.uk/v1/qr?data=https://oqr.to/k7Pm2qR&format=png&size=1024" \
  -H "Authorization: Bearer oqr_live_yourkey" \
  -o spring-poster.png

Rate limit: 20 creations per hour

Single-create is capped at 20 dynamic codes per hour per account; exceeding it returns 429. If you need hundreds at once (per-table codes, per-product packaging), use POST /v1/dynamic/bulk — up to 200 per request. See the bulk generation guide.

Repoint, rename, tag: editing a code

PATCH /v1/dynamic/{id} is the workhorse. Send only the fields you want to change. Editable fields:

FieldTypeNotes
destinationstringNew public http(s) URL. Takes effect immediately for every future scan.
labelstring | nullInternal name. Send null to clear it.
slugstringCustom back-half, 3–48 chars [a-z0-9-], no leading/trailing or doubled hyphen. Repoints the short link — the OLD slug stops resolving.
tagsstring[]Deduped and lower-cased, max 10. Replaces the existing set.
folder_idstring | nullFile into a folder, or null to un-file.
styleobjectQrStyle JSON for the rendered image.

The classic move is repointing after launch. Say the poster went out pointing at a holding page; once the real campaign page is ready, swap the destination — no reprint, same physical code:

bash
curl -X PATCH https://openqr.uk/v1/dynamic/b1c2… \
  -H "Authorization: Bearer oqr_live_yourkey" \
  -H "Content-Type: application/json" \
  -d '{ "destination": "https://example.com/spring/live" }'

# 200 OK
# { "id": "b1c2…", "slug": "k7Pm2qR",
#   "short_url": "https://oqr.to/k7Pm2qR",
#   "destination": "https://example.com/spring/live", "label": "Spring poster — bus stops" }

Memorable custom slugs

Set a custom slug before you print: PATCH with { "slug": "spring-sale" } gives you oqr.to/spring-sale instead of a random back-half. Changing a slug later breaks the old link, so lock it in early. A 409 means that back-half is already taken.

List and delete

GET /v1/dynamic?limit= returns your codes newest-first (limit ≤ 500, default 200). DELETE /v1/dynamic/{id} removes one and returns { "deleted": true }; the slug immediately stops resolving.

bash
# List
curl "https://openqr.uk/v1/dynamic?limit=50" \
  -H "Authorization: Bearer oqr_live_yourkey"
# { "codes": [ { "id", "slug", "short_url", "destination", "label", "status", "created_at" }, … ] }

# Delete
curl -X DELETE https://openqr.uk/v1/dynamic/b1c2… \
  -H "Authorization: Bearer oqr_live_yourkey"
# { "deleted": true }

Scan analytics in depth

GET /v1/dynamic/{id}/scans?days= is the analytics endpoint. days ranges 1–365 (default 30) and bounds the daily series and breakdowns; lifetime totals ignore the window. The response has two parts — a small scans summary and a fuller analytics object:

json
{
  "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-28", "n": 0 }, { "day": "2026-05-29", "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 } ]
  }
}
  • scans.total — lifetime scans; last7 — the trailing 7 days; topCountry / topDevice are the single leaders.
  • analytics.total — lifetime (same as scans.total); window_total — scans inside the days window.
  • daily — one bucket per day in the window, oldest → newest, zero-filled so you can plot it directly.
  • by_country / by_device / by_referrer — top entries (up to 8 each) as { value, n }. A missing referrer shows as Direct; unknown country/device as Unknown.

How scans are logged — coarse, no PII

Each scan records a timestamp plus country, device class and referrer host, derived from Cloudflare request headers at redirect time. No IP addresses, no fingerprints, no per-person identifiers. It's enough to see where and on what, not who.

Worked example: poll scans and build a summary

Here's a small Node script that reads the last 14 days for a code and prints a tidy summary — total, busiest day, and the top country and referrer. The same shape works for a cron job that posts to Slack.

javascript
const KEY = process.env.OPENQR_KEY;
const id = "b1c2…";

const res = await fetch(
  `https://openqr.uk/v1/dynamic/${id}/scans?days=14`,
  { 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}  |  last 14d: ${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)}`);

The Python equivalent, for an analytics or reporting stack:

python
import os, requests

KEY = os.environ["OPENQR_KEY"]
code_id = "b1c2…"

r = requests.get(
    f"https://openqr.uk/v1/dynamic/{code_id}/scans",
    params={"days": 14},
    headers={"Authorization": f"Bearer {KEY}"},
)
data = r.json()
a = data["analytics"]

busiest = max(a["daily"], key=lambda d: d["n"])
top = lambda xs: f'{xs[0]["value"]} ({xs[0]["n"]})' if xs else "none"

print(f'Lifetime: {data["scans"]["total"]}  |  14d: {a["window_total"]}')
print(f'Busiest day: {busiest["day"]} ({busiest["n"]})')
print(f'Top country: {top(a["by_country"])}')
print(f'Top referrer: {top(a["by_referrer"])}')

Where dynamic codes earn their keep

A few patterns this data layer is built for:

  1. 1

    Print campaigns you can fix in flight

    Posters, flyers and ads go out with a code pointing at a holding page; PATCH the destination the moment the real page is live. If a URL changes mid-campaign, you repoint once instead of reprinting thousands of sheets.

  2. 2

    A/B testing by swapping destinations

    Run one creative pointing at variant A, read /scans for a week, then PATCH the same code to variant B and compare windows. Same physical code, different landing pages over time.

  3. 3

    Packaging and product labels

    Codes on packaging live for years. Point them at a docs or support page now, and repoint to a newer one later without a print run. Bulk-create one per SKU, then tag and folder them.

  4. 4

    Event signage

    Wayfinding and schedule codes at a venue can be repointed day-to-day as the programme shifts, and /scans tells you which signs are actually getting used.

For minting codes at scale (one per table, seat or SKU) and organising them into folders, see bulk QR code generation. To drive all of this from an AI assistant — "create a code for the new menu and tell me last week's scans" — the same operations are exposed as tools on the OpenQR MCP server.

Get a free API keySign in via magic link, create a key, and start minting editable codes. Dynamic codes, analytics and the API are all free.
A static code from /v1/qr bakes your URL straight into the image — it can't be edited or tracked. A dynamic code from /v1/dynamic encodes an oqr.to short link that redirects through OpenQR, so you can repoint the destination anytime and read scan analytics. Use static when you need permanence and no dependency; dynamic when you need editability or data.

Related reading