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"}
StatusMeaning
400Missing or invalid body parameter.
401Missing or invalid Authorization header.
403Account tier doesn't allow this action (Business required).
404Resource doesn't exist (or belongs to another account).
429Rate limit exceeded.
500Something on our end. Safe to retry with backoff.

Create a QR code

POST /api/v1/qrcodes

Body

FieldTypeRequiredDescription
destinationstringyesWhere the QR should redirect.
labelstringnoInternal label for your records.
passwordstringno4–128 characters. Scanners must enter it before being redirected. Stored hashed; never returned.
expires_atstringnoISO 8601 date in the future. After it, scans show a “campaign ended” page.
scan_limitintegernoMaximum number of scans. Once reached, scans show a “limit reached” page.
utmobjectnoUTM 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.
folderstringnoDashboard folder, up to 60 characters. Codes with the same folder name are grouped together.
tagsstring[]noUp to 10 tags, 30 characters each. Stored lower-case.
brand_kitstringnoName 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.
designobjectnoColours, 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:

  1. the brand kit named in brand_kit — or, if you don't send one, your workspace's default brand kit — or our standard look;
  2. then any fields in design override 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 fieldValues
foreground, backgroundHex colours, e.g. "#1A365D". Keep strong contrast — dark on light scans best.
dot_stylesquare, rounded, dots, classy, classy-rounded, extra-rounded
corner_square_stylesquare, dot, extra-rounded
corner_dot_stylesquare, dot
logoPNG, JPEG or WEBP data URL (about 256×256 px), or null to remove
logo_size10–40. 30 covers about 9% of the code; larger logos need more contrast.
framenull, 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 paramValues
formatpng (default) or svg — SVG is vector, best for print
sizeWidth in pixels, 100–2000 (default 1000)
download1 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.svg

UTM 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=spring

The 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

EventTriggered when
scan.createdSomeone 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'])