Developers
Generate and manage QR codes at scale: bulk + folders
Generating one QR code is trivial. Generating four hundred — each pointing somewhere different, each labelled, each filed so you can find it again — is an ops problem. This guide covers the OpenQR bulk endpoint, folders for organisation, and listing/exporting your codes, then walks through a real script that turns a CSV of destinations into a print-ready label-to-short-URL map.
9 min read · Updated 26 June 2026
If you have not set up an API key yet, start with getting started with the OpenQR API — you sign in with a magic link at openqr.uk/api, create a key, and send it as Authorization: Bearer oqr_… on every request. This article assumes you have a key and have read the dynamic codes guide, since bulk creation produces the same editable, trackable codes — just many at once.
Why bulk, and the limits that make it necessary
Single-code creation via POST /v1/dynamic is rate-limited to 20 creations per hour, per account. That is deliberate abuse protection, and it is fine for occasional codes — but it makes creating a few hundred codes one-at-a-time a non-starter. The bulk endpoint exists precisely for volume: POST /v1/dynamic/bulk creates up to 200 codes in a single request, and it is the correct tool whenever you need more than a handful.
Two different limits — don't conflate them
The 20-per-hour cap applies to single POSTs to /v1/dynamic. The bulk endpoint has its own ceiling: a hard maximum of 200 codes per request. To create more than 200, send several bulk requests, each with up to 200 codes. Keep them sequential rather than firing dozens in parallel.
The bulk endpoint
Send a JSON body of { "codes": [{ "destination", "label"? }] } (a bare array also works). Each destination must be a public http(s) URL — private/internal hosts and oqr.to self-loops are rejected. label is optional and is just your own human-readable name for the code. A 201 returns the created codes, each with its id, slug and short_url (the https://oqr.to/<slug> the QR will encode).
curl -s https://openqr.uk/v1/dynamic/bulk \
-H "Authorization: Bearer oqr_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"codes": [
{ "destination": "https://acme.com/listing/12-oak-avenue", "label": "12 Oak Avenue" },
{ "destination": "https://acme.com/listing/4-elm-court", "label": "4 Elm Court" }
]
}'{
"created": [
{
"id": "9b1c…",
"slug": "k7Hq2mP",
"short_url": "https://oqr.to/k7Hq2mP",
"destination": "https://acme.com/listing/12-oak-avenue",
"label": "12 Oak Avenue"
},
{
"id": "3f8a…",
"slug": "Tn4xR9d",
"short_url": "https://oqr.to/Tn4xR9d",
"destination": "https://acme.com/listing/4-elm-court",
"label": "4 Elm Court"
}
]
}Validation is all-or-nothing per request
If any row has an invalid destination, the whole request is rejected with a 400 that names the offending row (e.g. "Row 7: …"). Nothing is created. Clean your CSV first, or validate URLs client-side before you POST.
Folders: organising what you create
Once you are creating codes by the hundred, you need somewhere to put them. Folders are a flat, named grouping under /v1/folders:
| Method | Path | Body | Returns |
|---|---|---|---|
| GET | /v1/folders | — | { folders: [{ id, name }] } |
| POST | /v1/folders | { name } | 201 { id, name } |
| PATCH | /v1/folders/{id} | { name } | { id, name } |
| DELETE | /v1/folders/{id} | — | { deleted: true } |
The bulk endpoint itself does not take a folder_id — it only accepts destination and label. You file codes after creating them, by patching each code: PATCH /v1/dynamic/{id} with { "folder_id": "<folder-id>" }. Pass null to un-file a code. The same PATCH is where you can also set a custom slug, tags, or repoint the destination later.
Deleting a folder doesn't delete its codes
DELETE /v1/folders/{id} removes the folder and un-files its codes (sets their folder_id to null) — the codes themselves, and every printed QR pointing at them, keep working. There is no way to accidentally nuke a print run by tidying up folders.
Listing and exporting your codes
GET /v1/dynamic?limit= returns your codes newest-first (limit up to 500, default 200). Each row carries id, slug, short_url, destination, label, status and created_at — enough to reconcile what you created, re-export a label-to-URL map, or feed a downstream system. For per-code scan analytics use GET /v1/dynamic/{id}/scans, covered in tracking QR code scans.
Worked example: a CSV in, a print map out
Here is the whole job end-to-end in Node (no dependencies beyond the standard library). It reads a CSV of destination,label, creates one folder for the batch, chunks the rows into requests of ≤200, bulk-creates them, files each into the folder, and writes print-map.csv mapping every label to its short_url — the file your print/design step needs.
import { readFileSync, writeFileSync } from "node:fs";
const API = "https://openqr.uk";
const KEY = process.env.OPENQR_KEY; // "oqr_…"
const BULK_MAX = 200;
const headers = {
"Authorization": `Bearer ${KEY}`,
"Content-Type": "application/json",
};
// Minimal CSV reader: expects a "destination,label" header row.
function readCsv(path) {
const [head, ...lines] = readFileSync(path, "utf8").trim().split(/\r?\n/);
const cols = head.split(",").map((c) => c.trim());
return lines.map((line) => {
const cells = line.split(",");
return Object.fromEntries(cols.map((c, i) => [c, (cells[i] ?? "").trim()]));
});
}
function chunk(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
async function api(path, init) {
const res = await fetch(API + path, { ...init, headers });
if (!res.ok) throw new Error(`${path} → ${res.status}: ${await res.text()}`);
return res.json();
}
async function main() {
const rows = readCsv(process.argv[2] ?? "destinations.csv");
if (!rows.length) throw new Error("No rows in CSV.");
// 1. One folder for this print run.
const folder = await api("/v1/folders", {
method: "POST",
body: JSON.stringify({ name: `Print run ${new Date().toISOString().slice(0, 10)}` }),
});
// 2. Bulk-create in batches of <=200, collecting every created code.
const created = [];
for (const batch of chunk(rows, BULK_MAX)) {
const codes = batch.map((r) => ({ destination: r.destination, label: r.label }));
const { created: made } = await api("/v1/dynamic/bulk", {
method: "POST",
body: JSON.stringify({ codes }),
});
created.push(...made);
}
// 3. File each code into the folder (bulk can't set folder_id directly).
for (const c of created) {
await api(`/v1/dynamic/${c.id}`, {
method: "PATCH",
body: JSON.stringify({ folder_id: folder.id }),
});
}
// 4. Write the print map: label -> short_url.
const csv = ["label,short_url", ...created.map((c) => `${c.label ?? ""},${c.short_url}`)].join("\n");
writeFileSync("print-map.csv", csv);
console.log(`Created ${created.length} codes in folder "${folder.name}". Wrote print-map.csv.`);
}
main().catch((e) => { console.error(e); process.exit(1); });Prefer Python? The shape is identical — read the CSV, slice into batches of 200, POST each to /v1/dynamic/bulk, then PATCH each returned id with the folder. Here is the core loop:
import csv, os, requests
API, KEY, BULK_MAX = "https://openqr.uk", os.environ["OPENQR_KEY"], 200
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
rows = list(csv.DictReader(open("destinations.csv"))) # columns: destination,label
folder = requests.post(f"{API}/v1/folders", json={"name": "Print run"}, headers=H).json()
created = []
for i in range(0, len(rows), BULK_MAX):
batch = [{"destination": r["destination"], "label": r["label"]} for r in rows[i:i + BULK_MAX]]
r = requests.post(f"{API}/v1/dynamic/bulk", json={"codes": batch}, headers=H)
r.raise_for_status()
created += r.json()["created"]
for c in created:
requests.patch(f"{API}/v1/dynamic/{c['id']}", json={"folder_id": folder["id"]}, headers=H)
with open("print-map.csv", "w", newline="") as f:
w = csv.writer(f); w.writerow(["label", "short_url"])
w.writerows([[c.get("label") or "", c["short_url"]] for c in created])Where this pays off
Anywhere you need a unique, editable code per physical item — one short URL per object, all repointable later without reprinting:
- Real-estate signage — one code per listing on every board and flyer; repoint it to the next property when the first sells.
- Asset and inventory tags — a code per machine, room or shelf that opens its maintenance log or manual.
- Retail SKUs — per-product codes that route to the live product page, swappable when a line is discontinued.
- Conference badges — a unique code per attendee linking to their profile, schedule or check-in.
- Per-item campaigns — uniquely coded packaging or coupons so each scan is attributable.
Because these are dynamic codes, every one is editable and trackable after printing — you can repoint any destination through the dashboard, the API, or the MCP server if you would rather drive it from an AI agent (the bulk_create_dynamic_qr, create_folder and update_dynamic_qr tools mirror these endpoints exactly).
Rendering the actual QR images
The bulk endpoint returns short_urls, not images. To print, render each short_url as a QR with GET /v1/qr?data=