QRzify
Menu

QRzify API and webhooks

Create dynamic codes from your own tools, read their statistics, and get a signed message whenever a code is scanned. The API is on the Unlimited plan. Make a key under Settings, API keys.

Authentication

Send your key in the Authorization header. Keys start with qrf_, are shown once when you make them, and can be revoked at any time. Each person can have 5 keys. All requests and responses are JSON, and the base address is https://qrzify.viralish.in.

curl https://qrzify.viralish.in/api/v1/qr_codes \
  -H "Authorization: Bearer qrf_your_key_here"

Codes

The API works with dynamic codes. A code looks like this:

{
  "id": 42,
  "name": "Diwali flyer",
  "slug": "aB3xK9",
  "short_url": "https://code.viralish.in/aB3xK9",
  "status": "active",
  "paused_reason": null,
  "qr_type": "url",
  "destination_url": "https://yourbrand.in/diwali",
  "utm": { "utm_source": "flyer" },
  "design": { "fg": "#14532D" },
  "protected": false,
  "max_scans": null,
  "scan_count": 120,
  "created_at": "2026-10-18T10:00:00Z"
}

The password you set is never returned; protected only says whether there is one.

List codes

GET /api/v1/qr_codes

Newest first. Use page (from 1) and per (1 to 100, default 25). The response has data and meta (page, per, total).

Get one code

GET /api/v1/qr_codes/:id

Create a code

POST /api/v1/qr_codes

Returns 201 with the code. You need a confirmed email address, and the same plan limits as the website apply.

curl -X POST https://qrzify.viralish.in/api/v1/qr_codes \
  -H "Authorization: Bearer qrf_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Diwali flyer",
    "destination_url": "https://yourbrand.in/diwali",
    "utm": { "utm_source": "flyer", "utm_medium": "print" },
    "redirect_rules": [
      { "label": "iPhone", "conditions": { "os": ["iOS"] }, "destination_url": "https://apps.apple.com/..." }
    ],
    "max_scans": 5000
  }'

Fields: name, qr_type, destination_url, folder_id, utm, design, page, redirect_rules, variants, access_password, expires_at, max_scans. The rules, variants and protection fields are checked the same way as in the website, and every link, including each rule and variant, is checked with Google Safe Browsing. Pages that need uploaded files, such as PDF and gallery pages, have to be made on the website because the API does not take files.

Update a code

PATCH /api/v1/qr_codes/:id

A code can be edited for 7 days after it is created (editable_until shows when); after that destination_url, utm, page, variants and redirect_rules are fixed, while name, folder_id, design, access_password, expires_at, max_scans and status can still change. Send only what changes. Sending redirect_rules replaces all the rules. Send "status": "paused" to pause a code, or "status": "active" to turn it back on. A code paused by QRzify, or one that reached its scan limit, cannot be turned on from the API. The printed code and its short link never change.

Statistics

GET /api/v1/qr_codes/:id/stats?days=30

days is 7, 30 or 90, up to the history your plan keeps. You get total_scans, unique_scans, a daily series (India dates, zero-filled) and breakdowns by country, city, device type, operating system, browser, referrer and variant.

Errors and limits

Errors are JSON like {"error": "not_found"}. A 422 also has details (the fields and what was wrong). You can make 120 requests per minute with each key. Past that you get 429 with a Retry-After header in seconds.

API error codes
StatuserrorMeaning
400bad_requestThe body is not valid JSON.
401unauthorizedMissing, wrong or revoked key.
403api_not_in_planYour plan does not include the API.
403email_not_verifiedConfirm your email address before creating dynamic codes.
404not_foundNo such code on your account.
422not_in_planA field you sent is not in your plan (details.fields lists them).
422edit_window_closedThe code was created more than 7 days ago, so its destination, campaign tags, page and routing can no longer change.
422plan_limitYour plan has no room for another live code.
422paused_by_qrzifyQRzify paused this code; contact support.
422scan_limit_reachedRaise or remove max_scans first.
422invalid_statusstatus must be active or paused.
422invalid_daysdays is not an allowed range (details.days lists them).
429rate_limitedMore than 120 requests in a minute.

Webhooks

Add up to 3 addresses under Settings, Webhooks and choose the events. The address has to be public and start with https:// or http://. We make a POST with a JSON body, wait up to 5 seconds, and treat any 2xx answer as received. We do not follow redirects. If you do not answer with 2xx we try again with a growing wait, up to 5 times. After 20 events in a row that could not be delivered, the address is switched off and you can turn it back on in settings.

{
  "id": "evt_5f2c9a1b7e04d3a8c6b1",
  "event": "scan.created",
  "created_at": "2026-10-18T10:00:00Z",
  "data": {
    "qr_code_id": 42,
    "slug": "aB3xK9",
    "scanned_at": "2026-10-18T10:00:00Z",
    "country": "IN",
    "city": "Pune",
    "device_type": "mobile",
    "os": "Android",
    "browser": "Chrome",
    "referrer_host": null,
    "variant": null
  }
}

We never send anything that identifies the visitor, such as an address.

Every request has these headers:

Checking the signature

Work out the signature yourself from the raw body, compare it in constant time, and reject requests whose timestamp is more than 5 minutes old. Use the raw body, not a re-built copy of the parsed JSON.

Ruby

def valid_qrzify_signature?(request, secret)
  body = request.raw_post
  timestamp = request.headers["X-QRzify-Timestamp"].to_s
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
  ActiveSupport::SecurityUtils.secure_compare(expected, request.headers["X-QRzify-Signature"].to_s)
end

Node.js

const crypto = require("crypto");

// rawBody must be the exact bytes received (for Express, use express.raw)
function validQrzifySignature(rawBody, headers, secret) {
  const timestamp = headers["x-qrzify-timestamp"] || "";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = "sha256=" + crypto.createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`).digest("hex");
  const given = Buffer.from(headers["x-qrzify-signature"] || "");
  const wanted = Buffer.from(expected);
  return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}