# QR Maker AI — Business API v1: integration rules for AI coding assistants # Full guide: https://qrmaker.ai/docs/business-api-guide.md (中文: https://qrmaker.ai/docs/business-api-guide.zh.md) # OpenAPI 3 spec: https://qrmaker.ai/openapi.json Interactive reference: https://qrmaker.ai/docs # Last updated 2026-09-24. Plain text, safe to paste into a prompt. ## Goal of a typical integration The user's system already has its own unique links (e.g. one referral link per promoter: https://app.example.com/signup?ref=AB12CD). For each link, their BACKEND creates a trackable dynamic QR code (external_id = their own id for that link, idempotent), stores short_link + SVG, shows / downloads / shares it in their own UI, pulls scan events incrementally (no webhooks), and builds reports for custom date ranges and time zones. Codes created in the QR Maker web dashboard are visible to the API too. ## Hard rules 1. Base URL: https://qrmaker.ai/api/v1 . Header: Authorization: Bearer . Token format "qrs_...". 2. Token comes from env var QRMAKER_API_TOKEN (or the project's secret manager). Never hard-code, log, commit, or ship it in browser / mobile / desktop code. Call the API from the server only (CORS blocks browsers). 3. The token is created by a human in Dashboard -> Settings -> API Tokens (Business plan, verified e-mail). Tokens cannot manage tokens, billing, team, domains or logo uploads (403 TOKEN_SCOPE). A token can be "read-only" (GET/HEAD only; anything else -> 403 TOKEN_READ_ONLY): recommend a read-only token for reporting / BI systems that only pull data. 4. JSON bodies, snake_case field names, unix-second timestamps, YYYY-MM-DD dates. Unknown request fields are rejected (400 VALIDATION_ERROR): send only documented fields. 5. Success: {"success":true,"data":...} (lists add "next_cursor"). Error: {"success":false,"error":"...", "code":"STABLE_CODE","params":{...}}. Branch on "code" only; never parse "error". 6. Encode and share ONLY short_link (https://qrmk.cc/{id} or https://{short_domain}/{id}). Never encode target_url. Changing target_url later does not change the image; printed codes follow. 7. Always send external_id when creating (1-128 chars of A-Z a-z 0-9 . _ : -, unique per account). Same external_id + same target_url -> 200 + existing code (header X-Idempotent-Replay: true). Same external_id + different target_url -> 409 EXTERNAL_ID_CONFLICT (params.id = existing code). POST never changes a destination; use PATCH explicitly. On replay, other body fields are ignored. 8. We render SVG only. Convert to PNG yourself (Node: sharp or @resvg/resvg-js; Python: cairosvg; browser: canvas on an SVG your backend delivered). Serve images from the user's own storage. Keep margin=4. Re-download an image only when image_version changes. 9. Tolerate additions: ignore unknown response fields, accept unknown enum values, handle unknown error codes by HTTP status class. Store raw scan events as JSON so new fields are kept. ## Limits and billing - 120 requests/minute per account (all tokens share). 50,000 billable calls per UTC calendar month. - Billable: every 2xx = 1; 304 = 1; batch = number of successful items. Free: any 4xx/5xx, GET /v1/scans with no events, GET /v1/usage. - Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (unix s), X-Quota-Limit, X-Quota-Remaining; Retry-After (seconds) on 429. - Retries: 429 -> sleep Retry-After; 500 / network error -> backoff 1,2,4,8 s + jitter, then alert; 402 BUSINESS_API_QUOTA_EXCEEDED -> stop until next month; other 4xx -> do not retry. - Sizes: batch 1-100 items; list limit <=200 (default 50); scans limit <=1000 (default 100); report range <=366 days; target_url http(s) <=2048 chars; name <=100; folder <=50; SVG size 64-4096, margin 0-10. ## Endpoints ({ref} = {id} or by-external-id/{external_id}, URL-encoded) POST /qrcodes[?include=svg] create. body: target_url (required), external_id, name, folder, short_domain, style. -> 201 created | 200 replay. data = QrCode (+svg) POST /qrcodes/batch[?include=svg] body {"items":[, ...]} (<=100). HTTP 200. data[i] = {"status":201|200|4xx|500,"data"?:QrCode,"error"?:{...,"params":{"index":i}}} Whole batch 402 (params.required/remaining) if quota < items. GET /qrcodes list. limit, cursor, status=active|disabled, folder, external_id, updated_since (unix s, inclusive, sorted updated_at ASC), include_deleted=true (only with updated_since; adds tombstones {id, external_id:null, status:"deleted", deleted_at, updated_at}; match on id). -> data[], next_cursor GET /qrcodes/{ref}[?include=svg] one code PATCH /qrcodes/{ref}[?include=svg] any of: target_url, name, folder, status, style (merged; null = reset), short_domain (null = qrmk.cc; WARNING changes short_link, breaks printed codes), external_id (null clears). Empty body -> 400. DELETE /qrcodes/{ref} permanent. -> {"id":"...","deleted":true}. Short link stops working. GET /qrcodes/{id}/svg image/svg+xml. size (64-4096, default 1000), margin (0-10, default 4), download=1. ETag "{image_version}.{size}.{margin}"; If-None-Match -> 304. X-QR-Logo: none|inlined|omitted (omitted -> ETag ends .nologo, don't keep it). GET /qrcodes/{id}/analytics from, to, tz -> scans, unique_visitors, bot_scans, lifetime_scans, daily[{date,scans,unique_visitors}], countries, regions, timezones, devices, os, browsers, referers, apps, languages, sources, os_versions (each [{key,count}], top 20 + "other"), hourly (7x24), hourly_unknown. GET /analytics/summary from, to, tz, group_by=code|folder|day, folder, external_id, limit, cursor, scan filters country (ISO alpha-2), app (e.g. wechat; browser = no app), src (placement tag; none = no tag). top level: from,to,tz,start_utc,end_utc,group_by,totals{scans,unique_visitors, bot_scans},next_cursor. rows: code {id,external_id,name,folder,scans, unique_visitors} (includes 0-scan codes, scans DESC) | folder {folder,codes, scans,unique_visitors} | day {date,scans,unique_visitors} (zero-filled). Cached 60 s per parameter set. GET /scans since_id, limit (<=1000), qrcode_id, external_id, include_bots. -> data[] (id ASC), next_since_id, has_more, poll_after_seconds. GET /scans/export from, to, tz (as in reports), qrcode_id | external_id (optional; default = all codes). -> text/csv (UTF-8 BOM), one row per non-bot scan, oldest first. Columns: scanned_at_utc, local_time, qrcode_id, external_id, code_name, country, region, timezone, device, os, os_version, browser, browser_version, app, language, referer, placement, variant, visitor (new columns only appended). Max 50,000 rows (header X-Export-Truncated: true, split the range). Billed 1 call per 1,000 rows. For continuous syncing use /scans, not this. GET /style[?include=svg] account default style for NEW codes -> {style (effective), configured[], svg? (sample preview)}. PUT /style body {"style": {...same fields as below} | null}. REPLACES the default style (null/{} clears). Only codes created afterwards inherit it (merged into the code at creation; request style overrides field by field; existing codes and image_version never change). Read-only tokens: GET only. GET /usage free. -> month, api_calls, api_quota, rate_limit_per_minute, plan, plan_expires_at ## Objects QrCode: id, external_id, name, target_url, short_link, short_domain, folder, status (active|disabled), style, scan_count, image_version, created_at, updated_at. updated_at changes only on edits (never on scans). Deleted codes are never listed (GET -> 404). style (send only fields to change; response has all): dot_style square|round|diamond|rounded-square|fluid (fluid = neighbouring modules joined into rounded strokes); color_mode solid|linear|radial; fg_color, bg_color (#rgb #rrggbb #rrggbbaa rgb() rgba()); bg_transparent bool; gradient_stops exactly 2 x {color, position 0-1}; gradient_angle 0-360; finder_outer_color, finder_inner_color; finder_shape square|rounded|smooth|circle (finder_round bool is the older switch: true = rounded, false = square); logo_url null or a URL of a logo uploaded to the same account (https://qrmaker.ai/api/uploads/...); logo_size_ratio 0.05-0.22; error_level L|M|Q|H. Invalid -> 400 STYLE_INVALID, params.field e.g. "style.fg_color". Scan event: id (strictly increasing, dedupe key), qrcode_id, external_id, scanned_at (unix s), country (ISO alpha-2), region (subdivision code WITHOUT country prefix, e.g. ON), device, os, browser, referer, variant, visitor (opaque per-UTC-day id, not reversible), is_bot, app (wechat, alipay, line, ...; null = regular browser), lang (BCP 47, e.g. zh-CN), os_version, browser_version (browser-reported major, indicative), tz (IANA, derived from country + region), local_time (ISO 8601 with offset, e.g. 2026-09-10T13:30:00-04:00), src (placement tag from ?s=). The fields from app onward are null for scans before 2026-09-24; treat all as nullable. Analytics breakdown keys: regions = full ISO 3166-2 (CA-ON); timezones = IANA names (America/Toronto) - a coarse stand-in for location, NOT a city; apps key "browser" = no app; sources key "none" = no tag; os_versions "iOS 18"; hourly[weekday][hour], weekday 0 = Monday, scanner's local time (not the tz param); sum(hourly) + hourly_unknown = scans. ## Scan sync loop (no webhooks) cursor = load() or 0 loop: r = GET /scans?since_id=cursor&limit=1000 upsert r.data by id in one transaction # at-least-once delivery: always dedupe by id cursor = r.next_since_id; save(cursor) # only after the events are stored if r.has_more: continue immediately sleep(max(r.poll_after_seconds, 60 if busy account else 30)) Run exactly one poller per account. Empty polls are free; a poll with events costs 1. Polling every 30 s with constant traffic can exceed 50,000/month; 60 s stays under 43,200. ## Reports - from / to: YYYY-MM-DD local dates in tz, both inclusive. Default: last 30 days ending today in tz. Max 366 days. - tz: IANA name (America/Toronto, Asia/Shanghai) or fixed offset (+08:00). Default UTC. URL-encode "+" as %2B. DST handled exactly. Responses echo from, to, tz, start_utc (inclusive), end_utc (exclusive). - Use start_utc / end_utc to query the user's own tables for the same window. - unique_visitors = distinct visitors per UTC day, summed (same person on 2 days = 2). COUNT(DISTINCT visitor) over stored events reproduces it. - Bots are excluded from scans and reported as bot_scans. Missing values = "unknown"; no referrer = "direct". - Location = country + region + time zone only (from the edge network). There is NO city, coordinates, network/operator or IP address in the data, and we do not store IP addresses. Do not promise the user city-level reports; if they need a city, they should ask the visitor on the page the target_url opens. Never use location data to identify or locate a specific household, individual or street address. ## Error codes (HTTP -> code -> action) 400 VALIDATION_ERROR (params.field) -> fix request 400 INVALID_TARGET_URL -> fix URL (http/https, <=2048) 400 STYLE_INVALID (params.field) -> fix style field 400 INVALID_TIMEZONE -> use IANA name or +HH:MM (encode +) 400 INVALID_DATE_RANGE -> from must be <= to 400 RANGE_TOO_LARGE (params.max_days) -> split range 400 DOMAIN_NOT_ACTIVE -> finish domain setup in dashboard or omit short_domain 401 UNAUTHORIZED -> token missing/invalid/expired/revoked; check secret, do not retry 402 BUSINESS_API_TOKENS -> account not on Business plan 402 BUSINESS_API_QUOTA_EXCEEDED (params.limit | params.required, params.remaining) -> stop until next month 402 BUSINESS_CUSTOM_DOMAIN -> reserved; Business plan needed 403 TOKEN_SCOPE -> only call /api/v1/* 403 TOKEN_READ_ONLY -> read-only token used for a write; use a read & write token 403 EMAIL_NOT_VERIFIED -> verify account e-mail in dashboard 403 URL_BLOCKED_PROTOCOL | _USERINFO | _IP_HOST | _PRIVATE_HOST | _ABUSE_TLD | _SELF_REFERENCE | _URL_SHORTENER | _BLOCKED_DOMAIN (params.detail) -> destination refused by anti-phishing policy 404 NOT_FOUND -> code missing/deleted/not yours, or unknown path; mark deleted locally 405 METHOD_NOT_ALLOWED -> see Allow header 409 EXTERNAL_ID_CONFLICT (params.id) -> decide: keep existing, or PATCH it 429 RATE_LIMITED (params.retry_after) -> sleep Retry-After 500 INTERNAL_ERROR -> retry with backoff (create with external_id so it is idempotent) ## Minimal Node.js (18+) example const BASE = 'https://qrmaker.ai/api/v1'; const H = { Authorization: `Bearer ${process.env.QRMAKER_API_TOKEN}`, 'Content-Type': 'application/json' }; const res = await fetch(`${BASE}/qrcodes?include=svg`, { method: 'POST', headers: H, body: JSON.stringify({ target_url: 'https://app.example.com/signup?ref=AB12CD', external_id: 'AB12CD', name: 'Referral AB12CD' }) }); const json = await res.json(); if (!json.success) throw new Error(json.code); // e.g. EXTERNAL_ID_CONFLICT const { id, short_link, image_version, svg } = json.data; // store these; encode only short_link ## Minimal curl curl -s -X POST "https://qrmaker.ai/api/v1/qrcodes?include=svg" -H "Authorization: Bearer $QRMAKER_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"target_url":"https://app.example.com/signup?ref=AB12CD","external_id":"AB12CD"}' curl -s "https://qrmaker.ai/api/v1/scans?since_id=0&limit=1000" -H "Authorization: Bearer $QRMAKER_API_TOKEN" curl -s "https://qrmaker.ai/api/v1/analytics/summary?from=2026-09-01&to=2026-09-30&tz=%2B08:00&group_by=code" \ -H "Authorization: Bearer $QRMAKER_API_TOKEN" ## Before writing code, find out from the user's codebase - where the unique links come from and which stable id to use as external_id; - where to store QrCode data and image files (their DB + object storage); - which scheduler / worker will run the single scan poller and persist the cursor; - whether their system is the source of truth for target URLs (then PATCH on EXTERNAL_ID_CONFLICT).