QRStudio Builder

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.

POST/api/v1/qrcodesscope qrcodes:write

Generate 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"
}
GET/api/v1/qrcodesscope qrcodes:read

List 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"
  }
]
GET/api/v1/qrcodes/{id}scope qrcodes:read

Fetch 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"
}
PATCH/api/v1/qrcodes/{id}scope qrcodes:write

Update 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,
  ...
}
DELETE/api/v1/qrcodes/{id}scope qrcodes:write

Permanently 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.
GET/api/v1/qrcodes/{id}/qr.svgscope qrcodes:read

Scannable image of the tracking URL.

  • Query: ?format=png (default SVG). The image encodes /r/<slug>, never the destination.
GET/api/v1/qrcodes/{id}/analyticssession only

Per-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 }
  ]
}
POST/api/v1/qrcodes/bulkscope qrcodes:write

Create 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", ... }
  ]
}
PATCH/api/v1/qrcodes/bulkscope qrcodes:write

Archive 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 }
DELETE/api/v1/qrcodes/bulkscope qrcodes:write

Permanently delete up to 100 codes by id.

  • Body: { ids: [uuid] } (1–100). Irreversible — see PATCH above to archive instead.
GET/api/v1/qrcodes/exportscope qrcodes:read

Stream all your codes as CSV.

Folders

Session-only — API keys can only hold qrcodes scopes, so folder routes require a dashboard session.

GET/api/v1/folderssession only

List your folders.

POST/api/v1/folderssession only

Create a folder.

  • Body: { name, color? (#rrggbb) }

Request body

{ "name": "Q2 Campaigns", "color": "#4f46e5" }

Response

201 Created
{ "id": "…", "name": "Q2 Campaigns", "color": "#4f46e5" }
PATCH/api/v1/folders/{id}session only

Rename or recolor a folder.

DELETE/api/v1/folders/{id}session only

Delete a folder.

API keys

Session-only. Max 4 active keys per account. The raw key is returned exactly once on creation.

POST/api/v1/keyssession only

Mint 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
}
GET/api/v1/keyssession only

List your keys (never the secret).

DELETE/api/v1/keys/{id}session only

Revoke 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.

POST/api/v1/webhookssession only

Register 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
}
GET/api/v1/webhookssession only

List your webhooks.

DELETE/api/v1/webhooks/{id}session only

Delete a webhook.

Account

Session-only. Includes GDPR data portability and erasure.

GET/api/v1/accountsession only

Your profile.

PATCH/api/v1/accountsession only

Update profile (display_name, timezone).

DELETE/api/v1/accountsession only

GDPR erasure — deletes your account and cascades all owned data.

GET/api/v1/account/exportsession only

GDPR export — full JSON archive (password hashes stripped).

GET/api/v1/account/audit-logsession only

Your recent mutations.

Redirect engine

GET/r/{slug}public

Resolve 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.