API reference
A REST API over HTTPS. All endpoints live under /api/v1 and return JSON.
Quickstart — generate a QR for a campaign
Create a dynamic QR with a destination URL and a name. The response returns the QR's id (a UUID) — store it against your ad campaign. Change the destination later with PATCH /api/v1/qrcodes/{id} and the printed code keeps working.
curl -X POST https://your-domain.com/api/v1/qrcodes \
-H "X-API-Key: qr_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"destination_url":"https://example.com/landing","name":"Spring Campaign"}'Response 201 Created:
{
"id": "8f3c0b2e-4d1a-4c9b-9f2e-1a2b3c4d5e6f", // QR UUID — store against your campaign
"name": "Spring Campaign",
"destination_url": "https://example.com/landing",
"short_slug": "a1B2c3",
"tracking_url": "https://your-domain.com/r/a1B2c3", // what the QR encodes
"qr_svg_url": "https://your-domain.com/api/v1/qrcodes/8f3c0b2e-.../qr.svg",
"is_active": true,
"created_at": "2026-06-14T21:00:00.000Z"
}Authentication
Two methods. A dashboard session can call everything. An API key is limited to the scopes stored on it — currently qrcodes:read and qrcodes:write — so API keys reach the QR-code endpoints only. Endpoints marked session only reject API keys (403).
Create a key in Dashboard → API keys (max 4 active). Send it on each request:
curl https://your-domain.com/api/v1/qrcodes \ -H "X-API-Key: qr_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Keys are stored only as SHA-256 hashes; the raw value is shown once. Treat it like a password.
Rate limits
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset. Exceeding the limit returns 429.
- • Session (JWT): 200 requests/min
- • API key: 100 requests/min by default (configurable per key, up to 10,000)
- • Redirect
/r/{slug}: 5,000/min per IP at the edge
Errors
Errors return a JSON body { "error": "..." } with a standard status: 400 invalid input · 401 unauthenticated · 403 wrong scope / session required · 404 not found · 409 conflict (e.g. key limit) · 429 rate limited.
Security model
- • Keys stored as SHA-256 hashes;
qr_sk_prefix lets secret scanners flag leaks. - • Scoped + capped (4/account); revocation purges the auth cache immediately.
- • Every mutation is audit-logged; view it under Dashboard → Audit log.
- • Tenant isolation via Postgres row-level security on every table.
- • Destinations screened by Google Safe Browsing; webhook URLs are SSRF-guarded.
- • CORS is locked to an allow-list in production.
Endpoints
QR codes
The only group that accepts API keys (with the matching scope). All others are session-only.
/api/v1/qrcodesscope qrcodes:writeGenerate a dynamic QR code. Returns the QR's id (UUID), tracking_url, and qr_svg_url.
- Body: destination_url (http/https, required), name? (recommended), folder_id?,
- tags?[], active_from?, active_until? (ISO 8601), password?, ab_destinations?[{url,weight}]
- Destination is checked against Google Safe Browsing; unsafe URLs → 400.
Request body
{
"destination_url": "https://example.com/landing",
"name": "Spring Campaign",
"tags": ["q2", "print"]
}Response
201 Created
{
"id": "8f3c0b2e-4d1a-4c9b-9f2e-1a2b3c4d5e6f",
"name": "Spring Campaign",
"destination_url": "https://example.com/landing",
"short_slug": "a1B2c3",
"tracking_url": "https://your-domain.com/r/a1B2c3",
"qr_svg_url": "https://your-domain.com/api/v1/qrcodes/8f3c0b2e-.../qr.svg",
"folder_id": null,
"tags": ["q2", "print"],
"is_active": true,
"created_at": "2026-06-14T21:00:00.000Z"
}/api/v1/qrcodesscope qrcodes:readList your QR codes (newest first). Password hashes are never returned.
- Query: ?folder=<uuid|none>, ?tag=<tag>
- Archived codes are excluded by default. ?archived=true returns only archived, ?archived=all returns both.
Response
200 OK
[
{
"id": "8f3c0b2e-4d1a-4c9b-9f2e-1a2b3c4d5e6f",
"short_slug": "a1B2c3",
"destination_url": "https://example.com/landing",
"name": "Spring Campaign",
"is_active": true,
"scan_count": 42,
"tags": ["q2"],
"created_at": "2026-06-14T21:00:00.000Z"
}
]/api/v1/qrcodes/{id}scope qrcodes:readFetch a single QR code by id. Password hashes are never returned.
- Same shape as a listing item. Archived codes are returned too — unlike the listing, a request by id is explicit.
- Unknown or not-owned id → 404.
Response
200 OK
{
"id": "8f3c0b2e-4d1a-4c9b-9f2e-1a2b3c4d5e6f",
"short_slug": "a1B2c3",
"destination_url": "https://example.com/landing",
"name": "Spring Campaign",
"is_active": true,
"archived_at": null,
"scan_count": 42,
"tags": ["q2"],
"created_at": "2026-06-14T21:00:00.000Z"
}/api/v1/qrcodes/{id}scope qrcodes:writeUpdate destination, is_active, archived, name, folder, tags, or advanced-link fields.
- Editing the destination updates the redirect instantly (KV is kept in sync).
- Send { "archived": true } to retire a code and { "archived": false } to restore it. An archived code stops resolving (410) but keeps its scan history and its slug.
- Archiving requires a paid plan — Free returns 402. Deleting is available on every plan.
Request body
{ "destination_url": "https://example.com/new-landing" }Response
200 OK
{
"id": "8f3c0b2e-4d1a-4c9b-9f2e-1a2b3c4d5e6f",
"short_slug": "a1B2c3",
"destination_url": "https://example.com/new-landing",
"is_active": true,
...
}/api/v1/qrcodes/{id}scope qrcodes:writePermanently delete a code. The slug 404s on the next scan and is freed for reuse.
- Destructive and irreversible — the row and its scan history are gone. To retire a code reversibly, PATCH { "archived": true } instead.
/api/v1/qrcodes/{id}/qr.svgscope qrcodes:readScannable image of the tracking URL.
- Query: ?format=png (default SVG). The image encodes /r/<slug>, never the destination.
/api/v1/qrcodes/{id}/analyticssession onlyPer-day scan timeseries.
- Query: ?days=1..365 (default 30). Returns { days, series }.
Response
200 OK
{
"days": 30,
"series": [
{ "day": "2026-06-13", "scans": 8 },
{ "day": "2026-06-14", "scans": 34 }
]
}/api/v1/qrcodes/bulkscope qrcodes:writeCreate up to 100 codes in one request.
- Body: { codes: [{ destination_url, name?, folder_id?, tags? }] } (1–100)
Request body
{
"codes": [
{ "destination_url": "https://example.com/a", "name": "Flyer A" },
{ "destination_url": "https://example.com/b", "name": "Flyer B" }
]
}Response
201 Created
{
"created": 2,
"codes": [
{ "id": "…", "short_slug": "a1B2c3", "tracking_url": "https://your-domain.com/r/a1B2c3", ... }
]
}/api/v1/qrcodes/bulkscope qrcodes:writeArchive or restore up to 100 codes by id.
- Body: { ids: [uuid] (1–100), archived: boolean }
- Requires a paid plan — Free returns 402.
Request body
{ "ids": ["8f3c0b2e-…", "1a2b3c4d-…"], "archived": true }Response
200 OK
{ "archived": 2 }/api/v1/qrcodes/bulkscope qrcodes:writePermanently delete up to 100 codes by id.
- Body: { ids: [uuid] } (1–100). Irreversible — see PATCH above to archive instead.
/api/v1/qrcodes/exportscope qrcodes:readStream all your codes as CSV.
Folders
Session-only — API keys can only hold qrcodes scopes, so folder routes require a dashboard session.
/api/v1/folderssession onlyList your folders.
/api/v1/folderssession onlyCreate a folder.
- Body: { name, color? (#rrggbb) }
Request body
{ "name": "Q2 Campaigns", "color": "#4f46e5" }Response
201 Created
{ "id": "…", "name": "Q2 Campaigns", "color": "#4f46e5" }/api/v1/folders/{id}session onlyRename or recolor a folder.
/api/v1/folders/{id}session onlyDelete a folder.
API keys
Session-only. Max 4 active keys per account. The raw key is returned exactly once on creation.
/api/v1/keyssession onlyMint a key (returns the secret once).
- Body: { name, scopes?: [qrcodes:read|qrcodes:write], rate_limit?: 1..10000, expires_at? }
- 409 if you already have 4 active keys.
Request body
{ "name": "Production server", "scopes": ["qrcodes:read", "qrcodes:write"] }Response
201 Created
{
"id": "…",
"name": "Production server",
"key_prefix": "qr_sk_a1b2",
"scopes": ["qrcodes:read", "qrcodes:write"],
"rate_limit": 100,
"key": "qr_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // shown once — store it now
}/api/v1/keyssession onlyList your keys (never the secret).
/api/v1/keys/{id}session onlyRevoke a key. It stops authenticating immediately (KV cache purged).
Webhooks
Session-only. Deliveries are HMAC-signed; verify the X-Webhook-Signature header with your secret.
/api/v1/webhookssession onlyRegister a webhook. URL must be a public endpoint (SSRF-guarded).
- Body: { url, events: [qr.created|qr.updated|qr.deleted|scan.threshold] }
Request body
{
"url": "https://your-server.com/hooks/qr",
"events": ["qr.created", "scan.threshold"]
}Response
201 Created
{
"id": "…",
"url": "https://your-server.com/hooks/qr",
"events": ["qr.created", "scan.threshold"],
"secret": "whsec_…", // verify the X-Webhook-Signature (sha256 HMAC) with this
"is_active": true
}/api/v1/webhookssession onlyList your webhooks.
/api/v1/webhooks/{id}session onlyDelete a webhook.
Account
Session-only. Includes GDPR data portability and erasure.
/api/v1/accountsession onlyYour profile.
/api/v1/accountsession onlyUpdate profile (display_name, timezone).
/api/v1/accountsession onlyGDPR erasure — deletes your account and cascades all owned data.
/api/v1/account/exportsession onlyGDPR export — full JSON archive (password hashes stripped).
/api/v1/account/audit-logsession onlyYour recent mutations.
Redirect engine
/r/{slug}publicResolve a scan: schedule → password → A/B → 302 to destination.
- Served from the edge via KV. IP rate-limited to 5000/min. Records the scan asynchronously.