Quick start
- Open API keys in your dashboard (Business plan) and create a key. Copy it: it's shown once.
- Store it as an environment variable on your server, e.g. TESSERA_API_KEY. Never put it in browser or app code.
- 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.pngJavaScript
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"
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | bad_json | The body isn't valid JSON. |
| 400 | bad_param | A query or body parameter is out of range. details: param, hint. |
| 400 | bad_destination | dest isn't a full http(s) web address. |
| 400 | bad_kind | kind isn't qr, link or barcode. |
| 400 | bad_alias | alias isn't 3–40 letters, numbers or dashes, or it's reserved. |
| 400 | domain_not_verified | domain isn't a verified domain of your workspace. |
| 400 | unsafe_destination | Google Web Risk flags the destination. |
| 400 | empty / too_long / bad_format | QR images: no data, too much data, or a format other than png or svg. |
| 400 | bad_cursor | The cursor isn't one we gave you. |
| 401 | api_key_required | No Authorization header, or it isn't Bearer tsk_… |
| 401 | invalid_api_key | The key doesn't exist or was deleted. |
| 402 | business_required | The workspace isn't on the Business plan (for example, it ended). |
| 403 | limit_reached | Your plan's limit for that kind of item is reached. details: limit, kind. |
| 404 | not_found | No such item in this workspace. |
| 409 | alias_taken | That alias is already used on that domain. |
| 413 | too_big | The body is too large (250 KB for items). |
| 429 | rate_limited / daily_limit | Too many requests. Wait for Retry-After seconds. |
| 500 | server_error | Something 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:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute. |
X-RateLimit-Remaining | Requests left in the current minute. |
X-RateLimit-Reset | Unix time when the minute resets. |
Retry-After | On 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.
| Name | Type | Description |
|---|---|---|
id | string | Short id, also the default ending of the short link. |
kind | qr | link | barcode | What it is. Can't change after creation. |
type | string | Content type, e.g. url (default), pdf, menu, vcard for QR codes; ean13, code128 for barcodes. |
name | string | Your label, up to 80 characters. |
dest | string | null | Where scans and clicks go. null for hosted pages and barcodes. |
url | string | The short link that the QR code encodes, e.g. https://go.yourbrand.com/menu. |
alias | string | null | Custom ending (3–40 letters, numbers or dashes), unique per domain. |
domain | string | null | Your verified custom domain, or null for ours. |
campaign | string | null | Group items for reporting. |
content | object | null | Hosted page data, or barcode data ({ "data": "…" }). |
style | object | Design of the QR code (colours, dot and corner styles). |
rules | object | null | Routing rules (by device, country, time…). |
paused | boolean | Paused items show a 'paused' page instead of redirecting. |
disabled | boolean | Disabled by us for breaking the terms. |
hits | integer | All-time scans (QR codes) or clicks (links). |
last_hit | integer | null | Unix time of the last scan or click. |
created / updated | integer | Unix 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.
| Name | Type | Description |
|---|---|---|
kind | qr | link | barcode | Only this kind. |
type | string | Only this content type, e.g. url or pdf. |
campaign | string | Only this campaign. |
domain | string | Only items on this custom domain. |
paused | true | false | Only paused, or only live, items. |
q | string | Search name, destination and alias. |
limit | integer | 1–100, default 25. |
cursor | string | next_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.
| Name | Type | Description |
|---|---|---|
kind | qr | link | barcode | Default qr. |
dest required | string | Full http(s) address to redirect to. Not used for barcodes and hosted pages. |
name | string | Up to 80 characters. |
type | string | Default url (codes, links) or code128 (barcodes). |
alias | string | Custom ending, e.g. menu. 3–40 letters, numbers or dashes, unique per domain. |
domain | string | One of your verified custom domains (see /domains). |
campaign | string | Up to 60 characters. |
style | object | QR design, e.g. {"fg": "#112233", "dot": "rounded"}. |
content | object | Hosted page or barcode data, e.g. {"bcid": "ean13", "text": "4006381333931"} for a barcode. |
rules | object | Routing rules. |
paused | boolean | Start 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.
| Name | Type | Description |
|---|---|---|
name, dest, alias, domain, campaign, style, content, rules, paused | As 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.
| Name | Type | Description |
|---|---|---|
days | integer | 1–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.
| Name | Type | Description |
|---|---|---|
data | string | Text or link to encode (up to 2,953 bytes). Required unless item is set. |
item | string | Id of a saved QR code or link: encodes its short link with its saved design. |
format | png | svg | Default png. |
size | integer | Pixels, 64–2048 for PNG (default 512), 64–4096 for SVG (default 1024). |
color | #RRGGBB | Dots. Default #141A2E. |
background | #RRGGBB | Default #FFFFFF. |
transparent | boolean | Transparent background. |
corner_color | #RRGGBB | Corner squares. Default: color. |
gradient_color | #RRGGBB | SVG only: second colour of a diagonal gradient. |
error_correction | L | M | Q | H | How much damage the code survives: 7%, 15%, 25%, 30%. Default M (PNG) or Q (SVG). |
margin | integer | Quiet zone in modules, 0–8. Default 4. |
dot_style | string | square, dots, rounded, extra-rounded, classy, classy-rounded |
corner_style | string | square, extra-rounded, dot |
corner_dot_style | string | square, dot |
download | string | File 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.pngJavaScript
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.