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.
| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | The body is not valid JSON. |
| 401 | unauthorized | Missing, wrong or revoked key. |
| 403 | api_not_in_plan | Your plan does not include the API. |
| 403 | email_not_verified | Confirm your email address before creating dynamic codes. |
| 404 | not_found | No such code on your account. |
| 422 | not_in_plan | A field you sent is not in your plan (details.fields lists them). |
| 422 | edit_window_closed | The code was created more than 7 days ago, so its destination, campaign tags, page and routing can no longer change. |
| 422 | plan_limit | Your plan has no room for another live code. |
| 422 | paused_by_qrzify | QRzify paused this code; contact support. |
| 422 | scan_limit_reached | Raise or remove max_scans first. |
| 422 | invalid_status | status must be active or paused. |
| 422 | invalid_days | days is not an allowed range (details.days lists them). |
| 429 | rate_limited | More 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.
scan.created: a person scanned a code. Bots are not counted.code.paused: a code was paused, by you, by billing or by a scan limit.code.activated: a code was turned back on.
{
"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:
X-QRzify-Event: the event name.X-QRzify-Timestamp: when we sent it, in Unix seconds.X-QRzify-Signature:sha256=followed by the HMAC-SHA256 oftimestamp + "." + body, using your webhook's signing secret as the key.
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);
}