Dynamic QRs
List, create, retrieve, update, delete dynamic QRs and pull scan analytics.
Dynamic QRs
Dashboard endpoints for managing dynamic QRs and reading analytics. All routes require a JWT bearer token.
For a conceptual overview, read Static vs dynamic QRs.
Public scan endpoint
GET /q/{short_id}/This is the URL encoded into every dynamic QR. It is not part of the
versioned API: it lives at the root of q.qrstudio.agency so the
encoded URL stays as short as possible.
Response: 302 Found to the live destination_url with
Cache-Control: no-store. Each hit creates a DynamicQrScan audit
row.
If the QR is paused (is_active: false), we redirect to a "QR is
paused" landing page instead of the destination.
List dynamic QRs
GET /api/v1/dynamic/?page=1&page_size=25
Authorization: Bearer <jwt>Pagination defaults: page_size=25, max 100.
Response: 200 OK
{
"count": 137,
"next": "https://api.qrstudio.agency/api/v1/dynamic/?page=2",
"previous": null,
"results": [
{
"id": 42,
"short_id": "aBc12dEf",
"name": "Spring 2026 menu",
"destination_url": "https://example.com/menus/spring-2026",
"variants": [],
"is_active": true,
"scan_count": 1247,
"last_scanned_at": "2026-05-08T13:45:12Z",
"destination_changes_count": 1,
"public_url": "https://q.qrstudio.agency/q/aBc12dEf/",
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-05-01T08:30:00Z"
}
]
}Create a dynamic QR
This route creates the database row only. To create and render in
one call, use POST /api/v1/generate/ with data_type: "dynamic".
POST /api/v1/dynamic/
Authorization: Bearer <jwt>
Content-Type: application/json
{
"name": "Spring 2026 menu",
"destination_url": "https://example.com/menus/spring-2026"
}| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Internal label, never visible to scanners. Max 120 chars. |
destination_url | URL | yes | The URL we 302 redirect to. Editable later. Max 2000 chars. |
Response: 201 Created
{
"id": 42,
"short_id": "aBc12dEf",
"name": "Spring 2026 menu",
"destination_url": "https://example.com/menus/spring-2026",
"variants": [],
"is_active": true,
"scan_count": 0,
"last_scanned_at": null,
"destination_changes_count": 0,
"public_url": "https://q.qrstudio.agency/q/aBc12dEf/",
"created_at": "2026-05-08T14:00:00Z",
"updated_at": "2026-05-08T14:00:00Z"
}403 Forbidden
Plan does not include dynamic QRs (no plan currently does, but kept for completeness) or you reached the active QR cap.
{
"detail": "Plan 'starter' is capped at 10 active dynamic QRs. Disable one or upgrade."
}Retrieve
GET /api/v1/dynamic/{short_id}/
Authorization: Bearer <jwt>Same shape as the list result entries.
404 Not Found
The QR does not exist or does not belong to you.
Update
PATCH /api/v1/dynamic/{short_id}/
Authorization: Bearer <jwt>
Content-Type: application/json
{
"destination_url": "https://example.com/menus/summer-2026"
}| Field | Editable | Notes |
|---|---|---|
name | Internal label | |
destination_url | The redirect target | |
is_active | Pause / kill switch | |
variants | A/B variants array (Pro+) | |
short_id | Read only, generated at creation | |
scan_count | Read only, system maintained |
A/B variants
Pro plan or higher. Pass an array of {url, weight, label}:
{
"variants": [
{ "url": "https://example.com/menu/A", "weight": 50, "label": "A" },
{ "url": "https://example.com/menu/B", "weight": 50, "label": "B" }
]
}- Weight is relative; total must be > 0.
- Maximum 10 variants per QR.
- Empty array (
[]) means "single destination, usedestination_url".
402 Payment Required
Free tier is capped at one lifetime destination edit per dynamic QR. The second edit returns:
{
"detail": "Plan 'free' allows 1 destination edit per dynamic QR. This QR has been edited 1 time(s) already. Upgrade to Starter ($7/mo) or higher for unlimited edits, or buy a credit pack."
}Delete
DELETE /api/v1/dynamic/{short_id}/
Authorization: Bearer <jwt>Returns 204 No Content. The redirect endpoint immediately starts
returning a "QR not found" page for that short id. Existing analytics
rows are preserved for the standard 13 month retention window.
Analytics
GET /api/v1/dynamic/{short_id}/analytics/?days=30
Authorization: Bearer <jwt>| Query | Default | Range |
|---|---|---|
days | 30 | 1 to 365 |
Response: 200 OK
{
"short_id": "aBc12dEf",
"name": "Spring 2026 menu",
"total_scans": 1247,
"scans_in_period": 1247,
"period_days": 30,
"by_day": [
{ "day": "2026-05-07", "count": 12 },
{ "day": "2026-05-08", "count": 31 }
],
"top_referers": [
{ "referer": "(direct)", "count": 980 },
{ "referer": "https://google.com/", "count": 142 }
],
"top_clients": [
{ "client": "iOS", "count": 689 },
{ "client": "Android", "count": 442 }
],
"top_countries": [
{ "country": "CA", "count": 1031 },
{ "country": "US", "count": 178 }
],
"top_cities": [
{ "city": "Montreal, CA", "count": 612 }
],
"top_devices": [
{ "device": "iPhone", "count": 689 },
{ "device": "Android", "count": 442 }
],
"top_os": [
{ "os": "iOS", "count": 689 },
{ "os": "Android", "count": 442 }
],
"top_browsers": [
{ "browser": "Safari", "count": 612 },
{ "browser": "Chrome", "count": 405 }
],
"scan_heatmap_by_hour": [
[0, 0, 1, 0, 0, 0, 1, 4, 12, 18, 22, 27, 31, 35, 33, 29, 24, 20, 18, 14, 10, 6, 3, 1],
[0, 0, 0, 1, 0, 1, 2, 5, 14, 20, 24, 30, 33, 37, 35, 31, 26, 22, 19, 15, 11, 7, 3, 2]
],
"geo_pins": [
{ "lat": 45.5, "lon": -73.6, "city": "Montreal", "country": "CA" }
],
"by_variant": []
}| Field | Description |
|---|---|
total_scans | All-time scans on this QR |
scans_in_period | Scans in the requested window |
by_day | One entry per day in the window (zero-fill not included) |
top_referers | Top 10 by raw Referer header. (direct) for missing |
top_clients | Coarse UA bucket (iOS, Android, macOS, Windows, Linux, Bot, other) |
top_countries | Top 10 by ISO 3166 code from edge headers |
top_cities | Top 10 "City, Country" |
top_devices | Top 10 device families (iPhone, iPad, Pixel, etc.) |
top_os | Top 10 OS families |
top_browsers | Top 10 browser families |
scan_heatmap_by_hour | 7 x 24 grid: weekdays vs hours of day |
geo_pins | Up to 200 deduped (lat, lon) pins for map rendering |
by_variant | Per-variant counts (empty array if no A/B variants) |
The aggregations sample the last 5 000 scans in the period. For QRs above that scale we maintain an additional rolled-up aggregation table; the response shape stays the same.
Custom domains (Agency+)
GET /api/v1/dynamic/custom-domains/
POST /api/v1/dynamic/custom-domains/
GET /api/v1/dynamic/custom-domains/{id}/
DELETE /api/v1/dynamic/custom-domains/{id}/
POST /api/v1/dynamic/custom-domains/{id}/verify/White-label your dynamic QR redirects on your own subdomain
(go.yourbrand.com/q/<short_id>/). Available on Agency and Enterprise.
The verify endpoint runs DNS TXT and CNAME checks. See the dashboard
walkthrough for the full setup.