API for developers
Connect qsy to the systems you already run.
A REST API that speaks JSON. Your POS, booking system or online shop creates short links with QR codes, changes where they point and reads scan counts. The API comes with Business.
For integrators
Built for systems that own a lot of codes.
Picture a restaurant system with one QR code per table across twenty restaurants. This is how it works with qsy:
- One folder and one key per restaurant. A key can be locked to a single folder. It only sees its own codes, so a bug in one integration never reaches the others.
- Your ids, not ours. Give every code your own external_id, such as the table number in the POS. One PUT call creates the code if it is missing and updates it if it exists.
- Safe to retry. Send an Idempotency-Key when you create. If the network drops mid-call, a retry returns the same response instead of a duplicate.
- The printed code stays put. Change the destination as often as you like. The slug never changes, and a deleted slug is never given to another link.
Get started
One key in one header.
Every call goes to https://api.qsy.no/v1. Send your key with each request:
Authorization: Bearer qsy_live_…You create keys under Account, API keys, once the account is on Business. The key is shown once. We only store a SHA-256 fingerprint and the first characters (qsy_live_ plus six more) so you can recognise it in the list. If you lose it, create a new one. A key can expire after 30, 90 or 365 days.
Access per key
You choose what each key can do. New keys start with only read access ticked.
| Scope | Grants |
|---|---|
links:read | Read links and folders, and fetch a link's QR code. |
links:write | Create, update and delete links and folders. |
styles:read | Read saved styles. |
analytics:read | Read scan statistics. |
qr:write | Generate static QR codes (POST /v1/qr). |
Quickstart
From key to printed QR code in four steps.
The examples use curl and read the key from the QSY_API_KEY environment variable.
- 01
Create a key
Under Account, API keys. Tick links:write and analytics:read, and optionally pick a folder to lock the key to. Put the key in your environment:
shell export QSY_API_KEY="qsy_live_…" - 02
Create a link with your own id
external_id ties the code to the table in your system. The Idempotency-Key makes sure a retry after a dropped connection does not create a duplicate. The response includes id, short_url and qr_encoded_url.
curl curl -X POST "https://api.qsy.no/v1/links" \ -H "Authorization: Bearer $QSY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 0e6c4b8a-2f1d-4d7e-9b3a-5c8f1e2d4a6b" \ -d '{ "destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table", "title": "Table 12 menu", "folder": "Bistro Nord/Tables", "external_id": "pos-table-12", "metadata": { "table": 12, "location": "grunerlokka" }, "style_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" }' - 03
Fetch the QR code as SVG
Vector, ready for print. The code points to the short link, so you can change the destination later without reprinting.
curl curl "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f/qr" \ -H "Authorization: Bearer $QSY_API_KEY" \ -o qr.svg - 04
Read the scan counts
Dates are calendar days in Europe/Oslo. If analytics are down, the API answers 503, never zeros.
curl curl "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f/stats?from=2026-10-19&to=2026-10-25" \ -H "Authorization: Bearer $QSY_API_KEY"
Later, change the destination with PATCH /v1/links/{id}, or sync your whole table list with PUT /v1/links/by-external-id/{external_id}.
Concepts
What to know before you build.
Folders and folder-locked keys
Folders organise links: up to 50 per account, five levels deep. A link created without a folder goes into "Via API". With a path such as "Bistro Nord/Tables" each level is found or created, without duplicates. When the limit is reached you get 409 folder_limit_reached, never a silent placement somewhere else.
A key locked to a folder sees that folder as a top-level folder. Links elsewhere do not exist for it (404). New links go into the folder, and the key cannot move or delete its own folder. If you delete the folder, the key is revoked.
Upserts with external_id
external_id is your id: up to 128 characters and unique per account. PUT /v1/links/by-external-id/{external_id} creates the link (201) if it is missing, and updates only the fields you send (200) if it exists. The call is safe to repeat. metadata is a free-form JSON object of up to 4 KB that travels with the link.
curl -X PUT "https://api.qsy.no/v1/links/by-external-id/pos-table-12" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"folder": "Bistro Nord/Tables"
}'Idempotency-Key
Works on POST /v1/links and POST /v1/links/bulk. The same key with the same body within 24 hours returns the stored response, with the header Idempotent-Replayed: true. The same key with a different body returns 409 idempotency_key_reused. Use a new UUID per operation.
Styles and SVG
You make styles in the studio, and GET /v1/styles gives you their ids. Send style_id when you create a link and the style is copied into it. If you change the style later, existing links keep theirs. A qr_config sent directly wins over style_id. QR codes are delivered as SVG. If you need PNG, convert the SVG on your side.
Errors
Every error has the same shape: a stable machine code, an English message and a request_id. The status always belongs to the code. For validation errors, details lists every invalid field. Infrastructure problems return 500 or 503, never 401 or 403.
{
"error": {
"code": "validation_error",
"message": "The request contains invalid fields.",
"details": [
{
"field": "external_id",
"code": "too_big",
"message": "external_id must be 1-128 characters."
}
],
"request_id": "req_5f2c9a0e7b1d4c3a8e6f0b2d"
}
}| Code | Status | Meaning |
|---|---|---|
bad_request | 400 | Invalid JSON, a body that is not an object, an invalid Idempotency-Key, or nothing to update. |
key_expired | 401 | The key has passed its expiry date. Create a new one. |
401 | The key is missing, malformed, unknown or revoked. | |
forbidden | 403 | The account has no API access, the key lacks the scope, or a folder-locked key tried to move or delete its own folder. |
not_found | 404 | Does not exist, belongs to another account, or is outside the key's folder. |
conflict | 409 | The request conflicts with what is stored. |
duplicate_external_id | 409 | Another link on the account already uses this external_id. |
folder_exists | 409 | A folder with the same name already exists there. details.id is that folder. |
folder_limit_reached | 409 | The folder limit is reached. details has limit and used. |
folder_not_empty | 409 | The folder has contents. Send ?force=true to move them up one level. |
idempotency_key_in_use | 409 | A request with the same Idempotency-Key is still running. |
idempotency_key_reused | 409 | The Idempotency-Key was used before with a different body. |
link_limit_reached | 409 | The plan's limit of active links is reached. details has limit and used. |
unsupported_media_type | 415 | Send the body with Content-Type: application/json. |
validation_error | 422 | One or more fields are invalid. details lists them. |
rate_limited | 429 | Too many requests. Wait as many seconds as Retry-After says. |
internal_error | 500 | Our fault. Retry, and send us the request_id if it persists. |
503 | A service we depend on is temporarily down. Retry after Retry-After. |
Rate limits
600 requests per minute per key and 1200 per account, in a sliding window. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time in seconds). On 429, wait as many seconds as Retry-After says.
X-Request-Id: req_5f2c9a0e7b1d4c3a8e6f0b2d
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1793008860When a change is live
Short links are served from the edge, close to whoever scans. Write calls answer with edge_synced: true when the change is live immediately, and false when it will be live within seconds. A brand-new link may then answer "not found" for a brief moment.
Time zone, pagination and CORS
Statistics use calendar days in Europe/Oslo. Timestamps are ISO 8601 in UTC. Lists are paginated with next_cursor until it is null. The API has no CORS. It is built for server-to-server use, so a key must never live in a browser or app.
Limits
The numbers, in one place.
The number of active links follows your plan and is listed on the pricing page. Everything else applies to every account with API access.
| Limit | Value |
|---|---|
| Requests per minute per key | 600 |
| Requests per minute per account | 1,200 |
| Active keys per account | 20 |
| Links per page, default | 50 |
| Links per page, maximum | 100 |
| Operations per bulk call | 100 |
| QR size, smallest (px) | 128 |
| QR size, largest (px) | 2,048 |
| QR size, default (px) | 1,024 |
| Characters in a static QR code | 2,953 |
| Characters in title | 200 |
| Characters in destination_url | 4,096 |
| Characters in external_id | 128 |
| Bytes in metadata (compact JSON) | 4,096 |
| Folders per account | 50 |
| Levels in the folder tree | 5 |
| Characters in a folder name | 60 |
| Hours an Idempotency-Key is remembered | 24 |
| Characters in an Idempotency-Key | 255 |
| Days in the statistics window, default | 30 |
| Countries in the statistics (top) | 5 |
Privacy
Numbers, not people.
Statistics count scans per day, device, source, country and time of day. We never store IP addresses. A country is only shown when there are enough scans that nobody can be singled out, and the API returns no cities. The data stays in the EU.
Reference
Every endpoint.
This reference is generated from the OpenAPI specification, the same file our tests keep in step with the code.
Download the specification (OpenAPI 3.1, JSON)
Links
get/v1/linksList links
Links visible to the key, newest first (created_at desc, id desc), keyset-paginated. Folder-scoped keys only see links in their folder subtree.
Scope links:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
limit | query | integer | no | Page size, 1-100 (default 50). Larger values are clamped to 100; 0, negative or non-numeric → 422. |
cursor | query | string | no | next_cursor from the previous page. A tampered or invalid cursor → 422. |
folder_id | query | string | no | Only links directly in this folder (not subfolders). "null" or empty → root links only. |
external_id | query | string | no | Only the link with this external_id. |
expand | query | "qr_config" | no | Comma-separated. Only qr_config is supported; unknown values → 422. |
curl "https://api.qsy.no/v1/links" \
-H "Authorization: Bearer $QSY_API_KEY"{
"links": [
{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"slug": "k7qm2x",
"short_url": "https://qsy.no/k7qm2x",
"qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z",
"edge_synced": true
}
],
"next_cursor": "MjAyNi0xMC0yNVQxMDowMDowMC4wMDBafDNmNmMyYTll…"
}post/v1/linksCreate a link
Creates a short link with a QR code. Placement: folder_id, else folder (path, created as needed), else the key's scope folder (folder-scoped keys) or "Via API" (account-wide keys). Counts towards the active-link limit of the plan (atomic). Send an Idempotency-Key so a retry never creates a duplicate.
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | 1-255 visible ASCII characters (use a UUID per logical operation). Same key + same body within 24 h → the stored response is replayed with Idempotent-Replayed: true. Same key + different body → 409 idempotency_key_reused; still running → 409 idempotency_key_in_use. Keys are scoped to the API key. |
expand | query | "qr_config" | no | Comma-separated. Only qr_config is supported; unknown values → 422. |
Body · CreateLinkRequest
| Name | Type | Required | Description |
|---|---|---|---|
destination_url | string | yes | http(s) URL (a bare domain gets https://), or a mailto:, tel:, sms:, WIFI: or vCard destination. Checked against Google Safe Browsing. UTM goes straight into the URL and is stored as sent. On redirect, qsy.no rewrites utm_medium=qr (case-insensitive) to utm_medium=qsy.no-shortlink when the shared short link is opened (lowercase path, no ?src=qr); QR scans keep qr, other media and parameters are never changed, and utm_medium is never added. |
title | string | null | no | Human-readable name. Trimmed; empty → null. |
active | boolean | no | Inactive links stop redirecting. Only active links count towards the plan limit. |
use_shortlink | boolean | no | true (default): the QR code encodes the short link, so the destination can be changed after printing. false: the QR code encodes destination_url directly (no redirect, no scan statistics). WIFI: and vCard destinations are always encoded directly. |
qr_config | QrConfig | null | no | Full QR style. Wins over style_id. Stored on the link (replaced as a whole on update). |
style_id | string · uuid | null | no | Copy a saved style from your account into the link. Input only (copy model): later edits to the style do not change existing links, and style_id is never returned. |
folder_id | string · uuid | null | no | Target folder. Must be visible to the key. On update, null moves the link to the account root (account-wide keys only). |
folder | string | null | no | Folder path such as "Bistro Nord/Tables". Each level is found (case-insensitive) or created, relative to the key's root. Max 5 levels, each 1-60 characters. Send folder_id or folder, not both. |
external_id | string | null | no | Your own identifier (POS table id, product id …). Unique per account. On update, null clears it. |
metadata | object | no | Free-form JSON object, at most 4096 bytes as compact JSON. Replaced as a whole on update (send {} to clear). |
curl -X POST "https://api.qsy.no/v1/links" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0e6c4b8a-2f1d-4d7e-9b3a-5c8f1e2d4a6b" \
-d '{
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"folder": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"style_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d"
}'{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"slug": "k7qm2x",
"short_url": "https://qsy.no/k7qm2x",
"qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z",
"edge_synced": true
}post/v1/links/bulkCreate or update links in bulk
Up to 100 operations, run in order with exactly the same rules as POST /links and PATCH /links/{id}. Partial success is normal: the response is 200 and each result has ok true/false. If the request runs short of time, the remaining operations are not started and come back with error.code service_unavailable and details.reason "not_processed"; send those again (with a new Idempotency-Key). Supports Idempotency-Key.
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Idempotency-Key | header | string | no | 1-255 visible ASCII characters (use a UUID per logical operation). Same key + same body within 24 h → the stored response is replayed with Idempotent-Replayed: true. Same key + different body → 409 idempotency_key_reused; still running → 409 idempotency_key_in_use. Keys are scoped to the API key. |
expand | query | "qr_config" | no | Comma-separated. Only qr_config is supported; unknown values → 422. |
Body · BulkRequest
| Name | Type | Required | Description |
|---|---|---|---|
operations | CreateLinkRequest | BulkUpdateItem[] | yes | Without id → create (same rules as POST /links). With id → update (same rules as PATCH /links/{id}). Run in order. |
curl -X POST "https://api.qsy.no/v1/links/bulk" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0e6c4b8a-2f1d-4d7e-9b3a-5c8f1e2d4a6b" \
-d '{
"operations": [
{
"destination_url": "https://bistro-nord.no/meny?t=13",
"external_id": "pos-table-13",
"folder": "Bistro Nord/Tables"
},
{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"destination_url": "https://bistro-nord.no/vinter-meny"
}
]
}'{
"results": [
{
"ok": true,
"index": 0,
"link": {
"id": "5d7f9b1c-3e5a-4c7e-9f1b-3d5f7a9c1e3b",
"slug": "p4rt8w",
"short_url": "https://qsy.no/p4rt8w",
"qr_encoded_url": "HTTPS://QSY.NO/P4RT8W",
"destination_url": "https://bistro-nord.no/meny?t=13",
"title": null,
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-13",
"metadata": {},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z",
"edge_synced": true
}
},
{
"ok": false,
"index": 1,
"error": {
"code": "not_found",
"message": "No link found with this id."
}
}
],
"summary": {
"total": 2,
"succeeded": 1,
"failed": 1
}
}get/v1/links/by-external-id/{external_id}Get a link by external_id
The link with this external_id. A link outside the key's folder → 404.
Scope links:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
external_id | path | string | yes | Your identifier, percent-encoded. Avoid "/" in external ids. |
expand | query | "qr_config" | no | Comma-separated. Only qr_config is supported; unknown values → 422. |
curl "https://api.qsy.no/v1/links/by-external-id/pos-table-12" \
-H "Authorization: Bearer $QSY_API_KEY"{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"slug": "k7qm2x",
"short_url": "https://qsy.no/k7qm2x",
"qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z",
"edge_synced": null
}put/v1/links/by-external-id/{external_id}Create or update a link by external_id (upsert)
Missing → created with the POST /links rules (destination_url required) → 201. Exists → updated with the PATCH rules (only the fields you send change; an empty object changes nothing) → 200. external_id in the body is optional but must match the path. Naturally idempotent, so no Idempotency-Key is needed. If the id is used by a link outside a folder-scoped key's folder → 409 duplicate_external_id.
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
external_id | path | string | yes | Your identifier, percent-encoded. Avoid "/" in external ids. |
Body · UpdateLinkRequest
| Name | Type | Required | Description |
|---|---|---|---|
destination_url | string | no | http(s) URL, or a mailto:, tel:, sms:, WIFI: or vCard destination. UTM goes straight into the URL and is stored as sent. On redirect, qsy.no rewrites utm_medium=qr (case-insensitive) to utm_medium=qsy.no-shortlink when the shared short link is opened (lowercase path, no ?src=qr); QR scans keep qr, other media and parameters are never changed, and utm_medium is never added. |
title | string | null | no | Human-readable name. Trimmed; empty → null. |
active | boolean | no | Inactive links stop redirecting. Only active links count towards the plan limit. |
use_shortlink | boolean | no | true (default): the QR code encodes the short link, so the destination can be changed after printing. false: the QR code encodes destination_url directly (no redirect, no scan statistics). WIFI: and vCard destinations are always encoded directly. |
qr_config | QrConfig | null | no | Full QR style. Wins over style_id. Stored on the link (replaced as a whole on update). |
style_id | string · uuid | null | no | Copy a saved style from your account into the link. Input only (copy model): later edits to the style do not change existing links, and style_id is never returned. |
folder_id | string · uuid | null | no | Target folder. Must be visible to the key. On update, null moves the link to the account root (account-wide keys only). |
folder | string | null | no | Folder path such as "Bistro Nord/Tables". Each level is found (case-insensitive) or created, relative to the key's root. Max 5 levels, each 1-60 characters. Send folder_id or folder, not both. |
external_id | string | null | no | Your own identifier (POS table id, product id …). Unique per account. On update, null clears it. |
metadata | object | no | Free-form JSON object, at most 4096 bytes as compact JSON. Replaced as a whole on update (send {} to clear). |
curl -X PUT "https://api.qsy.no/v1/links/by-external-id/pos-table-12" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"folder": "Bistro Nord/Tables"
}'{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"slug": "k7qm2x",
"short_url": "https://qsy.no/k7qm2x",
"qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z",
"edge_synced": true
}get/v1/links/{id}Get a link
One link. Another account's link, or one outside the key's folder → 404.
Scope links:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Link id. A value that is not a UUID → 404. |
expand | query | "qr_config" | no | Comma-separated. Only qr_config is supported; unknown values → 422. |
curl "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f" \
-H "Authorization: Bearer $QSY_API_KEY"{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"slug": "k7qm2x",
"short_url": "https://qsy.no/k7qm2x",
"qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
"destination_url": "https://bistro-nord.no/meny?utm_source=qr&utm_medium=table",
"title": "Table 12 menu",
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z",
"edge_synced": null
}patch/v1/links/{id}Update a link
Change the destination (the printed QR code keeps working), title, active, use_shortlink, style, folder, external_id or metadata. The slug never changes. Turning use_shortlink on or off changes what the QR code encodes, so printed codes must be reprinted.
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Link id. A value that is not a UUID → 404. |
expand | query | "qr_config" | no | Comma-separated. Only qr_config is supported; unknown values → 422. |
Body · UpdateLinkRequest
| Name | Type | Required | Description |
|---|---|---|---|
destination_url | string | no | http(s) URL, or a mailto:, tel:, sms:, WIFI: or vCard destination. UTM goes straight into the URL and is stored as sent. On redirect, qsy.no rewrites utm_medium=qr (case-insensitive) to utm_medium=qsy.no-shortlink when the shared short link is opened (lowercase path, no ?src=qr); QR scans keep qr, other media and parameters are never changed, and utm_medium is never added. |
title | string | null | no | Human-readable name. Trimmed; empty → null. |
active | boolean | no | Inactive links stop redirecting. Only active links count towards the plan limit. |
use_shortlink | boolean | no | true (default): the QR code encodes the short link, so the destination can be changed after printing. false: the QR code encodes destination_url directly (no redirect, no scan statistics). WIFI: and vCard destinations are always encoded directly. |
qr_config | QrConfig | null | no | Full QR style. Wins over style_id. Stored on the link (replaced as a whole on update). |
style_id | string · uuid | null | no | Copy a saved style from your account into the link. Input only (copy model): later edits to the style do not change existing links, and style_id is never returned. |
folder_id | string · uuid | null | no | Target folder. Must be visible to the key. On update, null moves the link to the account root (account-wide keys only). |
folder | string | null | no | Folder path such as "Bistro Nord/Tables". Each level is found (case-insensitive) or created, relative to the key's root. Max 5 levels, each 1-60 characters. Send folder_id or folder, not both. |
external_id | string | null | no | Your own identifier (POS table id, product id …). Unique per account. On update, null clears it. |
metadata | object | no | Free-form JSON object, at most 4096 bytes as compact JSON. Replaced as a whole on update (send {} to clear). |
curl -X PATCH "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"destination_url": "https://bistro-nord.no/vinter-meny?utm_source=qr"
}'{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"slug": "k7qm2x",
"short_url": "https://qsy.no/k7qm2x",
"qr_encoded_url": "HTTPS://QSY.NO/K7QM2X",
"destination_url": "https://bistro-nord.no/vinter-meny?utm_source=qr",
"title": "Table 12 menu",
"active": true,
"use_shortlink": true,
"folder_id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"folder_path": "Bistro Nord/Tables",
"external_id": "pos-table-12",
"metadata": {
"table": 12,
"location": "grunerlokka"
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:05:00.000Z",
"edge_synced": true
}delete/v1/links/{id}Delete a link
Deletes the link and its scan data. The slug is retired for good: it will never be given to another link, so a printed code can never point somewhere unexpected. Use active=false instead if the code might come back.
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Link id. A value that is not a UUID → 404. |
curl -X DELETE "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f" \
-H "Authorization: Bearer $QSY_API_KEY"{
"id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"deleted": true,
"edge_synced": true
}Folders
get/v1/foldersList folders
All folders visible to the key as a flat list sorted by path. A folder-scoped key sees its folder as a top-level folder (parent_id null).
Scope links:read
curl "https://api.qsy.no/v1/folders" \
-H "Authorization: Bearer $QSY_API_KEY"{
"folders": [
{
"id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
"name": "Bistro Nord",
"parent_id": null,
"path": "Bistro Nord",
"link_count": 2,
"subtree_link_count": 14,
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z"
},
{
"id": "0b2d4f6a-8c1e-4a3b-9d5f-7e9a1c3b5d7f",
"name": "Tables",
"parent_id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
"path": "Bistro Nord/Tables",
"link_count": 12,
"subtree_link_count": 12,
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z"
}
]
}Possible error codes
post/v1/foldersCreate a folder
Max 50 folders per account, nested at most 5 levels. A sibling with the same name (case-insensitive) → 409 folder_exists with its id in details.
Scope links:write
Body · CreateFolderRequest
| Name | Type | Required | Description |
|---|---|---|---|
name | string | yes | Trimmed. Must not contain "/". Unique among siblings (case-insensitive). |
parent_id | string · uuid | null | no | Omitted or null → the key's root. |
curl -X POST "https://api.qsy.no/v1/folders" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Bistro Nord"
}'{
"id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
"name": "Bistro Nord",
"parent_id": null,
"path": "Bistro Nord",
"link_count": 0,
"subtree_link_count": 0,
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z"
}get/v1/folders/{id}Get a folder
One folder with link counts.
Scope links:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Folder id. A value that is not a UUID → 404. |
curl "https://api.qsy.no/v1/folders/7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c" \
-H "Authorization: Bearer $QSY_API_KEY"{
"id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
"name": "Bistro Nord",
"parent_id": null,
"path": "Bistro Nord",
"link_count": 2,
"subtree_link_count": 14,
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z"
}patch/v1/folders/{id}Rename or move a folder
Rename and/or move. A folder-scoped key can rename its own folder but not move it (403).
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Folder id. A value that is not a UUID → 404. |
Body · UpdateFolderRequest
| Name | Type | Required | Description |
|---|---|---|---|
name | string | no | |
parent_id | string · uuid | null | no | Move the folder. null → the key's root. Cannot move into itself or a subfolder. |
curl -X PATCH "https://api.qsy.no/v1/folders/7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Bistro Nord Grünerløkka"
}'{
"id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
"name": "Bistro Nord Grünerløkka",
"parent_id": null,
"path": "Bistro Nord Grünerløkka",
"link_count": 2,
"subtree_link_count": 14,
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:00:00.000Z"
}delete/v1/folders/{id}Delete a folder
An empty folder is deleted. A folder with subfolders or links → 409 folder_not_empty with counts, unless ?force=true: then its contents move up one level and no link is ever deleted. API keys limited to the deleted folder are revoked. A folder-scoped key cannot delete its own folder (403).
Scope links:write
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Folder id. A value that is not a UUID → 404. |
force | query | "true" | "false" | no | "true" moves contents up one level instead of refusing. |
curl -X DELETE "https://api.qsy.no/v1/folders/7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c" \
-H "Authorization: Bearer $QSY_API_KEY"{
"id": "7a1e4c2b-5d3f-4e6a-8b9c-0d1e2f3a4b5c",
"deleted": true,
"contents_moved_to": null
}QR codes
get/v1/links/{id}/qrGet the QR code of a link (SVG)
Renders the link's QR code as SVG. It encodes qr_encoded_url: the short link, or the destination only for use_shortlink=false and WIFI:/vCard links. No query parameter can change what the code points to. Style overrides apply to this rendering only; nothing is stored.
Scope links:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Link id. A value that is not a UUID → 404. |
format | query | "svg" | no | Only svg. |
size | query | integer | no | Pixel size of the SVG coordinate system, clamped to 128-2048 (default 1024; invalid → 1024). |
style_id | query | string · uuid | no | Render with a saved style instead of the link's own. |
qr_config | query | string | no | URL-encoded JSON QrConfig, full or partial (merged over the defaults). Wins over style_id. |
curl "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f/qr" \
-H "Authorization: Bearer $QSY_API_KEY" \
-o qr.svg<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">…</svg>post/v1/qrGenerate a static QR code (SVG)
Encodes data literally: no short link, no tracking, nothing stored. For labels, WiFi and contact cards that must work without a redirect. The code can never be changed after printing.
Scope qr:write
Body · StaticQrRequest
| Name | Type | Required | Description |
|---|---|---|---|
data | string | yes | Encoded literally. You build the WIFI:, BEGIN:VCARD or geo: string yourself. |
type | "url" | "wifi" | "vcard" | "text" | "geo" | yes | Documents the content; it does not change what is encoded. |
qr_config | QrConfig | no | Full style. Wins over style_id. |
style_id | string · uuid | no | A saved style on your account. |
format | "svg" | no | Only SVG. Rasterise it yourself if you need PNG. |
size | integer | no | Out-of-range values are clamped, invalid values fall back to 1024. |
curl -X POST "https://api.qsy.no/v1/qr" \
-H "Authorization: Bearer $QSY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": "WIFI:T:WPA;S:BistroNord-Guest;P:velkommen2026;;",
"type": "wifi",
"style_id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"size": 1024
}' \
-o qr.svg<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">…</svg>Statistics
get/v1/links/{id}/statsGet scan statistics for a link
Aggregated scans for one link. Dates are calendar days in Europe/Oslo. Counters only: no IP addresses, no cities, no personal data. If analytics are unavailable the API answers 503 (never zeros).
Scope analytics:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Link id. A value that is not a UUID → 404. |
from | query | string · date | no | YYYY-MM-DD (Europe/Oslo). Default: 29 days before today (a 30-day window). |
to | query | string · date | no | YYYY-MM-DD (Europe/Oslo), inclusive. Default: today. Must not be before from, and the window can be at most 1096 days. |
curl "https://api.qsy.no/v1/links/3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f/stats?from=2026-10-19&to=2026-10-25" \
-H "Authorization: Bearer $QSY_API_KEY"{
"link_id": "3f6c2a9e-8b1d-4c5e-9a7f-2d4b6e8c1a3f",
"from": "2026-10-19",
"to": "2026-10-25",
"granularity": "day",
"total": 412,
"all_time": 3871,
"series": [
{
"bucket": "2026-10-19",
"count": 48
},
{
"bucket": "2026-10-20",
"count": 51
},
{
"bucket": "2026-10-21",
"count": 44
},
{
"bucket": "2026-10-22",
"count": 57
},
{
"bucket": "2026-10-23",
"count": 79
},
{
"bucket": "2026-10-24",
"count": 91
},
{
"bucket": "2026-10-25",
"count": 42
}
],
"breakdowns": {
"device": {
"mobile": 398,
"desktop": 6,
"tablet": 8,
"unknown": 0
},
"source": {
"qr": 401,
"link": 11,
"unknown": 0
},
"country": [
{
"country": "NO",
"count": 371
},
{
"country": "SE",
"count": 22
},
{
"country": "DE",
"count": 19
}
],
"daypart": {
"night": 9,
"morning": 61,
"day": 158,
"evening": 184
}
}
}Styles
get/v1/stylesList styles
Saved QR styles on the account, newest first. Styles are made in the qsy studio; the API only reads them. Account-level: not limited by folder scope.
Scope styles:read
curl "https://api.qsy.no/v1/styles" \
-H "Authorization: Bearer $QSY_API_KEY"{
"styles": [
{
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"name": "Bistro Nord",
"qr_config": {
"mode": "styled",
"shape": "square",
"errorCorrectionLevel": "M",
"margin": 0,
"dots": {
"type": "extra-rounded",
"color": "#1A1A1A",
"gradient": null
},
"cornersSquare": {
"type": "extra-rounded",
"color": "#1A1A1A",
"gradient": null
},
"cornersDot": {
"type": "dot",
"color": "#C0392B",
"gradient": null
},
"background": {
"color": "#FAFAF7",
"transparent": false,
"gradient": null,
"round": 0
},
"image": null,
"imageOptions": {
"imageSize": 0.4,
"margin": 2,
"mode": "center"
}
},
"created_at": "2026-10-25T10:00:00.000Z"
}
]
}Possible error codes
get/v1/styles/{id}Get a style
One saved style.
Scope styles:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string · uuid | yes | Style id. A value that is not a UUID → 404. |
curl "https://api.qsy.no/v1/styles/9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d" \
-H "Authorization: Bearer $QSY_API_KEY"{
"id": "9c8b7a6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"name": "Bistro Nord",
"qr_config": {
"mode": "styled",
"shape": "square",
"errorCorrectionLevel": "M",
"margin": 0,
"dots": {
"type": "extra-rounded",
"color": "#1A1A1A",
"gradient": null
},
"cornersSquare": {
"type": "extra-rounded",
"color": "#1A1A1A",
"gradient": null
},
"cornersDot": {
"type": "dot",
"color": "#C0392B",
"gradient": null
},
"background": {
"color": "#FAFAF7",
"transparent": false,
"gradient": null,
"round": 0
},
"image": null,
"imageOptions": {
"imageSize": 0.4,
"margin": 2,
"mode": "center"
}
},
"created_at": "2026-10-25T10:00:00.000Z",
"updated_at": "2026-10-25T10:05:00.000Z"
}get/v1/styles/{id}/previewPreview a style (SVG)
Renders a saved style as an SVG preview for style pickers. The code in the image always encodes the fixed sample URL https://qsy.no; no parameter can change what it encodes. Use id "default" for the house default style. To get the code of a real link, use GET /links/{id}/qr.
Scope styles:read
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
id | path | string | yes | Style id (UUID), or "default". |
format | query | "svg" | no | Only svg. |
size | query | integer | no | Pixel size of the SVG coordinate system, clamped to 128-1024 (default 512; invalid → 512). |
curl "https://api.qsy.no/v1/styles/id/preview" \
-H "Authorization: Bearer $QSY_API_KEY" \
-o qr.svg<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">…</svg>The API comes with Business.
With Business you create keys under Account and can start right away. Everything else Business includes is on the pricing page.