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",
"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. |
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"}'List QR codes
GET /api/v1/qrcodes
Query params: limit (default 50, max 100) and offset (default 0).
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.
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"}'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",
"device": "iOS",
"referrer": null,
"user_agent": "Mozilla/5.0 ..."
}
}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'])