OpenQR

Developers

The OpenQR TypeScript SDK: a typed client for QR codes

By Sam Moreton · updated 28 June 2026

If you're calling the OpenQR API from TypeScript or JavaScript, the official @open-qr/sdk client saves you the boilerplate: one typed object that wraps every endpoint, returns properly-typed results, and throws a structured OpenQRError on any non-2xx. It ships no dependencies — just the global fetch — so it runs unchanged in Node ≥18, edge runtimes and the browser. This is the full method-by-method reference, mapped to the REST endpoints underneath.

10 min read · Updated 28 June 2026

The SDK is a thin, faithful wrapper over the REST API — same account, same key, same rules. If you're new to the API, skim getting started first; everything the SDK does is the REST surface with types bolted on. Prefer raw fetch or another language? Stick with the REST guides. Reach for the SDK when you're in TypeScript and want autocomplete plus typed errors.

Publishing status

The SDK source lives in the OpenQR repo and is import-ready, but npm publishing is the one remaining step (it needs an npm token). Until it's on npm, install it from the repo/workspace; the import path and API shown here are final and won't change on publish.

Install and construct

pnpm add @open-qr/sdk
# npm install @open-qr/sdk

Create a free API key at openqr.uk/api, then construct the client. The key is sent as Authorization: Bearer oqr_… on every request — header-only, never a query string.

import { OpenQR } from "@open-qr/sdk";

const qr = new OpenQR({ apiKey: process.env.OPENQR_KEY! });

// Full options:
new OpenQR({
  apiKey: "oqr_…",
  baseUrl: "https://openqr.uk", // default; override only for self-hosting
  fetch: customFetch,           // optional; defaults to global fetch (needed on Node <18)
});

Where it runs

Because it uses the global fetch and ships zero dependencies, the same client works in Node ≥18, Bun, Deno, Cloudflare Workers and other edge runtimes, and the browser. On Node <18 (no global fetch), pass a fetch implementation in the options.

Generate static codes

generate() is overloaded on format: the default returns SVG markup as a string, while format: "png" returns the image bytes as a Uint8Array — the types follow automatically. qrUrl() builds a shareable GET URL without making a request (and without embedding your key).

// SVG markup (string)
const svg = await qr.generate({ data: "https://openqr.uk", size: 512, margin: 4 });

// PNG bytes (Uint8Array)
const png = await qr.generate({ data: "https://openqr.uk", format: "png", dark: "232E3A", light: "FFFFFF" });

// Apply a saved theme by id or name
await qr.generate({ data: "https://openqr.uk", theme: "Brand" });

// Build a shareable URL — no request made, no key embedded
const url = qr.qrUrl({ data: "https://openqr.uk", format: "png" });
FieldTypeNotes
datastringText or URL to encode (max 2000 chars). Required.
format"svg" | "png"Default svg (string); png returns Uint8Array.
sizenumber64–2048, default 512.
marginnumberQuiet-zone modules, 0–16, default 4.
dark / lightstringForeground/background hex, e.g. "232E3A".
themestringA saved theme id or name; explicit dark/light/margin override it.

Dynamic codes: full CRUD

Every dynamic operation has a typed method. createDynamicCode() additionally attaches a rateLimit snapshot parsed from the X-RateLimit-* response headers, so you can watch your hourly budget without a second call.

// Create — returns the code plus a rateLimit snapshot
const code = await qr.createDynamicCode({ destination: "https://example.com/menu", label: "Spring menu" });
console.log(code.short_url); // https://oqr.to/<slug>
console.log(code.rateLimit); // { limit, remaining, reset } | undefined

// List (newest first; limit 1–500, default 200)
const codes = await qr.listDynamicCodes({ limit: 50 });

// Update — send only what changes
await qr.updateDynamicCode(code.id, {
  destination: "https://example.com/summer-menu",
  slug: "summer-menu",
  tags: ["menu", "2026"],
  folder_id: null, // un-file
});

// Bulk create (up to 200 in one request)
const created = await qr.bulkCreateDynamicCodes({
  codes: [
    { destination: "https://example.com/a", label: "A" },
    { destination: "https://example.com/b", label: "B" },
  ],
  theme: "Brand",
});

// Delete
await qr.deleteDynamicCode(code.id);
MethodEndpointReturns
createDynamicCode(input)POST /v1/dynamicDynamicCode & { rateLimit? }
listDynamicCodes({ limit })GET /v1/dynamicDynamicCode[]
bulkCreateDynamicCodes(input)POST /v1/dynamic/bulkDynamicCode[]
updateDynamicCode(id, patch)PATCH /v1/dynamic/{id}DynamicCode
deleteDynamicCode(id)DELETE /v1/dynamic/{id}{ deleted }
getScans(id, { days })GET /v1/dynamic/{id}/scansScansResponse

Scan analytics

getScans() returns the typed ScansResponse — the compact scans summary and the fuller analytics object (daily series and breakdowns). The days window defaults to 30; the field meanings are documented in track QR scans with the analytics API.

const { scans, analytics } = await qr.getScans(code.id, { days: 30 });

console.log(scans.total, scans.last7, scans.topCountry, scans.topDevice);
console.log(analytics.window_total);
console.log(analytics.daily);       // [{ day, n }, …] zero-filled
console.log(analytics.by_country);  // [{ value, n }, …] top 8
console.log(analytics.by_referrer); // [{ value, n }, …] "Direct" when none

Folders and themes

// Folders
const folders = await qr.listFolders();
const folder = await qr.createFolder("Campaigns");
await qr.renameFolder(folder.id, "Q3 Campaigns");
await qr.deleteFolder(folder.id); // codes inside are un-filed, not deleted

// Themes — a reusable style applied by id or name
const themes = await qr.listThemes();
const theme = await qr.createTheme({
  name: "Brand",
  style: { fgColor: "#232E3A", bgColor: "#FFFFFF", dotType: "rounded", cornerSquareType: "extra-rounded", margin: 4 },
});
await qr.deleteTheme(theme.id); // already-styled codes keep their look

Apply a theme anywhere

Once saved, pass a theme's id or name as the theme field on generate(), createDynamicCode(), bulkCreateDynamicCodes() or updateDynamicCode() to style codes consistently. For static generate() the theme maps to its colours; dynamic codes carry the full style (dots, corners, logo, gradient, frame).

Typed error handling

Every non-2xx response throws an OpenQRError carrying the HTTP status, a stable code, the server's message, and — on a 429 — retryAfter seconds and the rateLimit snapshot. That makes retry/backoff and user-facing messages straightforward.

import { OpenQR, OpenQRError } from "@open-qr/sdk";

try {
  await qr.createDynamicCode({ destination: "http://localhost" });
} catch (err) {
  if (err instanceof OpenQRError) {
    console.error(err.status);     // 400
    console.error(err.code);       // "bad_request"
    console.error(err.message);    // server's message, e.g. private-host rejection
    console.error(err.retryAfter); // seconds (on 429)
    console.error(err.rateLimit);  // { limit, remaining, reset } (on 429)
  }
}
statuscodeWhen
400bad_requestInvalid body, missing field, or a disallowed destination.
401unauthorizedMissing, invalid or revoked API key.
404not_foundCode, folder or theme not found or not owned by your key.
409conflictRequested custom slug already taken.
429rate_limitedDynamic-code creation limit hit (20/hour) — see retryAfter.
500server_errorUnexpected server-side failure.

That's the whole surface. For the use-cases the SDK is built for, see building a link shortener, bulk generation and scheduling destinations. If you're wiring an AI agent rather than writing code, 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 building. The API, SDK, dynamic codes and analytics are all free.
None. @open-qr/sdk ships zero runtime dependencies and uses the global fetch. That keeps installs tiny and lets it run unchanged in Node ≥18, Bun, Deno, Cloudflare Workers and other edge runtimes, and the browser.

Related reading