LinkToQR.co

Developers

The LinkToQR.co API

A REST API over the same codes you manage in the dashboard. Create trackable and basic codes from your own systems, repoint them when a campaign changes, download the image, and pull scan data into your reporting.

Included with Business at $29/month Β· 10 keys Β· 120 requests/minute Β· no separate API charge

Quickstart

Create a key at Dashboard β†’ API. It is shown once, so save it somewhere safe. Then check it works:

curl "https://www.linktoqr.co/api/v1/me/" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/me/", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/me/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{
  "data": {
    "account_id": "6f1c…",
    "plan": "business",
    "plan_name": "Business",
    "analytics_retention_days": null,
    "usage": {
      "trackable_codes": { "used": 42, "limit": 1000 },
      "basic_codes": { "used": 3, "limit": 10000 }
    },
    "key": { "id": "9a2e…", "scopes": ["read", "write"] },
    "rate_limit": { "requests": 120, "window_seconds": 60 }
  }
}

Every endpoint lives under https://www.linktoqr.co/api/v1.

Authentication

Send your key as a bearer token: Authorization: Bearer ltq_sk_…. Each key is read-only or read-and-write, chosen when you create it; a read-only key answers 403 to anything that would change something. A key can also be given an expiry date.

Keys are stored as a hash, so we cannot recover one for you β€” if a key is lost or leaked, revoke it and create another. Revoking takes effect immediately, so when rotating, create the replacement and deploy it before revoking the old one.

Keep keys on a server

This API sends no CORS headers, which means a browser cannot call it directly β€” deliberately. A key in front-end code is readable by anyone who opens the page, and it can rewrite where your printed codes point.

Conventions

  • Trailing slashes. Canonical URLs end in /. Without one you get a308 to the slashed form, which preserves the method and body and which most HTTP clients follow automatically.
  • JSON in, JSON out. Send Content-Type: application/json on writes. Successful responses wrap the result in data; list endpoints add pagination; the image endpoint returns the image.
  • Partial updates. PATCH changes only the fields you send. Sendingnull for folder_id clears it; omitting it leaves it alone.
  • Numeric and enum parameters are strict. Anywhere a number is accepted (limit, offset, days, size, margin), a value that is not a whole number β€” or is negative β€” is a 400 naming the field. A whole number outside the documented range is clamped to it. Enum values (type, status, sort, format, level) are rejected rather than defaulted, so a typo never silently returns the wrong page or the wrong image.
  • Rate limit. 120 requests per minute per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining andX-RateLimit-Reset (epoch seconds); a 429 also carriesRetry-After.
  • Request ids. Every response carries X-Request-Id, and every error repeats it in the body. Quote it and we can find your exact call.
  • Scoping. A key only ever sees its own account. There is no way to widen it.
  • Caching. Only the image endpoint is cacheable, and only against itsETag β€” everything else is no-store. Images are revalidated every time rather than given a lifetime, so an edited design or a removed custom domain can never leave a stale code in a cache.
  • Idempotency. Pass your own alias when creating a trackable code: a repeat of the same call answers 409 instead of quietly creating a second code.

Errors

Failures use the matching HTTP status and a single, stable shape:

{
  "error": {
    "code": "insufficient_scope",
    "message": "This key is read-only. Create a key with write access to make changes.",
    "doc_url": "https://www.linktoqr.co/developers/#error-insufficient-scope",
    "request_id": "3f0a1c88-…"
  }
}

Branch on error.code, never on the message β€” messages are written for people and get reworded.

StatusCodeWhen
401missing_credentialsNo Authorization header.
401invalid_keyThe key is wrong, revoked or expired. We do not say which.
403insufficient_scopeA read-only key tried to make a change.
403plan_requiredThe account is not on a plan that includes the API.
403account_suspendedThe account is suspended. Printed codes keep redirecting.
403account_closedThe account is closed or scheduled for deletion.
403limit_reachedThe change would exceed the plan's code limit.
404not_foundNo code with that id on this account.
400invalid_requestA field is missing or malformed. The message says which.
405method_not_allowedWrong verb. The Allow header lists the right ones.
413payload_too_largeThe content is bigger than a QR code can hold at that error correction level. The message gives the size and the limit.
409conflictThe alias is taken, or the change is not allowed in the code's current state.
429rate_limitedToo many requests. Retry-After says how long to wait.
500server_errorOur fault. Safe to retry, and quote the request_id if it persists.

The QR code object

{
  "id": "b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
  "name": "Spring menu",
  "type": "dynamic",
  "status": "active",
  "content_type": "link",
  "alias": "spring-menu",
  "short_url": "https://qr.yourbrand.com/spring-menu",
  "destination_url": "https://yourbrand.com/menu/spring/",
  "folder_id": null,
  "tags": ["menu", "2026"],
  "design": { "template": "indigo", "fg": "#4f46e5", "bg": "#ffffff" },
  "image": {
    "png": "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/image/?format=png",
    "svg": "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/image/?format=svg"
  },
  "scan_count": 1284,
  "last_scanned_at": "2026-08-07T18:22:41.000Z",
  "starts_at": null,
  "expires_at": null,
  "password_protected": false,
  "created_at": "2026-03-02T09:11:00.000Z",
  "updated_at": "2026-08-01T14:03:22.000Z"
}

type is dynamic for a trackable code or static for a basic one.content_type says what the code holds β€” link, text,vcard, wifi, email, sms, call,event, or one of the hosted kinds pdf, page andshortlink. Hosted kinds have a destination we generate, so the API will not overwrite it β€” edit their content in the dashboard.

Endpoints

GET/api/v1/me/read

Who this key belongs to, and how much of the plan is left.

The call to make first. Confirms the key works and reports plan limits and current usage.

curl "https://www.linktoqr.co/api/v1/me/" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/me/", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/me/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
GET/api/v1/qr-codes/read

List your codes, newest first.

Filters: type (dynamic|static), status (active|paused|archived), folder_id, tag, search (matches the name). sort is newest, oldest or scans. Paging is limit (1-100, default 25) and offset. Deleted codes are never returned.

curl "https://www.linktoqr.co/api/v1/qr-codes/?type=dynamic&tag=menu&limit=25" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/?type=dynamic&tag=menu&limit=25", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/qr-codes/?type=dynamic&tag=menu&limit=25",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{
  "data": [ { "id": "…", "name": "Spring menu", "…": "…" } ],
  "pagination": { "limit": 25, "offset": 0, "total": 42, "has_more": true }
}
POST/api/v1/qr-codes/write

Create a trackable code, a basic code, or a short link.

type defaults to dynamic (trackable). name is optional β€” we derive one from the destination. alias is generated unless you pass one; a taken alias answers 409. Pass design to style it, tags to organise it, short_link: true for a link with no QR image. Add ?include=svg to get the rendered SVG back in the same response.

curl -X POST "https://www.linktoqr.co/api/v1/qr-codes/?include=svg" \
  -H "Authorization: Bearer $LTQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Spring menu","destination_url":"https://yourbrand.com/menu/spring/","alias":"spring-menu","tags":["menu"],"design":{"template":"indigo","frame":"scan-bottom","frameText":"SCAN FOR MENU"}}'
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/?include=svg", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "name": "Spring menu",
    "destination_url": "https://yourbrand.com/menu/spring/",
    "alias": "spring-menu",
    "tags": [
      "menu"
    ],
    "design": {
      "template": "indigo",
      "frame": "scan-bottom",
      "frameText": "SCAN FOR MENU"
    }
  })
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.post(
    "https://www.linktoqr.co/api/v1/qr-codes/?include=svg",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
    json={
        "name": "Spring menu",
        "destination_url": "https://yourbrand.com/menu/spring/",
        "alias": "spring-menu",
        "tags": [
            "menu"
        ],
        "design": {
            "template": "indigo",
            "frame": "scan-bottom",
            "frameText": "SCAN FOR MENU"
        }
    },
)
response.raise_for_status()
data = response.json()["data"]
HTTP/1.1 201 Created

{ "data": { "id": "…", "short_url": "https://qr.yourbrand.com/spring-menu", "svg": "<svg …>", "…": "…" } }
POST/api/v1/qr-codes/write

Create a basic code for contact details, WiFi, text and more.

Set type to static and content_type to one of link, text, email, sms, call, vcard, event or wifi, then send that type's fields. The content is encoded into the image itself, so it cannot be changed afterwards β€” and it needs no redirect, no scan tracking and no internet at scan time. GET /templates/ lists the fields each type takes.

curl -X POST "https://www.linktoqr.co/api/v1/qr-codes/" \
  -H "Authorization: Bearer $LTQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"type":"static","content_type":"vcard","name":"Sales card","vcard_first":"Amara","vcard_last":"Okafor","vcard_org":"Northwind","vcard_email":"amara@northwind.example","vcard_phone":"+15551234567"}'
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "type": "static",
    "content_type": "vcard",
    "name": "Sales card",
    "vcard_first": "Amara",
    "vcard_last": "Okafor",
    "vcard_org": "Northwind",
    "vcard_email": "amara@northwind.example",
    "vcard_phone": "+15551234567"
  })
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.post(
    "https://www.linktoqr.co/api/v1/qr-codes/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
    json={
        "type": "static",
        "content_type": "vcard",
        "name": "Sales card",
        "vcard_first": "Amara",
        "vcard_last": "Okafor",
        "vcard_org": "Northwind",
        "vcard_email": "amara@northwind.example",
        "vcard_phone": "+15551234567"
    },
)
response.raise_for_status()
data = response.json()["data"]
HTTP/1.1 201 Created

{ "data": { "id": "…", "type": "static", "content_type": "vcard", "…": "…" } }
POST/api/v1/qr-codes/bulk/write

Create up to 1000 trackable codes in one call.

One bad row does not sink the batch: rows that fail come back with their index and the reason, and everything else is created. Bounded by the trackable codes left on your plan, checked before anything is written.

curl -X POST "https://www.linktoqr.co/api/v1/qr-codes/bulk/" \
  -H "Authorization: Bearer $LTQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"codes":[{"destination_url":"https://yourbrand.com/store/1/","name":"Store 1"},{"destination_url":"https://yourbrand.com/store/2/","name":"Store 2","alias":"store-2"}],"tags":["stores"]}'
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/bulk/", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "codes": [
      {
        "destination_url": "https://yourbrand.com/store/1/",
        "name": "Store 1"
      },
      {
        "destination_url": "https://yourbrand.com/store/2/",
        "name": "Store 2",
        "alias": "store-2"
      }
    ],
    "tags": [
      "stores"
    ]
  })
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.post(
    "https://www.linktoqr.co/api/v1/qr-codes/bulk/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
    json={
        "codes": [
            {
                "destination_url": "https://yourbrand.com/store/1/",
                "name": "Store 1"
            },
            {
                "destination_url": "https://yourbrand.com/store/2/",
                "name": "Store 2",
                "alias": "store-2"
            }
        ],
        "tags": [
            "stores"
        ]
    },
)
response.raise_for_status()
data = response.json()["data"]
{ "data": { "created": 2, "failed": 0, "errors": [] } }
GET/api/v1/qr-codes/{id}/read

Retrieve one code.

An id you do not own answers 404, the same as one that does not exist. ?include=svg adds the rendered SVG.

curl "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{ "data": { "id": "b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "…": "…" } }
PATCH/api/v1/qr-codes/{id}/write

Repoint, rename, restyle, retag or pause a code.

Every field is optional and only what you send is changed: name, destination_url, status (active|paused|archived), folder_id (null clears it), tags (replaces the set), design. Basic codes accept everything except destination_url and status.

curl -X PATCH "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/" \
  -H "Authorization: Bearer $LTQ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"destination_url":"https://yourbrand.com/menu/summer/","tags":["menu","summer"]}'
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    "destination_url": "https://yourbrand.com/menu/summer/",
    "tags": [
      "menu",
      "summer"
    ]
  })
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.patch(
    "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
    json={
        "destination_url": "https://yourbrand.com/menu/summer/",
        "tags": [
            "menu",
            "summer"
        ]
    },
)
response.raise_for_status()
data = response.json()["data"]
{ "data": { "id": "b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "destination_url": "https://yourbrand.com/menu/summer/", "…": "…" } }
DELETE/api/v1/qr-codes/{id}/write

Delete a code.

The code stops redirecting and frees a slot on your plan. Its short link is never reissued to anyone else, so a printed one fails closed rather than landing on a stranger's page.

curl -X DELETE "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.delete(
    "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{ "data": { "id": "b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "deleted": true } }
GET/api/v1/qr-codes/{id}/image/read

Download the code as PNG or SVG.

format (png|svg), size (64-2048, default 512), margin (0-10, default 2), level (L|M|Q|H) and one-off fg / bg hex overrides that do not change the saved design. Returns the image itself β€” a plain, contrast-corrected rendering; logos, styled modules and frames are applied by the dashboard designer. Send a previous ETag as If-None-Match to get a 304 instead of the bytes. A size or margin that is not a whole number is a 400; one outside the range is clamped. Unknown query parameters are rejected. For anything printed, pass margin=4: that is the quiet zone the QR specification calls for, and below it some scanners fail. The default stays 2 so existing integrations keep getting the same image, and becomes 4 in the next API version.

curl "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/image/?format=png&size=1024&level=Q" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/image/?format=png&size=1024&level=Q", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/image/?format=png&size=1024&level=Q",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 21135
Cache-Control: private, max-age=0, must-revalidate
ETag: "Zr8kQ1s7yv3pM0aB2cD4eF6"
X-QR-Contrast-Adjusted: false

# then, on the next request:
curl -H "If-None-Match: \"Zr8kQ1s7yv3pM0aB2cD4eF6\"" …   ->   HTTP/1.1 304 Not Modified
GET/api/v1/qr-codes/{id}/scans/read

Scan totals, a daily series and every breakdown.

days is 1-365 (default 30), clamped to your plan's analytics history β€” when that happens the range says clamped_by_plan. Older scans are never deleted, so an upgrade brings them back.

curl "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/scans/?days=7" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/scans/?days=7", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/scans/?days=7",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{
  "data": {
    "qr_code_id": "b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
    "total_scans": 1284,
    "range": { "from": "2026-08-01", "to": "2026-08-07", "days": 7,
               "clamped_by_plan": false, "retention_days": null },
    "scans": 96, "unique_scans": 71,
    "daily": [ { "date": "2026-08-01", "scans": 12, "unique_scans": 9 } ],
    "countries": [ { "value": "US", "scans": 61, "unique_scans": 44 } ],
    "devices": [ { "value": "mobile", "scans": 88, "unique_scans": 65 } ],
    "sources": [], "browsers": [], "operating_systems": []
  }
}
GET/api/v1/qr-codes/{id}/activity/read

Individual scan events, newest first.

For reconciling a spike or feeding a warehouse. limit is 1-200 (default 50), with offset and the same days window as /scans/. Hashed IP and user-agent values are deliberately never returned.

curl "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/activity/?limit=50" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/activity/?limit=50", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/qr-codes/b21c9f4e-1a2b-4c3d-8e4f-5a6b7c8d9e0f/activity/?limit=50",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{
  "data": [
    { "id": "…", "scanned_at": "2026-08-07T18:22:41.000Z", "country": "US", "city": "Austin",
      "device": "mobile", "browser": "Safari", "os": "iOS", "referrer": null,
      "utm_source": null, "utm_medium": null, "utm_campaign": null }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1284, "has_more": true }
}
GET/api/v1/analytics/read

Account-wide reporting in one call.

The same numbers as your analytics dashboard: totals, a daily series, country / device / source / browser / OS breakdowns, and your top codes. Same days window and plan clamping as per-code scans.

curl "https://www.linktoqr.co/api/v1/analytics/?days=30" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/analytics/?days=30", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/analytics/?days=30",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{
  "data": {
    "range": { "…": "…" }, "total_scans": 48210, "scans": 3120, "unique_scans": 2410, "codes": 42,
    "daily": [ … ], "countries": [ … ], "devices": [ … ],
    "top_codes": [ { "id": "…", "name": "Spring menu", "scan_count": 1284 } ]
  }
}
GET/api/v1/folders/read

List your folders.

So you can pass folder_id when creating or updating a code without copying an id out of a dashboard URL.

curl "https://www.linktoqr.co/api/v1/folders/" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/folders/", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/folders/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{ "data": [ { "id": "…", "name": "Restaurants", "parent_folder_id": null, "created_at": "…" } ] }
GET/api/v1/templates/read

Design templates, frames, content types and image options.

Everything the API accepts as design or content input, generated from the same constants the app renders from. Currently 8 templates. Build a picker from this rather than hard-coding the list.

curl "https://www.linktoqr.co/api/v1/templates/" \
  -H "Authorization: Bearer $LTQ_KEY"
const response = await fetch("https://www.linktoqr.co/api/v1/templates/", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.LTQ_KEY}`
  }
});

if (!response.ok) throw new Error((await response.json()).error.message);
const { data } = await response.json();
import os, requests

response = requests.get(
    "https://www.linktoqr.co/api/v1/templates/",
    headers={"Authorization": f"Bearer {os.environ['LTQ_KEY']}"},
)
response.raise_for_status()
data = response.json()["data"]
{
  "data": {
    "templates": [ { "id": "classic", "label": "Classic", "fg": "#0f172a", "bg": "#ffffff" } ],
    "frames": [ { "id": "scan-bottom", "label": "Label below" } ],
    "content_types": [ { "id": "vcard", "fields": ["vcard_first", "…"] } ],
    "image": { "formats": ["png", "svg"], "error_correction_levels": ["L","M","Q","H"],
               "renders_frames_and_logos": false }
  }
}

What the API will not do

These are product guarantees, not gaps. They are the same rules the dashboard follows, and the API is held to them too.

  • It will not change a code's short link. There is no way to rename an alias through the API, because an alias is what is already printed on someone's menu or packaging. Point the code somewhere new instead β€” that is what a trackable code is for.
  • It will not break a printed code by accident. Deleting is explicit and never reissues the link to another account.
  • It will not hand you an unscannable image. Colours are contrast-corrected at render time, every time, including colours you pass in yourself. The response headerX-QR-Contrast-Adjusted tells you when that happened.
  • It will not reactivate a code an administrator disabled. That answers409, with the reason.
  • It will not stop your codes redirecting. Not on downgrade, not on suspension, not when a key expires. Only the API stops; the printed code keeps working.

Versioning

This is v1. Breaking changes β€” removing a field, changing a type, tightening validation β€” arrive as a new version prefix, never in place. New optional fields and new endpoints can appear withinv1, so parse defensively and ignore what you do not recognise.

Planned change: unknown query parameters

Query parameters we do not recognise are currently ignored on every endpoint except/qr-codes/{id}/image/, which already rejects them. A future release will reject them more widely, so that a typo like ?limitt=25 tells you rather than quietly returning the first page. The exact behaviour β€” including which parameters stay silently ignored β€” will be announced here before it changes. Nothing changes today.

Webhooks for scan events are the next thing on this API's roadmap. If that is what would make this work for you, say so β€” it moves things up the list.