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"}
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.

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

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",
    "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'])