QRCDN

The QRCDN API

One scoped surface for your dynamic codes: create them, retarget them, pause them, or pull their scan analytics, all over HTTP, all owner-scoped to your API key. Retargeting changes where a code points without touching the printed QR, because your code never dies.

Quickstart

Mint a key, create a code, print it, then change where it points. Everything below is copy-pasteable.

  1. 1. Mint a key

    Create one from the Studio: /api-keys → New key. It looks like qrcdn_live_… and is shown in full exactly once, so copy it somewhere safe. Keys are owner-scoped: yours can only see and change codes you created.

  2. 2. Create a code

    POST a name and a destination. QRCDN assigns the slug, and the code is live immediately.

    Request
    curl -X POST https://www.qrcdn.com/api/v1/codes \
      -H "Authorization: Bearer qrcdn_live_…" \
      -H "Content-Type: application/json" \
      -d '{"name": "Launch poster", "destination": "https://example.com/launch"}'
    Response
    // 201 Created
    {
      "slug": "K4RN9WD",
      "name": "Launch poster",
      "destination": "https://example.com/launch",
      "status": "active",
      "scanCount": 0,
      "expiresAt": null,
      "passwordProtected": false,
      "url": "https://qrcdn.com/K4RN9WD",
      "createdAt": "2026-07-23T09:14:02.000Z"
    }

    Full reference: POST /codes

  3. 3. Print it

    The response's url is the code's permanent address. Render the QR from the Studio or your own stack, then print it on anything.

  4. 4. Scan it

    A scan never reaches your destination directly. It hits QRCDN's redirect layer first, which looks up where the code currently points and sends the visitor on:

    GET qrcdn.com/<slug> → 302 · cache-control: no-store → your destination

  5. 5. Repoint it

    PATCH the same slug with a new destination. Nothing about the printed code changes.

    Request
    curl -X PATCH https://www.qrcdn.com/api/v1/codes/K4RN9WD \
      -H "Authorization: Bearer qrcdn_live_…" \
      -H "Content-Type: application/json" \
      -d '{"destination": "https://example.com/launch-v2"}'
    Response
    {
      "slug": "K4RN9WD",
      "destination": "https://example.com/launch-v2",
      "status": "active",
      "expiresAt": null
    }

    Full reference: PATCH /codes/{slug}

    Scan the same print again. New destination, same code. That is the whole product.

Authentication

Every request carries a bearer token in the Authorization header. Mint and revoke keys from /api-keys; API access is a Pro plan feature. Keys look like qrcdn_live_… and are shown in full only once, at creation.

curl https://www.qrcdn.com/api/v1/codes \
  -H "Authorization: Bearer qrcdn_live_…"

A key is owner-scoped: every request only ever reads or changes codes its own owner created. There is no cross-account visibility, not even to confirm whether a given slug exists at all.

A missing or malformed header, a malformed key, and an unknown or revoked key all return the same 401 shape (only the message text differs), so probing a key can never confirm whether it once existed:

// 401 Unauthorized
{ "error": "unauthorized", "message": "Invalid API key." }

A key that authenticates fine but whose plan does not include the API gets a 403 instead:

// 403 Forbidden
{ "error": "api_not_available", "message": "The API is available on the Pro plan." }

Base URL

https://www.qrcdn.com/api/v1

Endpoints

GET/codes

List every dynamic code owned by this key.

Parameters

NameInTypeRequiredNotes
No parameters. This endpoint only requires authentication.
Request
curl https://www.qrcdn.com/api/v1/codes \
  -H "Authorization: Bearer qrcdn_live_…"
Response
{
  "codes": [
    {
      "slug": "8K2QRX",
      "name": "Storefront flyer",
      "destination": "https://example.com/promo",
      "status": "active",
      "scanCount": 142,
      "expiresAt": null,
      "passwordProtected": false,
      "url": "https://qrcdn.com/8K2QRX",
      "createdAt": "2026-07-01T12:00:00.000Z"
    }
  ]
}

Response fields

FieldTypeNotes
codesApiCode[]Every dynamic code this key's owner has created, newest first.
codes[].slugstringUppercase. The path segment in the code's short URL.
codes[].namestringSet at creation. This API has no endpoint to rename a code afterward.
codes[].destinationstringWhere a scan currently redirects to.
codes[].status"active" | "paused" | "archived"Does not account for expiry: a code past its expiresAt still reports status active here. Compare expiresAt yourself if you need to know whether scans are actually reaching the destination.
codes[].scanCountnumberAll-time total, updated by an hourly rollup rather than per scan: a scan from the last few minutes may not be reflected yet. The analytics endpoint's today.scans is live.
codes[].expiresAtstring | nullISO-8601 UTC, or null if the code never expires. Once in the past, scans hit the same unavailable page a paused code shows, even while status above still reads active.
codes[].passwordProtectedbooleanNever the password or a hash of it: only whether one is set.
codes[].urlstringhttps://qrcdn.com/{slug}, lowercase host. Not the uppercase form printed on the QR itself.
codes[].createdAtstringISO-8601 UTC.

Errors

StatusCodeWhen
500internal_errorThe codes query failed on our end.
POST/codes

Create a dynamic code.

Parameters

NameInTypeRequiredNotes
namebodystringRequired1 to 60 characters, trimmed.
destinationbodystringRequiredHTTP or HTTPS URL, up to 2048 characters. Needs a real, dotted hostname: bare IPs and localhost are rejected.
stylebodyobjectOptionalA QrStyle v1 snapshot (dots, eyes, fill, background, logo), the same JSON the Studio's editor exports. Omit it and QRCDN uses its default style.
slugbodystringOptionalPro plan only. 4 to 30 characters from 23456789ABCDEFGHJKMNPQRSTVWXYZ (0, 1, I, L, O, and U excluded: they misprint on small labels). Case-insensitive, normalized to uppercase. A taken or reserved slug is rejected, never silently reassigned.
Request
curl -X POST https://www.qrcdn.com/api/v1/codes \
  -H "Authorization: Bearer qrcdn_live_…" \
  -H "Content-Type: application/json" \
  -d '{"name": "Storefront flyer", "destination": "https://example.com/promo", "slug": "mybrand26"}'
Response
// 201 Created
{
  "slug": "MYBRAND26",
  "name": "Storefront flyer",
  "destination": "https://example.com/promo",
  "status": "active",
  "scanCount": 0,
  "expiresAt": null,
  "passwordProtected": false,
  "url": "https://qrcdn.com/MYBRAND26",
  "createdAt": "2026-07-23T09:14:02.000Z"
}

Response fields

FieldTypeNotes
slugstringUppercase. The path segment in the code's short URL.
namestringSet at creation. This API has no endpoint to rename a code afterward.
destinationstringWhere a scan currently redirects to.
status"active" | "paused" | "archived"Does not account for expiry: a code past its expiresAt still reports status active here. Compare expiresAt yourself if you need to know whether scans are actually reaching the destination.
scanCountnumberAll-time total, updated by an hourly rollup rather than per scan: a scan from the last few minutes may not be reflected yet. The analytics endpoint's today.scans is live.
expiresAtstring | nullISO-8601 UTC, or null if the code never expires. Once in the past, scans hit the same unavailable page a paused code shows, even while status above still reads active.
passwordProtectedbooleanNever the password or a hash of it: only whether one is set.
urlstringhttps://qrcdn.com/{slug}, lowercase host. Not the uppercase form printed on the QR itself.
createdAtstringISO-8601 UTC.

Errors

StatusCodeWhen
422invalid_requestValidation failed: a bad name, destination, style, or vanity slug. message often carries the specific reason, e.g. invalid_destination, slug_taken, slug_reserved, or destination_unsafe (the destination failed a safety check).
403code_limit_reachedYou are at your plan's dynamic code limit (3 free, 250 Pro).
403vanity_slugs_not_availableslug was supplied, but your plan does not include vanity slugs (Pro only).
500internal_errorA backend failure: the profile lookup, the code-count check, the insert itself, or 5 straight slug collisions.
GET/codes/{slug}

Fetch one code by slug.

Parameters

NameInTypeRequiredNotes
slugpathstringRequiredCase-sensitive: slugs are always stored uppercase, so pass it exactly as returned.
Request
curl https://www.qrcdn.com/api/v1/codes/8K2QRX \
  -H "Authorization: Bearer qrcdn_live_…"
Response
{
  "slug": "8K2QRX",
  "name": "Storefront flyer",
  "destination": "https://example.com/promo",
  "status": "active",
  "scanCount": 142,
  "expiresAt": null,
  "passwordProtected": false,
  "url": "https://qrcdn.com/8K2QRX",
  "createdAt": "2026-07-01T12:00:00.000Z"
}

Response fields

FieldTypeNotes
slugstringUppercase. The path segment in the code's short URL.
namestringSet at creation. This API has no endpoint to rename a code afterward.
destinationstringWhere a scan currently redirects to.
status"active" | "paused" | "archived"Does not account for expiry: a code past its expiresAt still reports status active here. Compare expiresAt yourself if you need to know whether scans are actually reaching the destination.
scanCountnumberAll-time total, updated by an hourly rollup rather than per scan: a scan from the last few minutes may not be reflected yet. The analytics endpoint's today.scans is live.
expiresAtstring | nullISO-8601 UTC, or null if the code never expires. Once in the past, scans hit the same unavailable page a paused code shows, even while status above still reads active.
passwordProtectedbooleanNever the password or a hash of it: only whether one is set.
urlstringhttps://qrcdn.com/{slug}, lowercase host. Not the uppercase form printed on the QR itself.
createdAtstringISO-8601 UTC.

Errors

StatusCodeWhen
404not_foundThe slug does not exist, or exists but is owned by a different key. See Errors below for why the two cases look identical on purpose.
PATCH/codes/{slug}

Retarget, pause, and/or set a code's expiry.

Parameters

NameInTypeRequiredNotes
slugpathstringRequiredCase-sensitive, same as GET.
destinationbodystringOptionalNew destination. Same URL rules as create.
pausedbodybooleanOptionaltrue pauses the code (a scan hits the same unavailable page an expired code does); false resumes it.
expiresAtbodystring | nullOptionalPro plan only. ISO-8601 timestamp to set the expiry, or null to clear it. Past timestamps are accepted: expiring a code immediately is a legitimate request, not an error.
Request
curl -X PATCH https://www.qrcdn.com/api/v1/codes/8K2QRX \
  -H "Authorization: Bearer qrcdn_live_…" \
  -H "Content-Type: application/json" \
  -d '{"destination": "https://example.com/new-promo"}'
Response
{
  "slug": "8K2QRX",
  "destination": "https://example.com/new-promo",
  "status": "active",
  "expiresAt": null
}

Response fields

FieldTypeNotes
slugstringUppercase. The path segment in the code's short URL.
destinationstringWhere a scan currently redirects to.
status"active" | "paused" | "archived"Does not account for expiry: a code past its expiresAt still reports status active here. Compare expiresAt yourself if you need to know whether scans are actually reaching the destination.
expiresAtstring | nullISO-8601 UTC, or null if the code never expires. Once in the past, scans hit the same unavailable page a paused code shows, even while status above still reads active.

Errors

StatusCodeWhen
422invalid_requestValidation failed: an empty body (message empty_patch: at least one field is required), a bad or unsafe destination (invalid_destination, destination_too_long, destination_unsafe), a non-boolean paused (invalid_paused), or an unparseable expiresAt (invalid_expiry).
403plan_requiredexpiresAt was supplied, but your plan does not include access controls (Pro only).
404not_foundThe slug does not exist, or exists but is owned by a different key. See Errors below for why the two cases look identical on purpose.
500internal_errorThe update itself failed on our end.
GET/codes/{slug}/analytics

Scan analytics for one code: the same data your dashboard renders.

Parameters

NameInTypeRequiredNotes
slugpathstringRequiredCase-sensitive, same as GET.
rangequery7 | 30 | 90 | 365 (days)OptionalDefaults to 30. A value over your plan's retention window clamps down to the largest option that fits (30d free, 365d Pro); malformed or unlisted values also fall back to the default.
Request
curl "https://www.qrcdn.com/api/v1/codes/8K2QRX/analytics?range=30" \
  -H "Authorization: Bearer qrcdn_live_…"
Response
{
  "range": 30,
  "code": {
    "slug": "8K2QRX",
    "name": "Storefront flyer",
    "destination": "https://example.com/promo",
    "status": "active",
    "scanCount": 142,
    "expiresAt": null,
    "passwordProtected": false,
    "url": "https://qrcdn.com/8K2QRX",
    "createdAt": "2026-07-01T12:00:00.000Z"
  },
  "series": [
    { "day": "2026-06-24", "scans": 12, "uniques": 9 }
  ],
  "totals": { "scans": 142 },
  "today": { "scans": 3 },
  "topCountries": [
    { "key": "US", "count": 88 }
  ],
  "topDevices": [
    { "key": "mobile", "count": 120 }
  ],
  "recentEvents": [
    {
      "ts": "2026-07-23T09:10:44.000Z",
      "country": "US",
      "region": "CA",
      "city": "San Francisco",
      "device": "mobile",
      "referer": "https://instagram.com"
    }
  ]
}

Response fields

FieldTypeNotes
rangenumberThe window actually applied, in days, after clamping to your plan's retention window.
codeApiCodeThe same shape GET /codes/{slug} returns: see its fields above.
series[].daystringYYYY-MM-DD, UTC.
series[].scansnumberScans that day.
series[].uniquesnumberA per-day approximation from a salted, daily-rotating IP hash. Meaningful for a single day; not safe to sum across days.
totals.scansnumberSum of series[].scans over the window.
today.scansnumberLive count for the current UTC day, read straight from raw scan events rather than the rollup above (which lags up to an hour behind).
topCountries[].keystringISO-ish country code, or Other once the tail beyond the top 5 is folded together.
topCountries[].countnumberScans attributed to that country over the window.
topDevices[].keystringe.g. mobile, desktop, tablet.
topDevices[].countnumberScans attributed to that device type over the window.
recentEvents[].tsstringISO-8601 UTC.
recentEvents[].countrystring | nullNullable: geo lookups can fail.
recentEvents[].regionstring | nullNullable: geo lookups can fail.
recentEvents[].citystring | nullNullable: geo lookups can fail.
recentEvents[].devicestring | nullNullable when device class can't be parsed from the user agent.
recentEvents[].refererstring | nullNull for a direct scan: there is no referring page to report.

Errors

StatusCodeWhen
404not_foundThe slug does not exist, or exists but is owned by a different key. See Errors below for why the two cases look identical on purpose.
500internal_errorThe analytics query failed on our end.

Access controls

  • A code can carry an expiry, a password, or both: each is independent and optional.
  • Expiry is settable via the API today (expiresAt, PATCH above). Password protection is Studio-only in this release: a plaintext password in an API request body needs its own transport and logging review before it's exposed here, so it isn't yet.
  • A password-protected code's scan lands on an unlock page; a correct password forwards the visitor to the destination.
  • An expired code's scan serves the same unavailable page a paused code does, not the destination. Clearing the expiry brings it straight back: your code never dies.

Errors

Every non-2xx response is { "error": "<code>", "message": "<string>" }. For 422s the message is often the same short code as the error field (e.g. invalid_destination, slug_taken); for other statuses it is a human-readable sentence. A request body that is not valid JSON also 422s as invalid_request, before any field is even looked at.

Every endpoint above can also return these, regardless of its own errors table:

StatusCodeWhen
401unauthorizedA missing/malformed Authorization header, a malformed key, or an unknown/revoked key. All three return this exact status, code, and (mostly) message on purpose: a caller can never learn whether a key ever existed by probing it.
403api_not_availableYour plan does not include API access (the free plan; the API is Pro-only).
429quota_exceededYou are over your plan's monthly request cap (10,000 on Pro). Resets at the start of the next UTC calendar month.
500internal_errorA rare failure in the shared auth/quota pipeline itself, before your request reached a handler. Retry.

By design

A 404 from a code-by-slug endpoint never reveals whether the slug exists under a different account. If it did, a key could enumerate other people's slugs one probe at a time. Ownership and nonexistence look identical from the outside, on purpose.

Rate limits & quotas

  • The API is available on the Pro plan: up to 10,000 accepted requests per key per UTC calendar month. The request that would push you over the cap returns 429 quota_exceeded instead of running.
  • The quota resets at the start of each UTC month. Any authenticated request counts toward it, even one that goes on to fail validation (422) or address a code you do not own (404): the count happens at the authentication layer, before your request is evaluated.
  • There is no burst-level (per-second) rate limiting yet: the monthly cap above is the only ceiling today. Burst limiting arrives with general launch.