QRArchive API and webhooks
Available on the Business and Agency plans. Workspace admins and owners manage keys and webhook endpoints in Dashboard → Developers.
Authentication
Authorization: Bearer qra_abcd1234_…- A key belongs to one workspace and can only see and change that workspace's QR codes.
- The full key is shown once when it is created. Only a SHA-256 hash is stored, so a lost key can't be recovered; revoke it and create a new one.
- Keys carry permissions (
qr:read,qr:write,analytics:read) and can expire after 30, 90 or 365 days. - Every request re-checks that the key is active, the workspace isn't suspended and the plan still includes the API. Downgrading switches keys off immediately; upgrading again switches them back on.
- Server-to-server only. No CORS headers are sent, so browsers refuse cross-origin calls. Never put a key in a website or mobile app.
- Rate limits: 120 requests per minute per key. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(seconds until the window resets). Over the limit you get429withRetry-After. Repeated invalid keys from one address are blocked for 10 minutes. - A key stops working when the person who created it leaves the workspace or is no longer an owner or admin. Create keys from an account that will keep managing the workspace.
Errors
Every error uses the same JSON shape and an appropriate HTTP status:
{ "error": { "code": "validation_failed", "message": "destination.url: Enter a valid web address" } }| Status | Codes |
|---|---|
| 400 | invalid_json, invalid_cursor |
| 401 | missing_api_key, invalid_api_key |
| 403 | insufficient_scope, plan_required, workspace_suspended, feature_not_available, limit_reached |
| 404 | not_found |
| 413 / 415 | payload_too_large (64 KB), unsupported_media_type (send Content-Type: application/json) |
| 422 | validation_failed |
| 429 | rate_limited |
| 500 | server_error |
QR code object
{
"id": "7b0c…",
"name": "Table tent",
"description": null,
"type": "url",
"destination": { "url": "https://example.com/menu" },
"is_dynamic": true,
"short_url": "https://qra.example/q/Ab3dE7x",
"qr_content": "https://qra.example/q/Ab3dE7x",
"status": "active",
"blocked": false,
"folder_id": null,
"scan_count": 42,
"last_scanned_at": "2026-10-04T12:00:00.000Z",
"starts_at": null,
"expires_at": null,
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-03T16:30:00.000Z"
}qr_content is exactly what the QR code encodes. For dynamic codes it is the short URL, never the destination, so the destination can change after printing. Fields are only ever added to this object, never removed or renamed.
Endpoints
Base URL: https://www.qrarchive.com/api/v1
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /qr-codes | qr:read | List. ?status=active|paused|archived|all, ?limit=1–100 (default 50), ?cursor= |
| POST | /qr-codes | qr:write | Create |
| GET | /qr-codes/{id} | qr:read | Fetch one |
| PATCH | /qr-codes/{id} | qr:write | Change name, description, status, destination, expiry or folder |
| DELETE | /qr-codes/{id} | qr:write | Delete. Returns 204; the short link stops working |
| GET | /qr-codes/{id}/analytics | analytics:read | Daily scans, devices and countries. ?days=7|30|90 |
| GET | /qr-codes/{id}/image | qr:read | Print-ready SVG with the saved design. ?size=128–4096 |
Single QR codes (fetch, create and update) are returned as { "data": { …QR code object… } }. The list returns { "data": [...], "next_cursor": "…" | null }. Pass next_cursor back as cursor to get the next page; the order is newest first and stays stable while you page.
Create
curl -X POST "https://www.qrarchive.com/api/v1/qr-codes" \
-H "Authorization: Bearer $QRA_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Spring flyer", "type": "url", "destination": { "url": "https://example.com/spring" } }'| Field | Notes |
|---|---|
name | Required, 1–120 characters |
type | Destination type: url, whatsapp, phone, sms, email, text, wifi, vcard, maps, review, social, landing_page and so on. The same types as the dashboard |
destination | The fields for that type, the same as the dashboard form. They go through the same validation and safety checks (blocked domains, phishing lists, workspace-owned published landing pages) |
is_dynamic | Default true. Static codes encode the content directly and can't be edited or tracked |
description, timezone, folder_id | Optional. Folders need a plan with folders |
Send an Idempotency-Key header (any unique string up to 255 characters, such as a UUID) to make retries safe: for 24 hours, repeating the request with the same key and body returns the original response with Idempotent-Replayed: true instead of creating another code. Reusing a key with a different body returns 422 idempotency_key_reused, and a retry while the first request is still running returns 409 idempotency_in_progress.
New codes use the default design. Change the design in the dashboard; the image endpoint always uses the saved design. Plan limits apply exactly as in the dashboard: over the limit you get 403 limit_reached.
Update
Send only the fields you want to change. type and destination must be sent together. status is one of active, paused or archived. expires_at is an ISO-8601 date-time with an offset, or null; setting one needs a plan with expiry dates. Changes reach every scan within a few seconds.
Everything done through the API is written to the workspace audit log with the key's ID, and fires the same webhooks as the dashboard.
Webhooks
Add an https endpoint in Dashboard → Developers, pick the events, and store the signing secret (whsec_…) that's shown once.
| Event | data |
|---|---|
qr.created, qr.updated, qr.deleted | The QR code object |
scan.created | qr_id, scanned_at, rule_id, device_type, os, browser, country, language, referrer_host. No IP address or visitor identifier is ever sent |
enquiry.created | page_id, page_title, name, email, phone, message, created_at |
ping | Sent by Send test |
Each delivery is an HTTP POST with a JSON body:
{ "id": "evt-uuid", "type": "scan.created", "created_at": "…", "workspace_id": "…", "data": { … } }Headers:
| Header | Value |
|---|---|
X-Webhook-Id | The event ID. The same across retries, so use it to ignore duplicates |
X-Webhook-Timestamp | Unix seconds when this attempt was signed |
X-Webhook-Signature | v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")> |
This is QRArchive's own scheme, not the Standard Webhooks one, so verify it with the code below rather than a Standard Webhooks library.
Verifying signatures (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody) {
const ts = Number(headers["x-webhook-timestamp"]);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; // replay window
const expected = Buffer.from(`v1=${createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex")}`);
const given = Buffer.from(headers["x-webhook-signature"] ?? "");
return expected.length === given.length && timingSafeEqual(expected, given);
}Verify against the raw request body, before any JSON parsing.
Delivery rules
- Respond with any
2xxwithin 10 seconds. Anything else, including a redirect (redirects aren't followed), counts as a failure. - Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: six attempts in total. Events can arrive out of order or more than once.
- An endpoint that has failed at least 20 deliveries in a row and hasn't succeeded for 24 hours is paused. Workspace owners and admins get an email, and the dashboard shows why. Turning it back on resets the count.
- Endpoints must be
httpson port 443 with a public hostname. The host is resolved as part of every connection and private, loopback and cloud-metadata addresses are refused, so DNS changes can't redirect deliveries to internal addresses. - Delivery logs are kept for 30 days. Resend next to a finished delivery sends that event again straight away, with the same event ID.