ScanMosaic
REST API · v1

Build QR codes and short links into your app

Create dynamic QR codes, short links and barcodes, change where they point, read their scan stats and render print-ready QR images, all from your own code. JSON over HTTPS, one API key, plain errors.

Base URL
https://scanmosaic.com/api/v1
Auth
Authorization: Bearer tsk_…
Plan
Business

Quick start

  1. Open API keys in your dashboard (Business plan) and create a key. Copy it: it's shown once.
  2. Store it as an environment variable on your server, e.g. TESSERA_API_KEY. Never put it in browser or app code.
  3. Create a dynamic QR code. The response has its short link in url; encode that link, or download a ready image from /qr.

cURL

curl "https://scanmosaic.com/api/v1/items" \
  -H "Authorization: Bearer $TESSERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind": "qr", "name": "Table menu", "dest": "https://example.com/menu"}'

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "kind": "qr",
    "name": "Table menu",
    "dest": "https://example.com/menu"
  })
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.post(
    "https://scanmosaic.com/api/v1/items",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
    json={
    "kind": "qr",
    "name": "Table menu",
    "dest": "https://example.com/menu"
},
)
r.raise_for_status()
print(r.json()["data"])

cURL

curl "https://scanmosaic.com/api/v1/qr?item=k3Xp9Qa&size=1024" \
  -H "Authorization: Bearer $TESSERA_API_KEY" \
  -o menu.png

JavaScript

import { writeFile } from "node:fs/promises";

const res = await fetch("https://scanmosaic.com/api/v1/qr?item=k3Xp9Qa&size=1024", {
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("menu.png", Buffer.from(await res.arrayBuffer()));

Python

import os, requests

r = requests.get(
    "https://scanmosaic.com/api/v1/qr?item=k3Xp9Qa&size=1024",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
open("menu.png", "wb").write(r.content)

Authentication

Send your key in the Authorization header of every request. Keys start with tsk_ and belong to one workspace: they can read and change everything an editor of that workspace can, and nothing in other workspaces.

Authorization: Bearer tsk_AbC123…
  • Owners and admins create and delete keys under API keys in the dashboard. Deleting a key stops it straight away.
  • The API is part of the Business plan. If the plan ends, requests get 402 business_required until you renew; your codes and data are kept.
  • Changes made with a key appear in the workspace's activity log as "API key tsk_…".
  • CORS is open so you can call the API from any origin, but a key in a web page can be copied by anyone who opens it. Call the API from your server.

Errors

Every error uses the same JSON shape and a matching HTTP status. code is stable: build on it. message is plain English for logs and people. Some errors add details, such as param.

{
  "error": {
    "code": "bad_param",
    "message": "One of the parameters isn't valid.",
    "status": 400,
    "param": "size",
    "hint": "64 to 2048"
  }
}
Error codes
StatusCodeMeaning
400bad_jsonThe body isn't valid JSON.
400bad_paramA query or body parameter is out of range. details: param, hint.
400bad_destinationdest isn't a full http(s) web address.
400bad_kindkind isn't qr, link or barcode.
400bad_aliasalias isn't 3–40 letters, numbers or dashes, or it's reserved.
400domain_not_verifieddomain isn't a verified domain of your workspace.
400unsafe_destinationGoogle Web Risk flags the destination.
400empty / too_long / bad_formatQR images: no data, too much data, or a format other than png or svg.
400bad_cursorThe cursor isn't one we gave you.
401api_key_requiredNo Authorization header, or it isn't Bearer tsk_…
401invalid_api_keyThe key doesn't exist or was deleted.
402business_requiredThe workspace isn't on the Business plan (for example, it ended).
403limit_reachedYour plan's limit for that kind of item is reached. details: limit, kind.
404not_foundNo such item in this workspace.
409alias_takenThat alias is already used on that domain.
413too_bigThe body is too large (250 KB for items).
429rate_limited / daily_limitToo many requests. Wait for Retry-After seconds.
500server_errorSomething failed on our side. Retry with backoff.

Rate limits

Each key can make 120 requests a minute and 20,000 a day. Every response tells you where you stand:

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute.
X-RateLimit-RemainingRequests left in the current minute.
X-RateLimit-ResetUnix time when the minute resets.
Retry-AfterOn 429 only: seconds to wait before trying again.

Creating items is also limited to 300 an hour per workspace, and your plan sets how many items you can keep. Need more? Contact us.

Pagination

Lists return newest first, up to limit items (default 25, at most 100). When has_more is true, pass next_cursor as cursor to get the next page. Cursors stay valid when items are added.

{
  "data": [
    "\u2026"
  ],
  "has_more": true,
  "next_cursor": "MTc2NzIyNTYwMC5rM1hwOVFh"
}

The item object

QR codes, links and barcodes are all items. Single items come back as data; lists as data with paging fields.

Item fields
NameTypeDescription
idstringShort id, also the default ending of the short link.
kindqr | link | barcodeWhat it is. Can't change after creation.
typestringContent type, e.g. url (default), pdf, menu, vcard for QR codes; ean13, code128 for barcodes.
namestringYour label, up to 80 characters.
deststring | nullWhere scans and clicks go. null for hosted pages and barcodes.
urlstringThe short link that the QR code encodes, e.g. https://go.yourbrand.com/menu.
aliasstring | nullCustom ending (3–40 letters, numbers or dashes), unique per domain.
domainstring | nullYour verified custom domain, or null for ours.
campaignstring | nullGroup items for reporting.
contentobject | nullHosted page data, or barcode data ({ "data": "…" }).
styleobjectDesign of the QR code (colours, dot and corner styles).
rulesobject | nullRouting rules (by device, country, time…).
pausedbooleanPaused items show a 'paused' page instead of redirecting.
disabledbooleanDisabled by us for breaking the terms.
hitsintegerAll-time scans (QR codes) or clicks (links).
last_hitinteger | nullUnix time of the last scan or click.
created / updatedintegerUnix times.

Endpoints

GET /api/v1/me

Account

The workspace this key belongs to, its plan, limits, usage, verified domains and rate limits. Handy to check a key works.

cURL

curl "https://scanmosaic.com/api/v1/me" \
  -H "Authorization: Bearer $TESSERA_API_KEY"

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/me", {
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.get(
    "https://scanmosaic.com/api/v1/me",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": {
    "workspace": {
      "id": "w_8fK2mQ",
      "name": "Acme"
    },
    "plan": "business",
    "key": {
      "prefix": "tsk_AbC123",
      "name": "Shopify sync"
    },
    "limits": {
      "qr": 1000,
      "link": 5000,
      "barcode": 10000,
      "domains": 5
    },
    "usage": {
      "dynamic": 12,
      "links": 40,
      "barcodes": 3,
      "storageBytes": 0,
      "workspaces": 1
    },
    "domains": [
      "go.yourbrand.com"
    ],
    "rate_limits": {
      "per_minute": 120,
      "per_day": 20000
    }
  }
}

GET /api/v1/items

List items

Items in the workspace, newest first.

Parameters
NameTypeDescription
kindqr | link | barcodeOnly this kind.
typestringOnly this content type, e.g. url or pdf.
campaignstringOnly this campaign.
domainstringOnly items on this custom domain.
pausedtrue | falseOnly paused, or only live, items.
qstringSearch name, destination and alias.
limitinteger1–100, default 25.
cursorstringnext_cursor from the previous page.

cURL

curl "https://scanmosaic.com/api/v1/items?kind=link&campaign=spring&limit=50" \
  -H "Authorization: Bearer $TESSERA_API_KEY"

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items?kind=link&campaign=spring&limit=50", {
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.get(
    "https://scanmosaic.com/api/v1/items?kind=link&campaign=spring&limit=50",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": [
    {
      "id": "k3Xp9Qa",
      "kind": "link",
      "type": "url",
      "name": "Spring menu",
      "dest": "https://example.com/menu",
      "content": null,
      "style": {},
      "rules": null,
      "alias": "menu",
      "domain": "go.yourbrand.com",
      "campaign": "spring",
      "og": null,
      "created": 1767225600,
      "updated": 1767225600,
      "paused": false,
      "disabled": false,
      "hits": 42,
      "last_hit": 1767312000,
      "url": "https://go.yourbrand.com/menu"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

POST /api/v1/items

Create an item

Makes a dynamic QR code, short link or saved barcode. Counts towards your plan's limits.

Parameters
NameTypeDescription
kindqr | link | barcodeDefault qr.
dest requiredstringFull http(s) address to redirect to. Not used for barcodes and hosted pages.
namestringUp to 80 characters.
typestringDefault url (codes, links) or code128 (barcodes).
aliasstringCustom ending, e.g. menu. 3–40 letters, numbers or dashes, unique per domain.
domainstringOne of your verified custom domains (see /domains).
campaignstringUp to 60 characters.
styleobjectQR design, e.g. {"fg": "#112233", "dot": "rounded"}.
contentobjectHosted page or barcode data, e.g. {"bcid": "ean13", "text": "4006381333931"} for a barcode.
rulesobjectRouting rules.
pausedbooleanStart paused.

cURL

curl "https://scanmosaic.com/api/v1/items" \
  -H "Authorization: Bearer $TESSERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind": "link", "name": "Spring menu", "dest": "https://example.com/menu", "alias": "menu", "domain": "go.yourbrand.com", "campaign": "spring"}'

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "kind": "link",
    "name": "Spring menu",
    "dest": "https://example.com/menu",
    "alias": "menu",
    "domain": "go.yourbrand.com",
    "campaign": "spring"
  })
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.post(
    "https://scanmosaic.com/api/v1/items",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
    json={
    "kind": "link",
    "name": "Spring menu",
    "dest": "https://example.com/menu",
    "alias": "menu",
    "domain": "go.yourbrand.com",
    "campaign": "spring"
},
)
r.raise_for_status()
print(r.json()["data"])
Response 201 Created
{
  "data": {
    "id": "k3Xp9Qa",
    "kind": "link",
    "type": "url",
    "name": "Spring menu",
    "dest": "https://example.com/menu",
    "content": null,
    "style": {},
    "rules": null,
    "alias": "menu",
    "domain": "go.yourbrand.com",
    "campaign": "spring",
    "og": null,
    "created": 1767225600,
    "updated": 1767225600,
    "paused": false,
    "disabled": false,
    "hits": 42,
    "last_hit": 1767312000,
    "url": "https://go.yourbrand.com/menu"
  }
}

GET /api/v1/items/{id}

Get an item

One item by id.

cURL

curl "https://scanmosaic.com/api/v1/items/k3Xp9Qa" \
  -H "Authorization: Bearer $TESSERA_API_KEY"

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items/k3Xp9Qa", {
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.get(
    "https://scanmosaic.com/api/v1/items/k3Xp9Qa",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": {
    "id": "k3Xp9Qa",
    "kind": "link",
    "type": "url",
    "name": "Spring menu",
    "dest": "https://example.com/menu",
    "content": null,
    "style": {},
    "rules": null,
    "alias": "menu",
    "domain": "go.yourbrand.com",
    "campaign": "spring",
    "og": null,
    "created": 1767225600,
    "updated": 1767225600,
    "paused": false,
    "disabled": false,
    "hits": 42,
    "last_hit": 1767312000,
    "url": "https://go.yourbrand.com/menu"
  }
}

PATCH /api/v1/items/{id}

Update an item

Send only the fields to change. Changing dest takes effect on the next scan; printed codes keep working. Changing domain or alias changes the short link, so codes already printed stop working.

Parameters
NameTypeDescription
name, dest, alias, domain, campaign, style, content, rules, pausedAs in Create. kind can't be changed. Send null to clear alias, domain, campaign or rules.

cURL

curl -X PATCH "https://scanmosaic.com/api/v1/items/k3Xp9Qa" \
  -H "Authorization: Bearer $TESSERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"dest": "https://example.com/summer-menu", "paused": false}'

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items/k3Xp9Qa", {
  method: "PATCH",
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "dest": "https://example.com/summer-menu",
    "paused": false
  })
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.patch(
    "https://scanmosaic.com/api/v1/items/k3Xp9Qa",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
    json={
    "dest": "https://example.com/summer-menu",
    "paused": False
},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": {
    "id": "k3Xp9Qa",
    "kind": "link",
    "type": "url",
    "name": "Spring menu",
    "dest": "https://example.com/summer-menu",
    "content": null,
    "style": {},
    "rules": null,
    "alias": "menu",
    "domain": "go.yourbrand.com",
    "campaign": "spring",
    "og": null,
    "created": 1767225600,
    "updated": 1767225600,
    "paused": false,
    "disabled": false,
    "hits": 42,
    "last_hit": 1767312000,
    "url": "https://go.yourbrand.com/menu"
  }
}

DELETE /api/v1/items/{id}

Delete an item

Deletes the item and its scan history. Its short link stops working at once. This can't be undone.

cURL

curl -X DELETE "https://scanmosaic.com/api/v1/items/k3Xp9Qa" \
  -H "Authorization: Bearer $TESSERA_API_KEY"

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items/k3Xp9Qa", {
  method: "DELETE",
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.delete(
    "https://scanmosaic.com/api/v1/items/k3Xp9Qa",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": {
    "id": "k3Xp9Qa",
    "deleted": true
  }
}

GET /api/v1/items/{id}/stats

Item stats

Scans or clicks over a period: a daily series and breakdowns by country, device, operating system, browser, language and routing rule. Bots are not counted.

Parameters
NameTypeDescription
daysinteger1–365, default 30.

cURL

curl "https://scanmosaic.com/api/v1/items/k3Xp9Qa/stats?days=7" \
  -H "Authorization: Bearer $TESSERA_API_KEY"

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/items/k3Xp9Qa/stats?days=7", {
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.get(
    "https://scanmosaic.com/api/v1/items/k3Xp9Qa/stats?days=7",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": {
    "days": 7,
    "total": 42,
    "last_hit": 1767312000,
    "inRange": 12,
    "series": [
      [
        "2026-01-01",
        3
      ],
      [
        "2026-01-02",
        0
      ],
      [
        "…",
        0
      ]
    ],
    "countries": [
      {
        "k": "GB",
        "n": 7
      },
      {
        "k": "US",
        "n": 5
      }
    ],
    "devices": [
      {
        "k": "Mobile",
        "n": 10
      },
      {
        "k": "Desktop",
        "n": 2
      }
    ],
    "os": [
      {
        "k": "iOS",
        "n": 6
      }
    ],
    "browsers": [
      {
        "k": "Safari",
        "n": 6
      }
    ],
    "langs": [
      {
        "k": "en-gb",
        "n": 7
      }
    ],
    "rules": [
      {
        "k": "default",
        "n": 12
      }
    ]
  }
}

GET /api/v1/qr

QR code image

Renders a QR code as PNG or SVG. Use GET with query parameters, or POST the same parameters as JSON. Pass item to draw a saved code with its own design and short link, or data for any text.

Parameters
NameTypeDescription
datastringText or link to encode (up to 2,953 bytes). Required unless item is set.
itemstringId of a saved QR code or link: encodes its short link with its saved design.
formatpng | svgDefault png.
sizeintegerPixels, 64–2048 for PNG (default 512), 64–4096 for SVG (default 1024).
color#RRGGBBDots. Default #141A2E.
background#RRGGBBDefault #FFFFFF.
transparentbooleanTransparent background.
corner_color#RRGGBBCorner squares. Default: color.
gradient_color#RRGGBBSVG only: second colour of a diagonal gradient.
error_correctionL | M | Q | HHow much damage the code survives: 7%, 15%, 25%, 30%. Default M (PNG) or Q (SVG).
marginintegerQuiet zone in modules, 0–8. Default 4.
dot_stylestringsquare, dots, rounded, extra-rounded, classy, classy-rounded
corner_stylestringsquare, extra-rounded, dot
corner_dot_stylestringsquare, dot
downloadstringFile name: adds Content-Disposition: attachment.

PNGs are drawn on our servers with whole pixels per module, so edges stay sharp for print. Gradients and logos are SVG-only; for logos, use the generator.

cURL

curl "https://scanmosaic.com/api/v1/qr" \
  -H "Authorization: Bearer $TESSERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": "https://example.com", "format": "png", "size": 1024, "color": "#4865F0", "dot_style": "rounded", "corner_style": "extra-rounded"}' \
  -o qr.png

JavaScript

import { writeFile } from "node:fs/promises";

const res = await fetch("https://scanmosaic.com/api/v1/qr", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    "data": "https://example.com",
    "format": "png",
    "size": 1024,
    "color": "#4865F0",
    "dot_style": "rounded",
    "corner_style": "extra-rounded"
  })
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("qr.png", Buffer.from(await res.arrayBuffer()));

Python

import os, requests

r = requests.post(
    "https://scanmosaic.com/api/v1/qr",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
    json={
    "data": "https://example.com",
    "format": "png",
    "size": 1024,
    "color": "#4865F0",
    "dot_style": "rounded",
    "corner_style": "extra-rounded"
},
)
r.raise_for_status()
open("qr.png", "wb").write(r.content)

GET /api/v1/domains

Domains

The custom domains of the workspace. Use a host whose ready is true as domain when you create items.

cURL

curl "https://scanmosaic.com/api/v1/domains" \
  -H "Authorization: Bearer $TESSERA_API_KEY"

JavaScript

const res = await fetch("https://scanmosaic.com/api/v1/domains", {
  headers: { Authorization: `Bearer ${process.env.TESSERA_API_KEY}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.message);
console.log(body.data);

Python

import os, requests

r = requests.get(
    "https://scanmosaic.com/api/v1/domains",
    headers={"Authorization": f"Bearer {os.environ['TESSERA_API_KEY']}"},
)
r.raise_for_status()
print(r.json()["data"])
Response 200 OK
{
  "data": [
    {
      "host": "go.yourbrand.com",
      "verified": true,
      "ready": true
    }
  ]
}

Custom domains

On the Business plan your short links can use your own domain, like go.yourbrand.com/menu. Add and verify the domain under Domains in the dashboard: you add a CNAME record that points to us and a TXT record that proves it's yours, and we issue the SSL certificate. Then pass domain when you create or update an item.

Each domain can redirect its bare address (go.yourbrand.com) and unknown paths to pages you choose.

OpenAPI

The full API is described in openapi.json (OpenAPI 3.1). Import it into Postman, Insomnia or a client generator.

Versioning

v1 is stable. We may add endpoints, fields and error codes, so ignore fields you don't know. We won't remove or rename anything in v1; breaking changes would come as v2, announced well ahead.