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. 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. Create a code
POST a name and a destination. QRCDN assigns the slug, and the code is live immediately.
Requestcurl -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. Print it
The response's
urlis the code's permanent address. Render the QR from the Studio or your own stack, then print it on anything.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. Repoint it
PATCH the same slug with a new destination. Nothing about the printed code changes.
Requestcurl -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/v1Endpoints
/codesList every dynamic code owned by this key.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
| No parameters. This endpoint only requires authentication. | ||||
curl https://www.qrcdn.com/api/v1/codes \
-H "Authorization: Bearer qrcdn_live_…"{
"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
| Field | Type | Notes |
|---|---|---|
| codes | ApiCode[] | Every dynamic code this key's owner has created, newest first. |
| codes[].slug | string | Uppercase. The path segment in the code's short URL. |
| codes[].name | string | Set at creation. This API has no endpoint to rename a code afterward. |
| codes[].destination | string | Where 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[].scanCount | number | All-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[].expiresAt | string | null | ISO-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[].passwordProtected | boolean | Never the password or a hash of it: only whether one is set. |
| codes[].url | string | https://qrcdn.com/{slug}, lowercase host. Not the uppercase form printed on the QR itself. |
| codes[].createdAt | string | ISO-8601 UTC. |
Errors
| Status | Code | When |
|---|---|---|
| 500 | internal_error | The codes query failed on our end. |
/codesCreate a dynamic code.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
| name | body | string | Required | 1 to 60 characters, trimmed. |
| destination | body | string | Required | HTTP or HTTPS URL, up to 2048 characters. Needs a real, dotted hostname: bare IPs and localhost are rejected. |
| style | body | object | Optional | A QrStyle v1 snapshot (dots, eyes, fill, background, logo), the same JSON the Studio's editor exports. Omit it and QRCDN uses its default style. |
| slug | body | string | Optional | Pro 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. |
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"}'// 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
| Field | Type | Notes |
|---|---|---|
| slug | string | Uppercase. The path segment in the code's short URL. |
| name | string | Set at creation. This API has no endpoint to rename a code afterward. |
| destination | string | Where 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. |
| scanCount | number | All-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. |
| expiresAt | string | null | ISO-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. |
| passwordProtected | boolean | Never the password or a hash of it: only whether one is set. |
| url | string | https://qrcdn.com/{slug}, lowercase host. Not the uppercase form printed on the QR itself. |
| createdAt | string | ISO-8601 UTC. |
Errors
| Status | Code | When |
|---|---|---|
| 422 | invalid_request | Validation 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). |
| 403 | code_limit_reached | You are at your plan's dynamic code limit (3 free, 250 Pro). |
| 403 | vanity_slugs_not_available | slug was supplied, but your plan does not include vanity slugs (Pro only). |
| 500 | internal_error | A backend failure: the profile lookup, the code-count check, the insert itself, or 5 straight slug collisions. |
/codes/{slug}Fetch one code by slug.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
| slug | path | string | Required | Case-sensitive: slugs are always stored uppercase, so pass it exactly as returned. |
curl https://www.qrcdn.com/api/v1/codes/8K2QRX \
-H "Authorization: Bearer qrcdn_live_…"{
"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
| Field | Type | Notes |
|---|---|---|
| slug | string | Uppercase. The path segment in the code's short URL. |
| name | string | Set at creation. This API has no endpoint to rename a code afterward. |
| destination | string | Where 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. |
| scanCount | number | All-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. |
| expiresAt | string | null | ISO-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. |
| passwordProtected | boolean | Never the password or a hash of it: only whether one is set. |
| url | string | https://qrcdn.com/{slug}, lowercase host. Not the uppercase form printed on the QR itself. |
| createdAt | string | ISO-8601 UTC. |
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | The 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. |
/codes/{slug}Retarget, pause, and/or set a code's expiry.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
| slug | path | string | Required | Case-sensitive, same as GET. |
| destination | body | string | Optional | New destination. Same URL rules as create. |
| paused | body | boolean | Optional | true pauses the code (a scan hits the same unavailable page an expired code does); false resumes it. |
| expiresAt | body | string | null | Optional | Pro 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. |
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"}'{
"slug": "8K2QRX",
"destination": "https://example.com/new-promo",
"status": "active",
"expiresAt": null
}Response fields
| Field | Type | Notes |
|---|---|---|
| slug | string | Uppercase. The path segment in the code's short URL. |
| destination | string | Where 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. |
| expiresAt | string | null | ISO-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
| Status | Code | When |
|---|---|---|
| 422 | invalid_request | Validation 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). |
| 403 | plan_required | expiresAt was supplied, but your plan does not include access controls (Pro only). |
| 404 | not_found | The 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. |
| 500 | internal_error | The update itself failed on our end. |
/codes/{slug}/analyticsScan analytics for one code: the same data your dashboard renders.
Parameters
| Name | In | Type | Required | Notes |
|---|---|---|---|---|
| slug | path | string | Required | Case-sensitive, same as GET. |
| range | query | 7 | 30 | 90 | 365 (days) | Optional | Defaults 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. |
curl "https://www.qrcdn.com/api/v1/codes/8K2QRX/analytics?range=30" \
-H "Authorization: Bearer qrcdn_live_…"{
"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
| Field | Type | Notes |
|---|---|---|
| range | number | The window actually applied, in days, after clamping to your plan's retention window. |
| code | ApiCode | The same shape GET /codes/{slug} returns: see its fields above. |
| series[].day | string | YYYY-MM-DD, UTC. |
| series[].scans | number | Scans that day. |
| series[].uniques | number | A per-day approximation from a salted, daily-rotating IP hash. Meaningful for a single day; not safe to sum across days. |
| totals.scans | number | Sum of series[].scans over the window. |
| today.scans | number | Live 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[].key | string | ISO-ish country code, or Other once the tail beyond the top 5 is folded together. |
| topCountries[].count | number | Scans attributed to that country over the window. |
| topDevices[].key | string | e.g. mobile, desktop, tablet. |
| topDevices[].count | number | Scans attributed to that device type over the window. |
| recentEvents[].ts | string | ISO-8601 UTC. |
| recentEvents[].country | string | null | Nullable: geo lookups can fail. |
| recentEvents[].region | string | null | Nullable: geo lookups can fail. |
| recentEvents[].city | string | null | Nullable: geo lookups can fail. |
| recentEvents[].device | string | null | Nullable when device class can't be parsed from the user agent. |
| recentEvents[].referer | string | null | Null for a direct scan: there is no referring page to report. |
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | The 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. |
| 500 | internal_error | The 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:
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | A 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. |
| 403 | api_not_available | Your plan does not include API access (the free plan; the API is Pro-only). |
| 429 | quota_exceeded | You are over your plan's monthly request cap (10,000 on Pro). Resets at the start of the next UTC calendar month. |
| 500 | internal_error | A rare failure in the shared auth/quota pipeline itself, before your request reached a handler. Retry. |
By design
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_exceededinstead 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.