API reference
Programmatic access to freeqrstudio. Available on the Business plan. Base URL: https://www.freeqrstudio.com//api/v1.
Quickstart
Create your first dynamic QR code:
curl -X POST https://www.freeqrstudio.com//api/v1/qrcodes \
-H "Authorization: Bearer fqs_live_..." \
-H "Content-Type: application/json" \
-d '{"destination": "https://example.com/launch", "label": "Launch page"}'You'll get back the new record plus a short URL:
{
"qrcode": {
"id": "a3k9xb",
"destination": "https://example.com/launch",
"label": "Launch page",
"short_url": "https://www.freeqrstudio.com//r/a3k9xb",
"image_url": "https://www.freeqrstudio.com//api/v1/qrcodes/a3k9xb/image",
"scans": 0,
"disabled": false,
"expires_at": null,
"created_at": "2026-05-31T11:32:08.000Z"
}
}Generate the QR image yourself from the short URL with any QR library, or open the URL directly to test the redirect.
Authentication
Every request must include an Authorization: Bearer <api-key> header. Keys are created at /dashboard/api and look like fqs_live_….
Keys are full-access at the account level — they can create, read, update, and delete any QR owned by the account. Rotate by creating a new key and revoking the old one.
We never log API keys. We store only a SHA-256 hash; if you lose a key, create a new one.
Rate limits
60 requests per minute per key. Every response includes:
X-RateLimit-Limit— your per-minute cap (60).X-RateLimit-Remaining— how many requests are left.X-RateLimit-Reset— unix timestamp when the window resets.
When the limit is exceeded the API returns 429 Too Many Requests with a Retry-After header (seconds). Need more? Reach out and we'll raise it.
Errors
Errors return non-2xx HTTP status codes and a JSON body:
{"error": "Invalid or revoked API key"}| Status | Meaning |
|---|---|
400 | Missing or invalid body parameter. |
401 | Missing or invalid Authorization header. |
403 | Account tier doesn't allow this action (Business required). |
404 | Resource doesn't exist (or belongs to another account). |
429 | Rate limit exceeded. |
500 | Something on our end. Safe to retry with backoff. |
Create a QR code
POST /api/v1/qrcodes
Body
| Field | Type | Required | Description |
|---|---|---|---|
destination | string | yes | Where the QR should redirect. |
label | string | no | Internal label for your records. |
password | string | no | 4–128 characters. Scanners must enter it before being redirected. Stored hashed; never returned. |
expires_at | string | no | ISO 8601 date in the future. After it, scans show a “campaign ended” page. |
scan_limit | integer | no | Maximum number of scans. Once reached, scans show a “limit reached” page. |
utm | object | no | UTM tags appended to the destination on every scan, e.g. {"source": "poster", "medium": "qr", "campaign": "spring"}. Keys: source, medium, campaign, term, content (max 100 characters each). See UTM tracking. |
folder | string | no | Dashboard folder, up to 60 characters. Codes with the same folder name are grouped together. |
tags | string[] | no | Up to 10 tags, 30 characters each. Stored lower-case. |
brand_kit | string | no | Name or id of a saved brand kit to style the code with. Without it (and without design) your default brand kit is used. See Design & brand kits. |
design | object | no | Colours, dot and corner style, logo and frame. Overrides the brand kit field by field. See Design & brand kits. |
Example
curl -X POST https://www.freeqrstudio.com//api/v1/qrcodes \
-H "Authorization: Bearer fqs_live_..." \
-H "Content-Type: application/json" \
-d '{"destination": "https://example.com", "label": "Homepage"}'Response
{
"qrcode": {
"id": "a3k9xb",
"destination": "https://example.com",
"label": "Homepage",
"short_url": "https://www.freeqrstudio.com//r/a3k9xb",
"scans": 0,
"disabled": false,
"expires_at": null,
"has_password": false,
"scan_limit": null,
"utm": null,
"content_type": null,
"folder": null,
"tags": [],
"design": {
"foreground": "#0F0F0E",
"background": "#FFFFFF",
"dot_style": "rounded",
"corner_square_style": "extra-rounded",
"corner_dot_style": "dot",
"has_logo": false,
"logo_size": 30,
"frame": null
},
"created_at": "2026-09-21T10:00:00.000Z"
}
}List QR codes
GET /api/v1/qrcodes
Query params: limit (default 50, max 100), offset (default 0), and the optional filters folder (exact folder name; empty for unfiled codes) and tag.
curl https://www.freeqrstudio.com//api/v1/qrcodes?limit=20 \
-H "Authorization: Bearer fqs_live_..."{
"qrcodes": [ ... ],
"pagination": { "total": 47, "limit": 20, "offset": 0, "has_more": true }
}Retrieve a QR code
GET /api/v1/qrcodes/{id}
curl https://www.freeqrstudio.com//api/v1/qrcodes/a3k9xb \
-H "Authorization: Bearer fqs_live_..."Update a QR code
PATCH /api/v1/qrcodes/{id}
Change the destination (the printed QR keeps working — only the redirect target changes), the label, or disable the code. You can also set or change password, expires_at, scan_limit, utm, folder and tags; send null for any of them to remove it. Sending utm replaces all saved tags. brand_kit and design change how the code looks in future downloads — codes that are already printed keep working.
curl -X PATCH https://www.freeqrstudio.com//api/v1/qrcodes/a3k9xb \
-H "Authorization: Bearer fqs_live_..." \
-H "Content-Type: application/json" \
-d '{"destination": "https://example.com/v2"}'# Password-protect a code and stop it after 500 scans
curl -X PATCH https://www.freeqrstudio.com//api/v1/qrcodes/a3k9xb \
-H "Authorization: Bearer fqs_live_..." \
-H "Content-Type: application/json" \
-d '{"password": "staff-only", "scan_limit": 500}'Design & brand kits
Every code has a saved design, used by the image endpoint and by downloads in the dashboard. When you create a code, the design is built in this order:
- the brand kit named in
brand_kit— or, if you don't send one, your workspace's default brand kit — or our standard look; - then any fields in
designoverride it.
Create brand kits in the dashboard (Brand kits) or by saving a style in the generator. List them with GET /api/v1/brand-kits.
design field | Values |
|---|---|
foreground, background | Hex colours, e.g. "#1A365D". Keep strong contrast — dark on light scans best. |
dot_style | square, rounded, dots, classy, classy-rounded, extra-rounded |
corner_square_style | square, dot, extra-rounded |
corner_dot_style | square, dot |
logo | PNG, JPEG or WEBP data URL (about 256×256 px), or null to remove |
logo_size | 10–40. 30 covers about 9% of the code; larger logos need more contrast. |
frame | null, or {"style": "banner", "text": "Scan me", "position": "bottom", "color": "#0F0F0E", "text_color": "#FFFFFF"}. Styles: box, banner, bubble; text up to 24 characters. |
Invalid values are rejected with a 400 that names the field, rather than silently replaced.
# A client's brand kit, with a campaign-specific frame
curl -X POST https://www.freeqrstudio.com//api/v1/qrcodes -H "Authorization: Bearer fqs_live_..." -H "Content-Type: application/json" -d '{
"destination": "https://acme.com/spring",
"label": "Spring poster",
"brand_kit": "Acme Coffee",
"design": { "frame": { "style": "banner", "text": "Scan for 20% off" } }
}'Download the image
GET /api/v1/qrcodes/{id}/image
The code as a print-ready image in its saved design (logo, colours and frame included). Every QR response includes it as image_url. Requires your API key, like every endpoint — fetch it from your server rather than linking it in public web pages.
| Query param | Values |
|---|---|
format | png (default) or svg — SVG is vector, best for print |
size | Width in pixels, 100–2000 (default 1000) |
download | 1 to send it as a file download |
curl "https://www.freeqrstudio.com//api/v1/qrcodes/a3k9xb/image?format=svg" -H "Authorization: Bearer fqs_live_..." -o poster-qr.svgUTM tracking
Tags saved on a QR (utm) are added to the destination as utm_source, utm_medium, etc. on every scan, so tools like Google Analytics attribute the visit to the QR. Any utm_* parameters on the short URL itself override the saved tag with the same name — so one code can be tracked per placement:
# Saved tags: {"source": "poster", "campaign": "spring"}
https://www.freeqrstudio.com//r/a3k9xb → ...?utm_source=poster&utm_campaign=spring
https://www.freeqrstudio.com//r/a3k9xb?utm_source=newsletter → ...?utm_source=newsletter&utm_campaign=springThe tags used for each scan are recorded and shown in the QR's analytics, the CSV export and the scan.created webhook.
Delete a QR code
DELETE /api/v1/qrcodes/{id}
Scans against a deleted QR show the expired page.
curl -X DELETE https://www.freeqrstudio.com//api/v1/qrcodes/a3k9xb \
-H "Authorization: Bearer fqs_live_..."Webhooks
Register an HTTPS endpoint and we'll POST a JSON payload whenever one of your QRs is scanned.
Event types
| Event | Triggered when |
|---|---|
scan.created | Someone scans one of your QRs (only counted scans — expired/disabled QRs don't fire). |
Payload shape
{
"id": "evt_3f5a8c1d2e7b4f06",
"type": "scan.created",
"created": 1748694200000,
"data": {
"qr_id": "a3k9xb",
"destination": "https://example.com/launch",
"scanned_at": "2026-05-31T11:30:00.000Z",
"country": "US",
"region": "NY",
"city": "New York",
"device": "iOS",
"device_type": "Mobile",
"browser": "Safari",
"language": "en",
"referrer": null,
"user_agent": "Mozilla/5.0 ...",
"utm": { "source": "poster", "medium": "qr", "campaign": "spring" }
}
}Deliveries have a 5-second timeout. If your endpoint returns anything but 2xx, we log the failure on the endpoint row visible in your dashboard. (Automatic retries are coming.)
Manage webhook endpoints
Programmatically (or use the dashboard):
# Add an endpoint
curl -X POST https://www.freeqrstudio.com//api/v1/webhooks \
-H "Authorization: Bearer fqs_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://api.yourapp.com/hooks/fqs", "events": ["scan.created"]}'
# List endpoints
curl https://www.freeqrstudio.com//api/v1/webhooks \
-H "Authorization: Bearer fqs_live_..."
# Remove
curl -X DELETE https://www.freeqrstudio.com//api/v1/webhooks/{webhook_id} \
-H "Authorization: Bearer fqs_live_..."The create response includes a secret field — save it. It's only shown once and you'll need it to verify incoming signatures.
Verify webhook signatures
Each request includes an X-FQS-Signature header in the form t=<timestamp>,v1=<hex-hmac>. Compute HMAC-SHA256 over <timestamp>.<raw-body> with your endpoint secret and compare in constant time.
Node.js example
import crypto from 'crypto';
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('='))
);
if (!parts.t || !parts.v1) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1, 'hex')
);
}
// In your handler:
app.post('/hooks/fqs', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-fqs-signature'];
if (!verify(req.body.toString('utf8'), sig, process.env.FQS_WEBHOOK_SECRET)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// ... do your thing
res.status(200).send('ok');
});Python example
import hmac, hashlib
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split('=') for p in signature_header.split(','))
if 't' not in parts or 'v1' not in parts:
return False
expected = hmac.new(
secret.encode(),
f"{parts['t']}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])