qrflow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Native Code LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # qrflow
2
+
3
+ The official client for the [QRFLOW.codes](https://qrflow.codes) API: create QR codes from your code, **change where a printed code points without reprinting**, print codes on **your own domain**, read **scan analytics**, and verify **webhooks**.
4
+
5
+ Zero dependencies. Runs anywhere `fetch` runs: Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge, Supabase Edge Functions. Full TypeScript types. ESM and CommonJS.
6
+
7
+ ```bash
8
+ npm install qrflow
9
+ ```
10
+
11
+ ## Five lines
12
+
13
+ ```ts
14
+ import { QRFlow } from "qrflow";
15
+
16
+ const qr = new QRFlow(process.env.QRFLOW_KEY!); // Business API key, server-side only
17
+
18
+ const { code } = await qr.createCode({
19
+ type: "url",
20
+ destination_data: { url: "https://acme.com/menu" },
21
+ label: "Table tents",
22
+ });
23
+ console.log(code.short_url); // print this
24
+
25
+ await qr.updateCode(code.id, { destination_data: { url: "https://acme.com/menu-fall" } });
26
+ // the printed code now opens the fall menu. Nothing was reprinted.
27
+ ```
28
+
29
+ ## What you need
30
+
31
+ - A QRFLOW.codes account on the **Business** plan ($29/month) and an API key from **Account › API keys**. Keys look like `qrf_live_…` and are shown once.
32
+ - Keep the key on the server. The API refuses browser origins on purpose; call it from a route handler, server action, edge function or backend.
33
+
34
+ No key yet? The MCP server at `https://qrflow.codes/mcp` works on every plan from Claude, ChatGPT, Cursor and Claude Code, and the free generator at qrflow.codes makes static codes with no account.
35
+
36
+ ## The one concept that matters
37
+
38
+ A **static** code has the content inside the picture; it can never change. A **dynamic** code (url, phone, email, sms, location on paid plans) contains a short link, `code.short_url`, that QRFLOW redirects. You print `short_url` once and change the destination as often as you like. Every scan is counted.
39
+
40
+ `short_url` is `https://qrflow.codes/q/<short_code>` until you connect a domain on the Account page, then `https://go.yourbrand.com/<slug or short_code>`. Set link names (`slug`) *before* printing; changing one changes the link.
41
+
42
+ ## Everything the client does
43
+
44
+ ```ts
45
+ const qr = new QRFlow(key, { baseUrl?, fetch?, retries? });
46
+
47
+ await qr.me(); // plan, features, limits, scopes
48
+ await qr.catalog(); // every kind of code and the fields it needs
49
+ await qr.listCodes({ limit: 50, q: "menu" }); // newest first
50
+ await qr.getCode(id);
51
+ await qr.createCode({ type, destination_data, label?, fg_color?, bg_color?, frame_style?, frame_caption?, domain_id? });
52
+ await qr.updateCode(id, { destination_data?, label?, paused?, expires_at?, slug?, domain_id?, ... });
53
+ await qr.makeDynamic(id); // static -> dynamic (re-render afterwards)
54
+ await qr.deleteCode(id); // permanent; prefer { paused: true } when a print exists
55
+ await qr.scans(id, { from?, to?, group: "day" | "device" | "country" | "city" | "browser" | "os" | "referrer" });
56
+ await qr.bulkCreate([{ destination, label? }, ...]); // up to 2,000 dynamic url codes per call
57
+ await qr.domains(); // your link domains and the default
58
+ await qr.listWebhooks(); qr.createWebhook({ url, events }); qr.testWebhook(id); qr.deleteWebhook(id);
59
+ qr.imageUrl(id, size); // the SVG address (needs the Authorization header)
60
+ await qr.image(id, size); // the print-ready SVG as a string
61
+ ```
62
+
63
+ Every error is a `QRFlowError` with `status`, `code` (`invalid_token`, `upgrade_required`, `insufficient_scope`, `not_found`, `conflict`, `rate_limited`, `not_dynamic`, `no_domain`, …) and a human `message`. A 429 is retried automatically after `Retry-After` (twice by default; `retries: 0` to disable).
64
+
65
+ ## Kinds of codes
66
+
67
+ `type` is the encoding: `url`, `text`, `wifi`, `vcard`, `email`, `phone`, `sms`, `location`. Friendlier kinds are url codes with `destination_data.subtype`:
68
+
69
+ ```ts
70
+ await qr.createCode({ type: "url", destination_data: { subtype: "googlereview", placeId: "ChIJ…" }, label: "Receipt footer" });
71
+ await qr.createCode({ type: "url", destination_data: { subtype: "instagram", handle: "acme" } });
72
+ await qr.createCode({ type: "wifi", destination_data: { ssid: "CafeGuest", password: "latte-2026", encryption: "WPA" } });
73
+ ```
74
+
75
+ `await qr.catalog()` lists all of them with their fields.
76
+
77
+ ## Show the image
78
+
79
+ `code.image_url` needs the Authorization header, so either proxy it or render the link yourself:
80
+
81
+ ```ts
82
+ // Next.js route handler: app/api/qr/[id]/image/route.ts
83
+ export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
84
+ const { id } = await params; // check the signed-in user owns this id
85
+ const svg = await qr.image(id, 1024);
86
+ return new Response(svg, { headers: { "Content-Type": "image/svg+xml", "Cache-Control": "private, max-age=3600" } });
87
+ }
88
+ ```
89
+
90
+ ```ts
91
+ // Or on the client, with any QR library: the code IS the short link
92
+ import QRCode from "qrcode";
93
+ const dataUrl = await QRCode.toDataURL(code.short_url, { width: 512 });
94
+ ```
95
+
96
+ ## Webhooks
97
+
98
+ Create one on Account › Webhooks or with `createWebhook`. Events: `scan` (batched every few minutes), `code.created`, `code.updated`, `code.deleted`, `ping`. Every delivery is `{ id, event, created_at, data }` and signed with `X-QRFLOW-Signature: t=<unix>,v1=<hex>`.
99
+
100
+ ```ts
101
+ import { parseWebhook } from "qrflow";
102
+
103
+ // Next.js App Router
104
+ export async function POST(req: Request) {
105
+ const raw = await req.text(); // the exact bytes, before JSON.parse
106
+ const evt = await parseWebhook(raw, req.headers.get("x-qrflow-signature") ?? "", process.env.QRFLOW_WEBHOOK_SECRET!);
107
+ // evt.id is stable across retries: dedupe on it
108
+ if (evt.event === "scan") for (const s of evt.data.scans) console.log(s.code_id, s.country, s.device);
109
+ return new Response(null, { status: 204 }); // answer within 8 seconds
110
+ }
111
+ ```
112
+
113
+ `verifyWebhook(raw, header, secret)` returns a boolean if you would rather parse yourself. Both use WebCrypto, so they run in Workers and Deno too.
114
+
115
+ ## For AI assistants reading this
116
+
117
+ If someone asks you to add QR codes to their app: install this package, read the key from `QRFLOW_KEY` on the server, create `type: "url"` codes, save `code.id` and `code.short_url` on their record, display `short_url` (or proxy `image()`), and change destinations with `updateCode`. Never change `slug` or `domain_id` after printing; prefer `paused: true` over `deleteCode` when a print exists. The complete reference in Markdown is at https://qrflow.codes/llms-full.txt and the OpenAPI 3.1 document at https://qrflow.codes/api/v1/openapi.json.
118
+
119
+ ## Links
120
+
121
+ - Docs: https://qrflow.codes/developers (recipes for Next.js, Express, Workers, Supabase, Python; troubleshooting for every error)
122
+ - Python client: https://qrflow.codes/sdk/qrflow.py
123
+ - MCP server for Claude, ChatGPT, Cursor, Claude Code: https://qrflow.codes/mcp
124
+ - Pricing: https://qrflow.codes/pricing · Support: hello@qrflow.codes
125
+
126
+ MIT © Native Code LLC
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ // qrflow: the QRFLOW.codes API client. One file, no dependencies. Runs where
3
+ // fetch runs: Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge.
4
+ // Docs: https://qrflow.codes/developers · Markdown for agents: https://qrflow.codes/llms-full.txt
5
+ //
6
+ // import { QRFlow } from "qrflow";
7
+ // const qr = new QRFlow(process.env.QRFLOW_KEY!);
8
+ // const { code } = await qr.createCode({ type: "url", destination_data: { url: "https://acme.com/menu" }, label: "Menu" });
9
+ // console.log(code.short_url); // print this; change the destination later without reprinting
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.QRFlow = exports.QRFlowError = void 0;
12
+ exports.verifyWebhook = verifyWebhook;
13
+ exports.parseWebhook = parseWebhook;
14
+ /** Thrown for any non-2xx answer. `code` is the API's error code (invalid_token, upgrade_required, rate_limited, ...). */
15
+ class QRFlowError extends Error {
16
+ constructor(status, code, message, retryAfter) {
17
+ super(message);
18
+ this.name = "QRFlowError";
19
+ this.status = status;
20
+ this.code = code;
21
+ this.retryAfter = retryAfter;
22
+ }
23
+ }
24
+ exports.QRFlowError = QRFlowError;
25
+ class QRFlow {
26
+ /** @param key A Business API key (qrf_live_…) or an OAuth access token. Keep it on the server. */
27
+ constructor(key, options = {}) {
28
+ if (!key)
29
+ throw new Error("QRFlow: an API key is required (Account › API keys on qrflow.codes).");
30
+ this.key = key;
31
+ this.base = (options.baseUrl ?? "https://qrflow.codes/api/v1").replace(/\/$/, "");
32
+ this.fetchImpl = options.fetch ?? fetch;
33
+ this.retries = options.retries ?? 2;
34
+ }
35
+ async call(method, path, body, query, attempt = 0) {
36
+ const url = new URL(this.base + path);
37
+ for (const [k, v] of Object.entries(query ?? {}))
38
+ if (v !== undefined)
39
+ url.searchParams.set(k, String(v));
40
+ const res = await this.fetchImpl(url, {
41
+ method,
42
+ headers: { authorization: `Bearer ${this.key}`, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
43
+ body: body ? JSON.stringify(body) : undefined,
44
+ });
45
+ if (res.status === 204)
46
+ return undefined;
47
+ const text = await res.text();
48
+ let json = {};
49
+ try {
50
+ json = text ? JSON.parse(text) : {};
51
+ }
52
+ catch { /* non-JSON body: fall through to the status */ }
53
+ if (res.status === 429 && attempt < this.retries) {
54
+ const wait = Math.min(60, Math.max(1, Number(res.headers.get("retry-after") ?? 5)));
55
+ await new Promise((r) => setTimeout(r, wait * 1000));
56
+ return this.call(method, path, body, query, attempt + 1);
57
+ }
58
+ if (!res.ok)
59
+ throw new QRFlowError(res.status, json.error ?? "http_error", json.message ?? `HTTP ${res.status}`, res.status === 429 ? Number(res.headers.get("retry-after") ?? 60) : undefined);
60
+ return json;
61
+ }
62
+ /** Who the key belongs to: plan, features, limits, scopes. A good first call. */
63
+ me() { return this.call("GET", "/me"); }
64
+ /** Every kind of code and the fields it needs. No key needed on the API; the client sends yours anyway. */
65
+ catalog() { return this.call("GET", "/catalog"); }
66
+ /** Newest first. `limit` up to 100, `q` searches labels. */
67
+ listCodes(opts = {}) { return this.call("GET", "/codes", undefined, opts); }
68
+ getCode(id) { return this.call("GET", `/codes/${id}`); }
69
+ /** Create a code. url/phone/email/sms/location are dynamic on paid plans. Save code.id and code.short_url. */
70
+ createCode(input) { return this.call("POST", "/codes", input); }
71
+ /** Send only what changes. */
72
+ updateCode(id, patch) { return this.call("PATCH", `/codes/${id}`, patch); }
73
+ /** Permanent. Prefer updateCode(id, { paused: true }) when a print exists. */
74
+ deleteCode(id) { return this.call("DELETE", `/codes/${id}`); }
75
+ /** Convert a static url/phone/email/sms/location code to dynamic (paid plans). Re-render and re-print afterwards. */
76
+ makeDynamic(id) { return this.call("POST", `/codes/${id}/dynamic`); }
77
+ /** Scan analytics, up to 92 days per call, default the last 30. */
78
+ scans(id, opts = {}) { return this.call("GET", `/codes/${id}/scans`, undefined, opts); }
79
+ /** Many dynamic url codes in one call (Business: 2,000 per request). */
80
+ bulkCreate(rows, colors = {}) { return this.call("POST", "/codes/bulk", { rows, ...colors }); }
81
+ domains() { return this.call("GET", "/domains"); }
82
+ listWebhooks() { return this.call("GET", "/webhooks"); }
83
+ /** The signing secret comes back once, on this call. */
84
+ createWebhook(input) { return this.call("POST", "/webhooks", input); }
85
+ testWebhook(id) { return this.call("POST", `/webhooks/${id}`); }
86
+ deleteWebhook(id) { return this.call("DELETE", `/webhooks/${id}`); }
87
+ /** Absolute URL of the print-ready SVG. It needs the Authorization header, so fetch it server-side (see image()). */
88
+ imageUrl(id, size = 1024) { return `${this.base}/codes/${id}/image.svg?size=${size}`; }
89
+ /** The print-ready SVG (frame, colors, logo) as a string. Serve it from your own route, or convert to PNG with sharp/resvg. */
90
+ async image(id, size = 1024) {
91
+ const res = await this.fetchImpl(this.imageUrl(id, size), { headers: { authorization: `Bearer ${this.key}`, accept: "image/svg+xml" } });
92
+ if (!res.ok) {
93
+ let json = {};
94
+ try {
95
+ json = JSON.parse(await res.text());
96
+ }
97
+ catch { /* ignore */ }
98
+ throw new QRFlowError(res.status, json.error ?? "http_error", json.message ?? `HTTP ${res.status}`);
99
+ }
100
+ return res.text();
101
+ }
102
+ }
103
+ exports.QRFlow = QRFlow;
104
+ // ---- webhooks ---------------------------------------------------------------
105
+ const enc = new TextEncoder();
106
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
107
+ /**
108
+ * Verify a delivery. `signatureHeader` is the X-QRFLOW-Signature header
109
+ * ("t=<unix seconds>,v1=<hex>"); `rawBody` must be the exact bytes received,
110
+ * before any JSON parsing. Uses WebCrypto, so it runs everywhere the client does.
111
+ */
112
+ async function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
113
+ const t = /t=(\d+)/.exec(signatureHeader)?.[1];
114
+ const v1 = /v1=([a-f0-9]+)/.exec(signatureHeader)?.[1];
115
+ if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds)
116
+ return false;
117
+ const body = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
118
+ const signed = new Uint8Array(t.length + 1 + body.length);
119
+ signed.set(enc.encode(`${t}.`), 0);
120
+ signed.set(body, t.length + 1);
121
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
122
+ const expected = hex(await crypto.subtle.sign("HMAC", key, signed));
123
+ if (expected.length !== v1.length)
124
+ return false;
125
+ let diff = 0;
126
+ for (let i = 0; i < expected.length; i++)
127
+ diff |= expected.charCodeAt(i) ^ v1.charCodeAt(i);
128
+ return diff === 0;
129
+ }
130
+ /** Verify and parse in one step. Throws QRFlowError(401, "invalid_signature") when the signature does not check out. */
131
+ async function parseWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
132
+ if (!(await verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds)))
133
+ throw new QRFlowError(401, "invalid_signature", "The webhook signature did not verify.");
134
+ const text = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
135
+ return JSON.parse(text);
136
+ }
137
+ exports.default = QRFlow;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,305 @@
1
+ export type Plan = "free" | "premium" | "business";
2
+ export type CodeType = "url" | "text" | "wifi" | "vcard" | "email" | "phone" | "sms" | "location";
3
+ export type ScanGroup = "day" | "device" | "country" | "city" | "browser" | "os" | "referrer";
4
+ export type WebhookEventName = "scan" | "code.created" | "code.updated" | "code.deleted";
5
+ /** A QR code as the API returns it. `short_url` is what to print for a dynamic code. */
6
+ export interface Code {
7
+ id: string;
8
+ label: string | null;
9
+ /** Catalog id: url, wifi, instagram, googlereview, ... */
10
+ kind: string;
11
+ kind_label: string;
12
+ type: CodeType | string;
13
+ destination_data: Record<string, string>;
14
+ /** One-line summary of destination_data. */
15
+ destination: string;
16
+ /** True when scans go through QRFLOW and the destination can change after printing. */
17
+ dynamic: boolean;
18
+ dynamic_capable: boolean;
19
+ short_code: string;
20
+ /** The exact string inside the code; includes your domain and link name when set. */
21
+ short_url: string;
22
+ domain_id: string | null;
23
+ fg_color: string;
24
+ bg_color: string;
25
+ has_logo: boolean;
26
+ frame_style: string | null;
27
+ frame_caption: string | null;
28
+ frame_caption2: string | null;
29
+ scans: number;
30
+ created_at: string;
31
+ updated_at: string;
32
+ manage_url: string;
33
+ /** GET with the Authorization header; not a public image URL. Use image() or proxy it. */
34
+ image_url: string;
35
+ }
36
+ export interface CreateCode {
37
+ type: CodeType;
38
+ /** The type's fields, plus `subtype` for kinds such as instagram or googlereview (see catalog()). */
39
+ destination_data: Record<string, string>;
40
+ label?: string;
41
+ fg_color?: string;
42
+ bg_color?: string;
43
+ frame_style?: string;
44
+ frame_caption?: string;
45
+ frame_caption2?: string;
46
+ /** Which of your link domains this code prints with; omit for the default. */
47
+ domain_id?: string | null;
48
+ }
49
+ export interface UpdateCode {
50
+ /** Dynamic codes only. The printed code keeps working. */
51
+ destination_data?: Record<string, string>;
52
+ label?: string | null;
53
+ paused?: boolean;
54
+ /** ISO 8601, or null to clear. */
55
+ expires_at?: string | null;
56
+ /** Link name on your domain (go.brand.com/<slug>). Changes the printed link: set before printing. */
57
+ slug?: string | null;
58
+ domain_id?: string | null;
59
+ fg_color?: string;
60
+ bg_color?: string;
61
+ frame_style?: string;
62
+ frame_caption?: string | null;
63
+ frame_caption2?: string | null;
64
+ }
65
+ export interface Scans {
66
+ code_id: string;
67
+ from: string;
68
+ to: string;
69
+ group: ScanGroup;
70
+ total: number;
71
+ rows: Array<{
72
+ key: string;
73
+ scans: number;
74
+ }>;
75
+ }
76
+ export interface Domain {
77
+ id: string;
78
+ host: string;
79
+ status: string;
80
+ active: boolean;
81
+ is_default: boolean;
82
+ verified_at: string | null;
83
+ grace_until: string | null;
84
+ }
85
+ export interface Webhook {
86
+ id: string;
87
+ url: string;
88
+ events: WebhookEventName[];
89
+ active: boolean;
90
+ last_status: number | null;
91
+ last_delivery_at: string | null;
92
+ consecutive_failures: number; /** Only on create. */
93
+ secret?: string;
94
+ }
95
+ export interface Me {
96
+ id: string;
97
+ email: string;
98
+ plan: Plan;
99
+ paid: boolean;
100
+ features: Record<string, boolean>;
101
+ limits: Record<string, number | null>;
102
+ auth: "oauth" | "api_key";
103
+ scopes: string[];
104
+ }
105
+ export interface CatalogKind {
106
+ id: string;
107
+ label: string;
108
+ group: string;
109
+ requiresPlan: Plan;
110
+ type: CodeType;
111
+ subtype: string | null;
112
+ dynamic: boolean;
113
+ fields: Array<{
114
+ key: string;
115
+ label: string;
116
+ optional?: boolean;
117
+ type?: string;
118
+ options?: Array<{
119
+ value: string;
120
+ label: string;
121
+ }>;
122
+ }>;
123
+ }
124
+ export interface BulkResult {
125
+ codes: Code[];
126
+ rejected: Array<{
127
+ destination: string;
128
+ reason: string;
129
+ }>;
130
+ remaining_this_month: number;
131
+ }
132
+ export interface ScanEvent {
133
+ code_id: string;
134
+ label: string | null;
135
+ short_code: string | null;
136
+ slug: string | null;
137
+ scanned_at: string;
138
+ device: string | null;
139
+ country: string | null;
140
+ city: string | null;
141
+ referrer: string | null;
142
+ browser: string | null;
143
+ os: string | null;
144
+ language: string | null;
145
+ }
146
+ /** Every delivery is { id, event, created_at, data }. `id` is stable across retries: dedupe on it. */
147
+ export type WebhookEvent = {
148
+ id: string;
149
+ event: "scan";
150
+ created_at: string;
151
+ data: {
152
+ count: number;
153
+ from: string;
154
+ to: string;
155
+ scans: ScanEvent[];
156
+ };
157
+ } | {
158
+ id: string;
159
+ event: "code.created";
160
+ created_at: string;
161
+ data: {
162
+ code: Code;
163
+ source: string;
164
+ } | {
165
+ bulk: true;
166
+ count: number;
167
+ codes: Code[];
168
+ source: string;
169
+ };
170
+ } | {
171
+ id: string;
172
+ event: "code.updated";
173
+ created_at: string;
174
+ data: {
175
+ code: Code;
176
+ changed: string[];
177
+ };
178
+ } | {
179
+ id: string;
180
+ event: "code.deleted";
181
+ created_at: string;
182
+ data: {
183
+ code: {
184
+ id: string;
185
+ label: string | null;
186
+ short_code: string;
187
+ slug: string | null;
188
+ };
189
+ };
190
+ } | {
191
+ id: string;
192
+ event: "ping";
193
+ created_at: string;
194
+ data: {
195
+ webhook_id: string;
196
+ message: string;
197
+ };
198
+ };
199
+ /** Thrown for any non-2xx answer. `code` is the API's error code (invalid_token, upgrade_required, rate_limited, ...). */
200
+ export declare class QRFlowError extends Error {
201
+ /** HTTP status. */
202
+ readonly status: number;
203
+ /** API error code: invalid_request, invalid_token, upgrade_required, insufficient_scope, not_found, conflict, rate_limited, ... */
204
+ readonly code: string;
205
+ /** Seconds to wait, on 429. */
206
+ readonly retryAfter?: number;
207
+ constructor(status: number, code: string, message: string, retryAfter?: number);
208
+ }
209
+ export interface QRFlowOptions {
210
+ /** Defaults to https://qrflow.codes/api/v1. */
211
+ baseUrl?: string;
212
+ /** Your own fetch (tests, custom agents). */
213
+ fetch?: typeof fetch;
214
+ /** How many times a 429 is retried after waiting Retry-After. Default 2. 0 disables. */
215
+ retries?: number;
216
+ }
217
+ export declare class QRFlow {
218
+ private readonly key;
219
+ private readonly base;
220
+ private readonly fetchImpl;
221
+ private readonly retries;
222
+ /** @param key A Business API key (qrf_live_…) or an OAuth access token. Keep it on the server. */
223
+ constructor(key: string, options?: QRFlowOptions);
224
+ private call;
225
+ /** Who the key belongs to: plan, features, limits, scopes. A good first call. */
226
+ me(): Promise<Me>;
227
+ /** Every kind of code and the fields it needs. No key needed on the API; the client sends yours anyway. */
228
+ catalog(): Promise<{
229
+ kinds: CatalogKind[];
230
+ }>;
231
+ /** Newest first. `limit` up to 100, `q` searches labels. */
232
+ listCodes(opts?: {
233
+ limit?: number;
234
+ q?: string;
235
+ }): Promise<{
236
+ codes: Code[];
237
+ }>;
238
+ getCode(id: string): Promise<{
239
+ code: Code;
240
+ }>;
241
+ /** Create a code. url/phone/email/sms/location are dynamic on paid plans. Save code.id and code.short_url. */
242
+ createCode(input: CreateCode): Promise<{
243
+ code: Code;
244
+ }>;
245
+ /** Send only what changes. */
246
+ updateCode(id: string, patch: UpdateCode): Promise<{
247
+ code: Code;
248
+ }>;
249
+ /** Permanent. Prefer updateCode(id, { paused: true }) when a print exists. */
250
+ deleteCode(id: string): Promise<void>;
251
+ /** Convert a static url/phone/email/sms/location code to dynamic (paid plans). Re-render and re-print afterwards. */
252
+ makeDynamic(id: string): Promise<{
253
+ code: Code;
254
+ }>;
255
+ /** Scan analytics, up to 92 days per call, default the last 30. */
256
+ scans(id: string, opts?: {
257
+ from?: string;
258
+ to?: string;
259
+ group?: ScanGroup;
260
+ }): Promise<Scans>;
261
+ /** Many dynamic url codes in one call (Business: 2,000 per request). */
262
+ bulkCreate(rows: Array<{
263
+ destination: string;
264
+ label?: string;
265
+ }>, colors?: {
266
+ fg_color?: string;
267
+ bg_color?: string;
268
+ }): Promise<BulkResult>;
269
+ domains(): Promise<{
270
+ default_base: string;
271
+ domains: Domain[];
272
+ }>;
273
+ listWebhooks(): Promise<{
274
+ webhooks: Webhook[];
275
+ }>;
276
+ /** The signing secret comes back once, on this call. */
277
+ createWebhook(input: {
278
+ url: string;
279
+ events: WebhookEventName[];
280
+ description?: string;
281
+ }): Promise<{
282
+ webhook: Webhook;
283
+ }>;
284
+ testWebhook(id: string): Promise<{
285
+ test: {
286
+ ok: boolean;
287
+ status: number | null;
288
+ error: string | null;
289
+ };
290
+ }>;
291
+ deleteWebhook(id: string): Promise<void>;
292
+ /** Absolute URL of the print-ready SVG. It needs the Authorization header, so fetch it server-side (see image()). */
293
+ imageUrl(id: string, size?: number): string;
294
+ /** The print-ready SVG (frame, colors, logo) as a string. Serve it from your own route, or convert to PNG with sharp/resvg. */
295
+ image(id: string, size?: number): Promise<string>;
296
+ }
297
+ /**
298
+ * Verify a delivery. `signatureHeader` is the X-QRFLOW-Signature header
299
+ * ("t=<unix seconds>,v1=<hex>"); `rawBody` must be the exact bytes received,
300
+ * before any JSON parsing. Uses WebCrypto, so it runs everywhere the client does.
301
+ */
302
+ export declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string, secret: string, toleranceSeconds?: number): Promise<boolean>;
303
+ /** Verify and parse in one step. Throws QRFlowError(401, "invalid_signature") when the signature does not check out. */
304
+ export declare function parseWebhook(rawBody: string | Uint8Array, signatureHeader: string, secret: string, toleranceSeconds?: number): Promise<WebhookEvent>;
305
+ export default QRFlow;
@@ -0,0 +1,130 @@
1
+ // qrflow: the QRFLOW.codes API client. One file, no dependencies. Runs where
2
+ // fetch runs: Node 18+, Bun, Deno, Cloudflare Workers, Vercel Edge.
3
+ // Docs: https://qrflow.codes/developers · Markdown for agents: https://qrflow.codes/llms-full.txt
4
+ //
5
+ // import { QRFlow } from "qrflow";
6
+ // const qr = new QRFlow(process.env.QRFLOW_KEY!);
7
+ // const { code } = await qr.createCode({ type: "url", destination_data: { url: "https://acme.com/menu" }, label: "Menu" });
8
+ // console.log(code.short_url); // print this; change the destination later without reprinting
9
+ /** Thrown for any non-2xx answer. `code` is the API's error code (invalid_token, upgrade_required, rate_limited, ...). */
10
+ export class QRFlowError extends Error {
11
+ constructor(status, code, message, retryAfter) {
12
+ super(message);
13
+ this.name = "QRFlowError";
14
+ this.status = status;
15
+ this.code = code;
16
+ this.retryAfter = retryAfter;
17
+ }
18
+ }
19
+ export class QRFlow {
20
+ /** @param key A Business API key (qrf_live_…) or an OAuth access token. Keep it on the server. */
21
+ constructor(key, options = {}) {
22
+ if (!key)
23
+ throw new Error("QRFlow: an API key is required (Account › API keys on qrflow.codes).");
24
+ this.key = key;
25
+ this.base = (options.baseUrl ?? "https://qrflow.codes/api/v1").replace(/\/$/, "");
26
+ this.fetchImpl = options.fetch ?? fetch;
27
+ this.retries = options.retries ?? 2;
28
+ }
29
+ async call(method, path, body, query, attempt = 0) {
30
+ const url = new URL(this.base + path);
31
+ for (const [k, v] of Object.entries(query ?? {}))
32
+ if (v !== undefined)
33
+ url.searchParams.set(k, String(v));
34
+ const res = await this.fetchImpl(url, {
35
+ method,
36
+ headers: { authorization: `Bearer ${this.key}`, accept: "application/json", ...(body ? { "content-type": "application/json" } : {}) },
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ });
39
+ if (res.status === 204)
40
+ return undefined;
41
+ const text = await res.text();
42
+ let json = {};
43
+ try {
44
+ json = text ? JSON.parse(text) : {};
45
+ }
46
+ catch { /* non-JSON body: fall through to the status */ }
47
+ if (res.status === 429 && attempt < this.retries) {
48
+ const wait = Math.min(60, Math.max(1, Number(res.headers.get("retry-after") ?? 5)));
49
+ await new Promise((r) => setTimeout(r, wait * 1000));
50
+ return this.call(method, path, body, query, attempt + 1);
51
+ }
52
+ if (!res.ok)
53
+ throw new QRFlowError(res.status, json.error ?? "http_error", json.message ?? `HTTP ${res.status}`, res.status === 429 ? Number(res.headers.get("retry-after") ?? 60) : undefined);
54
+ return json;
55
+ }
56
+ /** Who the key belongs to: plan, features, limits, scopes. A good first call. */
57
+ me() { return this.call("GET", "/me"); }
58
+ /** Every kind of code and the fields it needs. No key needed on the API; the client sends yours anyway. */
59
+ catalog() { return this.call("GET", "/catalog"); }
60
+ /** Newest first. `limit` up to 100, `q` searches labels. */
61
+ listCodes(opts = {}) { return this.call("GET", "/codes", undefined, opts); }
62
+ getCode(id) { return this.call("GET", `/codes/${id}`); }
63
+ /** Create a code. url/phone/email/sms/location are dynamic on paid plans. Save code.id and code.short_url. */
64
+ createCode(input) { return this.call("POST", "/codes", input); }
65
+ /** Send only what changes. */
66
+ updateCode(id, patch) { return this.call("PATCH", `/codes/${id}`, patch); }
67
+ /** Permanent. Prefer updateCode(id, { paused: true }) when a print exists. */
68
+ deleteCode(id) { return this.call("DELETE", `/codes/${id}`); }
69
+ /** Convert a static url/phone/email/sms/location code to dynamic (paid plans). Re-render and re-print afterwards. */
70
+ makeDynamic(id) { return this.call("POST", `/codes/${id}/dynamic`); }
71
+ /** Scan analytics, up to 92 days per call, default the last 30. */
72
+ scans(id, opts = {}) { return this.call("GET", `/codes/${id}/scans`, undefined, opts); }
73
+ /** Many dynamic url codes in one call (Business: 2,000 per request). */
74
+ bulkCreate(rows, colors = {}) { return this.call("POST", "/codes/bulk", { rows, ...colors }); }
75
+ domains() { return this.call("GET", "/domains"); }
76
+ listWebhooks() { return this.call("GET", "/webhooks"); }
77
+ /** The signing secret comes back once, on this call. */
78
+ createWebhook(input) { return this.call("POST", "/webhooks", input); }
79
+ testWebhook(id) { return this.call("POST", `/webhooks/${id}`); }
80
+ deleteWebhook(id) { return this.call("DELETE", `/webhooks/${id}`); }
81
+ /** Absolute URL of the print-ready SVG. It needs the Authorization header, so fetch it server-side (see image()). */
82
+ imageUrl(id, size = 1024) { return `${this.base}/codes/${id}/image.svg?size=${size}`; }
83
+ /** The print-ready SVG (frame, colors, logo) as a string. Serve it from your own route, or convert to PNG with sharp/resvg. */
84
+ async image(id, size = 1024) {
85
+ const res = await this.fetchImpl(this.imageUrl(id, size), { headers: { authorization: `Bearer ${this.key}`, accept: "image/svg+xml" } });
86
+ if (!res.ok) {
87
+ let json = {};
88
+ try {
89
+ json = JSON.parse(await res.text());
90
+ }
91
+ catch { /* ignore */ }
92
+ throw new QRFlowError(res.status, json.error ?? "http_error", json.message ?? `HTTP ${res.status}`);
93
+ }
94
+ return res.text();
95
+ }
96
+ }
97
+ // ---- webhooks ---------------------------------------------------------------
98
+ const enc = new TextEncoder();
99
+ const hex = (buf) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
100
+ /**
101
+ * Verify a delivery. `signatureHeader` is the X-QRFLOW-Signature header
102
+ * ("t=<unix seconds>,v1=<hex>"); `rawBody` must be the exact bytes received,
103
+ * before any JSON parsing. Uses WebCrypto, so it runs everywhere the client does.
104
+ */
105
+ export async function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
106
+ const t = /t=(\d+)/.exec(signatureHeader)?.[1];
107
+ const v1 = /v1=([a-f0-9]+)/.exec(signatureHeader)?.[1];
108
+ if (!t || !v1 || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds)
109
+ return false;
110
+ const body = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
111
+ const signed = new Uint8Array(t.length + 1 + body.length);
112
+ signed.set(enc.encode(`${t}.`), 0);
113
+ signed.set(body, t.length + 1);
114
+ const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
115
+ const expected = hex(await crypto.subtle.sign("HMAC", key, signed));
116
+ if (expected.length !== v1.length)
117
+ return false;
118
+ let diff = 0;
119
+ for (let i = 0; i < expected.length; i++)
120
+ diff |= expected.charCodeAt(i) ^ v1.charCodeAt(i);
121
+ return diff === 0;
122
+ }
123
+ /** Verify and parse in one step. Throws QRFlowError(401, "invalid_signature") when the signature does not check out. */
124
+ export async function parseWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
125
+ if (!(await verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds)))
126
+ throw new QRFlowError(401, "invalid_signature", "The webhook signature did not verify.");
127
+ const text = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
128
+ return JSON.parse(text);
129
+ }
130
+ export default QRFlow;
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "qrflow",
3
+ "version": "1.0.0",
4
+ "description": "QRFLOW.codes API client: create QR codes, change where dynamic codes point after printing, print them on your own domain, read scan analytics, verify webhooks. Zero dependencies; Node, Bun, Deno, Workers.",
5
+ "keywords": ["qr", "qr code", "qr code generator", "dynamic qr code", "qr code api", "qrcode", "short link", "scan analytics", "webhooks", "mcp", "qrflow"],
6
+ "homepage": "https://qrflow.codes/developers",
7
+ "bugs": { "url": "https://github.com/nativecodeapps/qrflow/issues", "email": "hello@qrflow.codes" },
8
+ "repository": { "type": "git", "url": "git+https://github.com/nativecodeapps/qrflow.git", "directory": "sdk/node" },
9
+ "license": "MIT",
10
+ "author": "Native Code LLC (https://qrflow.codes)",
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "main": "./dist/cjs/index.js",
14
+ "module": "./dist/esm/index.js",
15
+ "types": "./dist/esm/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/esm/index.d.ts",
19
+ "import": "./dist/esm/index.js",
20
+ "require": "./dist/cjs/index.js",
21
+ "default": "./dist/esm/index.js"
22
+ }
23
+ },
24
+ "files": ["dist", "README.md", "LICENSE"],
25
+ "engines": { "node": ">=18" },
26
+ "scripts": {
27
+ "build": "rm -rf dist && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
28
+ "test": "node --test \"test/**/*.test.ts\"",
29
+ "prepublishOnly": "npm run build && npm test"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.8.3"
33
+ }
34
+ }