Developers
Build a branded link shortener with the OpenQR dynamic API
By Sam Moreton · updated 28 June 2026
oqr.to that redirects through OpenQR, can be repointed anytime, and logs every hit. Strip away the QR framing and that's the exact definition of a link shortener — with the bonus that every short link is already a scannable QR code. This guide builds a small but complete shortener on the OpenQR dynamic API: create short links with custom back-halves, redirect users, repoint dead links, and read click analytics. Full working Node code, no extra backend.12 min read · Updated 28 June 2026
Prerequisite: a free API key — see getting started with the OpenQR API. Every call is Authorization: Bearer oqr_… against https://openqr.uk. The conceptual model for what a dynamic code is lives in the dynamic codes API guide; here we treat it purely as a shortener primitive.
Why this works as a shortener
Creating a dynamic code returns a short_url like https://oqr.to/your-slug. Hitting that URL 302-redirects to the destination, OpenQR logs the click (country, device, referrer), and you can PATCH the destination later without changing the link. Custom slugs give you readable branded back-halves. That's a full shortener feature set — you don't host the redirect layer, OpenQR does.
The four operations you need
| Shortener action | OpenQR call |
|---|---|
| Shorten a URL | POST /v1/dynamic { destination } → returns short_url |
| Pick a custom back-half | PATCH /v1/dynamic/{id} { slug } (or set it before printing) |
| Repoint a link | PATCH /v1/dynamic/{id} { destination } |
| Read click analytics | GET /v1/dynamic/{id}/scans?days= |
| List / delete links | GET /v1/dynamic · DELETE /v1/dynamic/{id} |
Shorten a URL
POST /v1/dynamic with { destination }. The destination must be a public http(s) URL — private/internal hosts and oqr.to self-loops are rejected as an abuse guard. You get back the id, the random slug, and the short_url to hand out.
curl -X POST https://openqr.uk/v1/dynamic \
-H "Authorization: Bearer oqr_live_yourkey" \
-H "Content-Type: application/json" \
-d '{ "destination": "https://example.com/a/very/long/url?utm=x", "label": "Q3 newsletter" }'
# 201 Created
# { "id": "b1c2…", "slug": "k7Pm2qR",
# "short_url": "https://oqr.to/k7Pm2qR",
# "destination": "https://example.com/a/very/long/url?utm=x" }Custom branded back-halves
A random slug works, but a readable one (oqr.to/q3-news) is the whole appeal of a branded shortener. Set the slug via PATCH. Rules: 3–48 chars, [a-z0-9-] only, no leading/trailing or doubled hyphen. A 409 means that back-half is already taken; pick another.
curl -X PATCH https://openqr.uk/v1/dynamic/b1c2… \
-H "Authorization: Bearer oqr_live_yourkey" \
-H "Content-Type: application/json" \
-d '{ "slug": "q3-news" }'
# 200 OK → short link is now https://oqr.to/q3-newsChanging a slug breaks the old link
The slug IS the short link. Repoint the destination as often as you like — the link is stable. But change the slug and the old back-half immediately stops resolving, breaking anything already shared. Lock your custom slug in before you distribute the link.
A complete shortener in Node
Here's a self-contained module that shortens a URL with an optional custom slug, falling back gracefully if the slug is taken. It uses nothing but fetch:
const BASE = "https://openqr.uk";
const KEY = process.env.OPENQR_KEY;
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
/** Shorten a URL, optionally requesting a custom back-half. */
export async function shorten(destination, slug) {
const create = await fetch(`${BASE}/v1/dynamic`, {
method: "POST",
headers: auth,
body: JSON.stringify({ destination }),
});
if (!create.ok) throw new Error((await create.json()).error);
const code = await create.json();
if (!slug) return code; // random slug is fine
const patch = await fetch(`${BASE}/v1/dynamic/${code.id}`, {
method: "PATCH",
headers: auth,
body: JSON.stringify({ slug }),
});
if (patch.status === 409) {
// Back-half taken — keep the random slug, surface a warning.
return { ...code, warning: `slug "${slug}" taken; using ${code.slug}` };
}
if (!patch.ok) throw new Error((await patch.json()).error);
return patch.json();
}
const link = await shorten("https://example.com/launch", "launch");
console.log(link.short_url); // https://oqr.to/launchRepoint a dead link without reissuing it
The killer shortener feature: a link you've already shared can be redirected somewhere new. Newsletter went out with a broken URL? Don't reissue — repoint:
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/launch-fixed" }'Click analytics for every link
Each short link carries analytics for free. GET /v1/dynamic/{id}/scans?days= returns lifetime and windowed click totals, a zero-filled daily series, and top countries, devices and referrers — exactly what a shortener's stats page shows. Full field reference in track QR scans with the analytics API.
const id = "b1c2…";
const res = await fetch(
`https://openqr.uk/v1/dynamic/${id}/scans?days=30`,
{ headers: { Authorization: `Bearer ${process.env.OPENQR_KEY}` } }
);
const { scans, analytics } = await res.json();
console.log(`${scans.total} total clicks, ${analytics.window_total} this month`);
console.log("Top referrer:", analytics.by_referrer[0]?.value ?? "Direct");Every short link is already a QR code
Because the short_url is a real URL, you can render it as a QR in one call: GET /v1/qr?data=https://oqr.to/your-slug&format=png. That's the edge a QR-native shortener has over a plain one — print or display the same link, no extra step.
Bulk shortening and limits
Single-create is capped at 20 links per hour per account (a 429 with Retry-After when exceeded). For migrating a batch of links, use POST /v1/dynamic/bulk — up to 200 per request. See bulk QR code generation for chunking and folders. To organise links, file them with folder_id and label them with tags (max 10).
If you'd rather not call the REST endpoints directly, the typed OpenQR TypeScript SDK wraps all of this (createDynamicCode, updateDynamicCode, getScans), and an AI agent can drive the same operations through the MCP server.