Files

780 lines
42 KiB
HTML
Raw Permalink Normal View History

2026-03-06 10:16:10 -06:00
<!DOCTYPE html>
<html lang="en">
<script>
(function(){var t=localStorage.getItem('qrknit-theme');if(t&&t!=='default')document.documentElement.setAttribute('data-theme',t);})();
</script>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Reference — QRknit</title>
<meta name="description" content="Complete REST API reference for QRknit. Endpoints for authentication, link management, QR code generation, analytics, and admin operations.">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://qrknit.com/api-docs">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://qrknit.com/api-docs">
<meta property="og:site_name" content="QRknit">
<meta property="og:title" content="API Reference — QRknit">
<meta property="og:description" content="Complete REST API reference for QRknit. Endpoints for authentication, link management, QR code generation, analytics, and admin operations.">
<meta property="og:image" content="https://qrknit.com/static/og-image.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/qk-ico.png">
<link rel="apple-touch-icon" href="/static/qk-ico.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&family=Barlow:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #111118;
--surface2: #181824;
--surface3: #1e1e2e;
--border: rgba(255,255,255,0.07);
--accent: #00b8d4;
--accent-rgb: 0,184,212;
--accent-grad: #1a52c8;
--accent-grad-rgb: 26,82,200;
--accent2: #ff5e62;
--accent3: #7c6af7;
--accent3-rgb: 124,106,247;
--accent3-light: #a89cf7;
--text: #f0f0f8;
--muted: #808098;
--radius: 12px;
--font-head: 'Orbitron', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--font-body: 'Barlow', sans-serif;
}
[data-theme="gold"] { --accent:#f5a623; --accent-rgb:245,166,35; --accent-grad:#c47f17; --accent-grad-rgb:196,127,23; --accent3:#d4a847; --accent3-rgb:212,168,71; --accent3-light:#e8c96a; }
[data-theme="green"] { --accent:#00c896; --accent-rgb:0,200,150; --accent-grad:#007a58; --accent-grad-rgb:0,122,88; --accent3:#4caf7a; --accent3-rgb:76,175,122; --accent3-light:#7fd9a4; }
[data-theme="red"] { --accent:#ff4757; --accent-rgb:255,71,87; --accent-grad:#c0392b; --accent-grad-rgb:192,57,43; --accent3:#ff6b7a; --accent3-rgb:255,107,122; --accent3-light:#ff9daa; }
html { scroll-behavior: smooth; }
body { background:var(--bg); color:var(--text); font-family:var(--font-body); font-size:15px; line-height:1.6; min-height:100vh; overflow-x:hidden; }
body::before {
content:''; position:fixed; inset:0; pointer-events:none; z-index:0;
background:
radial-gradient(ellipse 90% 60% at 15% -5%, rgba(var(--accent-rgb),.09) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 90% 90%, rgba(var(--accent-grad-rgb),.08) 0%, transparent 55%),
radial-gradient(ellipse 50% 50% at 50% 50%, rgba(var(--accent-rgb),.03) 0%, transparent 60%);
}
/* ── HEADER ── */
header {
position:sticky; top:0; z-index:200;
display:flex; align-items:center; justify-content:space-between;
padding:0 40px; height:64px;
background:rgba(10,10,15,.88); backdrop-filter:blur(20px);
border-bottom:1px solid var(--border);
}
.logo { font-family:var(--font-head); font-size:18px; font-weight:700; letter-spacing:.04em; text-decoration:none; color:var(--text); display:inline-flex; align-items:center; gap:8px; }
.logo-dot-text { color:var(--accent); text-shadow:0 0 12px var(--accent); }
.logo-icon-wrap { position:relative; display:inline-flex; align-items:center; flex-shrink:0; isolation:isolate; }
.logo-icon { height:26px; width:26px; object-fit:contain; border-radius:3px; display:block; }
.logo-icon-tint { position:absolute; inset:0; background:var(--accent); mix-blend-mode:hue; border-radius:3px; pointer-events:none; }
.header-right { display:flex; align-items:center; gap:12px; }
.theme-picker { display:flex; align-items:center; gap:6px; padding:0 4px; }
.theme-swatch { width:14px; height:14px; border-radius:50%; border:2px solid transparent; cursor:pointer; padding:0; transition:transform .2s,border-color .2s; }
.theme-swatch:hover { transform:scale(1.2); }
.theme-swatch.active { border-color:var(--text); transform:scale(1.15); }
.theme-swatch[data-theme="default"] { background:linear-gradient(135deg,#00b8d4,#1a52c8); }
.theme-swatch[data-theme="gold"] { background:linear-gradient(135deg,#f5a623,#c47f17); }
.theme-swatch[data-theme="green"] { background:linear-gradient(135deg,#00c896,#007a58); }
.theme-swatch[data-theme="red"] { background:linear-gradient(135deg,#ff4757,#c0392b); }
/* ── BUTTONS ── */
.btn-primary { background:linear-gradient(135deg,var(--accent),var(--accent-grad)); color:#fff; border:none; cursor:pointer; font-family:var(--font-head); font-weight:700; font-size:12px; letter-spacing:.04em; padding:12px 22px; border-radius:var(--radius); transition:all .2s; white-space:nowrap; text-decoration:none; display:inline-flex; align-items:center; gap:6px; }
.btn-primary:hover { transform:translateY(-1px); box-shadow:0 8px 24px rgba(var(--accent-rgb),.35); }
.btn-outline { background:transparent; border:1px solid var(--border); color:var(--text); font-family:var(--font-mono); font-size:12px; padding:11px 20px; border-radius:var(--radius); cursor:pointer; transition:all .2s; text-decoration:none; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
.btn-outline:hover { border-color:rgba(var(--accent-rgb),.5); color:var(--accent); }
/* ── PAGE LAYOUT ── */
.page-wrap { max-width:1080px; margin:0 auto; padding:0 40px 80px; position:relative; z-index:1; }
/* ── HERO ── */
.hero { text-align:center; padding:80px 40px 60px; position:relative; z-index:1; overflow:hidden; }
.hero-eyebrow { font-family:var(--font-mono); font-size:11px; letter-spacing:.16em; color:var(--accent); text-transform:uppercase; margin-bottom:20px; }
.hero h1 { font-family:var(--font-head); font-weight:900; line-height:1.05; font-size:clamp(30px,5vw,56px); letter-spacing:.04em; margin-bottom:20px; }
.hero-accent { background:linear-gradient(135deg,var(--accent) 0%,var(--accent-grad) 100%); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
.hero-sub { font-size:clamp(14px,2vw,17px); color:var(--muted); max-width:560px; margin:0 auto 36px; font-weight:300; line-height:1.7; }
.hero-note { font-family:var(--font-mono); font-size:11px; color:var(--muted); letter-spacing:.06em; }
.hero-note span { color:var(--accent); }
.hero::after {
content:''; position:absolute; left:50%; top:40px; transform:translateX(-50%);
width:500px; height:500px; border-radius:50%;
background:radial-gradient(ellipse, rgba(var(--accent-rgb),.06) 0%, transparent 70%);
pointer-events:none; z-index:-1;
}
/* ── TOC (Table of Contents) ── */
.toc-bar {
display:flex; align-items:center; gap:8px; flex-wrap:wrap;
padding:16px 0 32px; border-bottom:1px solid var(--border); margin-bottom:48px;
}
.toc-label { font-family:var(--font-mono); font-size:10px; letter-spacing:.12em; color:var(--muted); text-transform:uppercase; margin-right:4px; }
.toc-link {
font-family:var(--font-mono); font-size:11px; color:var(--muted); text-decoration:none;
background:var(--surface); border:1px solid var(--border); border-radius:6px;
padding:5px 12px; transition:all .2s; letter-spacing:.04em;
}
.toc-link:hover { border-color:rgba(var(--accent-rgb),.4); color:var(--accent); }
/* ── SECTION HEADINGS ── */
.api-section { margin-bottom:60px; }
.api-section-header { display:flex; align-items:center; gap:14px; margin-bottom:24px; }
.api-section-icon {
width:38px; height:38px; border-radius:10px; flex-shrink:0;
background:rgba(var(--accent-rgb),.1); border:1px solid rgba(var(--accent-rgb),.2);
display:flex; align-items:center; justify-content:center;
}
.api-section-icon svg { color:var(--accent); }
.api-section-title { font-family:var(--font-head); font-size:16px; font-weight:700; letter-spacing:.04em; }
.api-section-desc { font-size:13px; color:var(--muted); margin-bottom:20px; line-height:1.65; }
/* ── ENDPOINT TABLE ── */
.endpoint-table { width:100%; border-collapse:collapse; }
.endpoint-table thead tr {
border-bottom:1px solid var(--border);
}
.endpoint-table thead th {
font-family:var(--font-mono); font-size:10px; letter-spacing:.12em; color:var(--muted);
text-transform:uppercase; text-align:left; padding:10px 14px; font-weight:400;
}
.endpoint-table tbody tr {
border-bottom:1px solid var(--border);
transition:background .15s;
}
.endpoint-table tbody tr:last-child { border-bottom:none; }
.endpoint-table tbody tr:hover { background:rgba(255,255,255,.025); }
.endpoint-table td { padding:14px; vertical-align:top; }
/* Method badges */
.method {
font-family:var(--font-mono); font-size:10px; font-weight:700; letter-spacing:.08em;
padding:3px 9px; border-radius:5px; white-space:nowrap; display:inline-block;
}
.method-get { background:rgba(0,200,120,.12); color:#00c87a; border:1px solid rgba(0,200,120,.25); }
.method-post { background:rgba(var(--accent-rgb),.12); color:var(--accent); border:1px solid rgba(var(--accent-rgb),.25); }
.method-patch { background:rgba(245,166,35,.12); color:#f5a623; border:1px solid rgba(245,166,35,.25); }
.method-delete { background:rgba(255,94,98,.12); color:#ff5e62; border:1px solid rgba(255,94,98,.25); }
/* Endpoint path */
.ep-path {
font-family:var(--font-mono); font-size:12px; color:var(--text);
background:var(--surface2); border:1px solid var(--border);
border-radius:6px; padding:4px 10px; white-space:nowrap; display:inline-block;
}
.ep-path .ep-param { color:var(--accent3-light); }
/* Auth badge */
.auth-badge {
font-family:var(--font-mono); font-size:9px; letter-spacing:.08em;
padding:2px 8px; border-radius:4px; white-space:nowrap; display:inline-block;
}
.auth-yes { background:rgba(var(--accent-rgb),.1); color:var(--accent); border:1px solid rgba(var(--accent-rgb),.2); }
.auth-admin { background:rgba(124,106,247,.12); color:var(--accent3-light); border:1px solid rgba(124,106,247,.2); }
.auth-none { background:var(--surface2); color:var(--muted); border:1px solid var(--border); }
/* Description */
.ep-desc { font-size:13px; color:var(--muted); line-height:1.6; }
.ep-desc code {
font-family:var(--font-mono); font-size:11px; color:var(--text);
background:var(--surface2); border:1px solid var(--border);
border-radius:4px; padding:1px 6px;
}
.ep-desc .ep-tag { color:var(--accent3-light); font-style:italic; }
/* ── INTRO BOX ── */
.intro-box {
background:var(--surface); border:1px solid var(--border); border-radius:var(--radius);
padding:24px 28px; margin-bottom:48px; position:relative; overflow:hidden;
}
.intro-box::before {
content:''; position:absolute; top:0; left:0; right:0; height:2px;
background:linear-gradient(90deg,var(--accent),var(--accent-grad));
}
.intro-box h2 { font-family:var(--font-head); font-size:13px; font-weight:700; letter-spacing:.05em; margin-bottom:10px; }
.intro-box p { font-size:13px; color:var(--muted); line-height:1.7; }
.intro-box code {
font-family:var(--font-mono); font-size:12px; color:var(--accent);
background:rgba(var(--accent-rgb),.08); border:1px solid rgba(var(--accent-rgb),.2);
border-radius:4px; padding:1px 7px;
}
/* ── FOOTER ── */
footer {
border-top:1px solid var(--border); padding:32px 40px;
display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px;
position:relative; z-index:1;
}
.footer-logo { font-family:var(--font-head); font-size:14px; font-weight:700; color:var(--muted); letter-spacing:.04em; display:flex; align-items:center; gap:6px; }
.footer-logo span { color:var(--accent); }
.footer-links { display:flex; gap:20px; align-items:center; }
.footer-links a { font-family:var(--font-mono); font-size:11px; color:var(--muted); text-decoration:none; letter-spacing:.06em; transition:color .2s; }
.footer-links a:hover { color:var(--accent); }
.footer-note { font-family:var(--font-mono); font-size:10px; color:var(--muted); opacity:.5; letter-spacing:.05em; }
/* ── REVEAL ANIMATIONS ── */
.reveal { opacity:0; transform:translateY(20px); transition:opacity .5s ease, transform .5s ease; }
.reveal.visible { opacity:1; transform:none; }
.reveal-delay-1 { transition-delay:.08s; }
.reveal-delay-2 { transition-delay:.16s; }
.reveal-delay-3 { transition-delay:.24s; }
/* ── RESPONSIVE ── */
@media(max-width:900px) {
header { padding:0 20px; }
.hero { padding:60px 20px 40px; }
.page-wrap { padding:0 20px 60px; }
.endpoint-table { display:block; overflow-x:auto; }
footer { padding:24px 20px; }
}
@media(max-width:600px) {
.toc-bar { gap:6px; }
.ep-path { white-space:normal; word-break:break-all; }
}
</style>
</head>
<body>
<!-- ── HEADER ── -->
<header>
<a class="logo" href="/">
<span class="logo-icon-wrap"><img class="logo-icon" src="/static/qk-ico.png" alt=""><span class="logo-icon-tint"></span></span>QR<span class="logo-dot-text">knit</span>
</a>
<div class="header-right">
<div class="theme-picker">
<button class="theme-swatch" data-theme="default" title="Default" aria-label="Default blue theme"></button>
<button class="theme-swatch" data-theme="gold" title="Gold" aria-label="Gold theme"></button>
<button class="theme-swatch" data-theme="green" title="Green" aria-label="Green theme"></button>
<button class="theme-swatch" data-theme="red" title="Red" aria-label="Red theme"></button>
</div>
<a class="btn-outline" href="/">Home</a>
<a class="btn-primary" href="/app">Open App →</a>
</div>
</header>
<!-- ── HERO ── -->
<section class="hero">
<div class="hero-eyebrow reveal">REST API · Session Auth · JSON</div>
<h1 class="reveal reveal-delay-1"><span class="hero-accent">API</span> Reference</h1>
<p class="hero-sub reveal reveal-delay-2">
Complete reference for the QRknit REST API. Manage links, generate QR codes, pull analytics, and administer users — all from your own code.
</p>
<div class="hero-note reveal reveal-delay-3">Base URL: <span>https://your-instance.com</span> &nbsp;·&nbsp; Format: <span>application/json</span></div>
</section>
<!-- ── MAIN CONTENT ── -->
<div class="page-wrap">
<!-- Intro box -->
<div class="intro-box reveal">
<h2>Authentication</h2>
<p>
Authenticated endpoints accept either of two credentials. <strong>Session cookie</strong>: log in via the web UI
or call <code>POST /api/auth/login</code> with <code>{"username": "…", "password": "…"}</code> — the server sets
an HttpOnly session cookie valid for 30 days. <strong>API key</strong>: create a key in the dashboard (or via
<code>POST /api/keys</code>) and send it as <code>Authorization: Bearer qrk_…</code>. Keys carry a
<code>read</code> or <code>write</code> scope — read-only keys may only call GET endpoints. Endpoints marked
<strong>Admin</strong> additionally require the authenticated user to have admin privileges.
2026-03-06 10:16:10 -06:00
</p>
</div>
<!-- TOC -->
<nav class="toc-bar reveal">
<span class="toc-label">Jump to</span>
<a class="toc-link" href="#auth">Auth</a>
<a class="toc-link" href="#links">Links</a>
<a class="toc-link" href="#utilities">Utilities</a>
<a class="toc-link" href="#admin">Admin</a>
<a class="toc-link" href="#contact">Contact</a>
<a class="toc-link" href="#public">Public</a>
</nav>
<!-- ── AUTH ── -->
<section class="api-section reveal" id="auth">
<div class="api-section-header">
<div class="api-section-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
</div>
<div class="api-section-title">Auth</div>
</div>
<p class="api-section-desc">Session-based login. A successful login sets a 30-day HttpOnly cookie used for all authenticated requests.</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/auth/login</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Login — body: <code>{"username": "…", "password": "…"}</code>. Sets session cookie and returns <code>plan</code>, <code>plan_limits</code>, and <code>org_id</code>.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/auth/logout</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Clear the current session and invalidate the cookie.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/auth/me</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Returns <code>{"authenticated": true, "username": "…", "is_admin": bool, "org_id": int|null, "plan": "…", "plan_limits": {…}}</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/keys</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">List your API keys — prefix, scope, created and last-used timestamps. Full tokens are never returned.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/keys</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Create an API key. Body: <code>{"name": "…", "scope": "read"|"write"}</code>. The response includes the full <code>token</code> <strong>exactly once</strong> — only a hash is stored.</td>
</tr>
<tr>
<td><span class="method method-delete">DELETE</span></td>
<td><span class="ep-path">/api/keys/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Revoke an API key immediately. Admins may revoke any user's key.</td>
</tr>
2026-03-06 10:16:10 -06:00
</tbody>
</table>
</section>
<!-- ── LINKS ── -->
<section class="api-section reveal" id="links">
<div class="api-section-header">
<div class="api-section-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
</div>
<div class="api-section-title">Links</div>
</div>
<p class="api-section-desc">Create, read, update, and delete short links. Org members see all links within their org. Admin accounts see only their own links by default, with optional <code>?user=</code> filtering.</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/shorten</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Create a short link. Body: <code>{"url": "…", "custom_code": "…", "title": "…", "expires_at": "…", "tags": […], "redirect_type": 301|302, "password": "…"}</code>. All fields except <code>url</code> are optional — redirects default to 302; a <code>password</code> gates the link behind a branded unlock page.</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/links</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">List links. Query params: <code>?q=</code> (search), <code>?tag=</code>, <code>?page=</code>, <code>?per_page=</code>. Admin can add <code>?user=&lt;username&gt;</code> to filter by user.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Link detail — includes <code>created_by</code> username.</td>
</tr>
<tr>
<td><span class="method method-patch">PATCH</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Edit link — updatable fields: <code>url</code>, <code>title</code>, <code>expires_at</code>, <code>tags</code>, <code>is_pinned</code>, <code>redirect_type</code> (301/302), <code>password</code> (empty string removes protection; omit to leave unchanged).</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-delete">DELETE</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Delete a link.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span>/analytics</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Click analytics. Query: <code>?days=7|30|90</code> (capped to plan's max window), <code>?exclude_bots=1</code> to filter crawler/bot traffic. Returns <code>daily</code> (each day includes <code>unique</code> visitors), <code>unique_visitors</code>, <code>bot_clicks</code>, <code>referrers</code>, <code>devices</code>, <code>browsers</code>, <code>countries</code>, <code>heatmap</code> (7×24 array), and <code>max_days</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/analytics/aggregate</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Aggregate analytics across your whole account (or org). Query: <code>?days=</code>, <code>?tag=</code> to scope to one tag, <code>?exclude_bots=1</code>. Returns <code>daily</code>, <code>unique_visitors</code>, <code>referrers</code>, <code>devices</code>, <code>browsers</code>, <code>countries</code>, and <code>top_links</code>.</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/links/<span class="ep-param">:code</span>/clicks/export</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Download raw click events as CSV. Columns: <code>timestamp</code>, <code>referrer</code>, <code>device</code>, <code>browser</code>, <code>country</code>.</td>
</tr>
</tbody>
</table>
</section>
<!-- ── UTILITIES ── -->
<section class="api-section reveal" id="utilities">
<div class="api-section-header">
<div class="api-section-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"></path><path d="M15.54 8.46a5 5 0 0 1 0 7.07M8.46 8.46a5 5 0 0 0 0 7.07"></path></svg>
</div>
<div class="api-section-title">Utilities</div>
</div>
<p class="api-section-desc">Stats, plan usage, tags, QR code generation, bulk operations, and CSV import/export.</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/stats</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Dashboard stats: total links, total clicks, clicks in last 7 days, top 5 links, and a 30-day <code>daily</code> click array.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/plan</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Current user's effective plan, limits, and live usage — <code>{plan, limits: {max_links, monthly_clicks, analytics_days}, usage: {active_links, monthly_clicks}}</code>. Org members see pooled usage across all org members.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/tags</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Tags scoped to the requesting user — own tags for solo users and admins; all org member tags for org members.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/fetch-title</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Server-side page title fetch. Query: <code>?url=&lt;url&gt;</code>. Returns <code>{"title": "…"}</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/qr/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">QR for a short link. Query params: <code>?format=png|svg|pdf</code>, <code>?ec=L|M|Q|H</code> (error correction), <code>?fg=</code>, <code>?bg=</code>, <code>?size=</code>, <code>?style=</code> (raster formats only), <code>?download=1</code> for an attachment.</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/qr/custom</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">QR for any URL. Query: <code>?url=</code> plus the same params as above (<code>format</code>, <code>ec</code>, <code>fg</code>, <code>bg</code>, <code>size</code>, <code>style</code>, <code>download</code>). Styles: <code>square</code>, <code>rounded</code>, <code>dots</code>, <code>vertical</code>, <code>horizontal</code>.</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/qr/custom</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">QR with logo overlay. Body: <code>{url, fg, bg, size, style, ec, format, logo}</code> where <code>logo</code> is a base64-encoded image string (forces error correction H; SVG embeds the logo as a data URI).</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/links/bulk</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Bulk operations. Body: <code>{"action": "delete"|"tag"|"expire", "codes": […]}</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/links/export</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Download all links as CSV.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/links/import</span></td>
<td><span class="auth-badge auth-yes">Session</span></td>
<td class="ep-desc">Import links from CSV. Body: <code>{"csv": "…"}</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/health</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Health check — returns <code>{"status": "ok"}</code>. Used by Docker and uptime monitors.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/config</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Public deployment config — returns non-sensitive values like <code>app_name</code> and <code>base_url</code>.</td>
</tr>
</tbody>
</table>
</section>
<!-- ── ADMIN ── -->
<section class="api-section reveal" id="admin">
<div class="api-section-header">
<div class="api-section-icon" style="background:rgba(var(--accent3-rgb),.1);border-color:rgba(var(--accent3-rgb),.2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--accent3-light)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
</div>
<div class="api-section-title" style="color:var(--accent3-light)">Admin</div>
</div>
<p class="api-section-desc">All Admin endpoints require an authenticated admin session. These manage users, organizations, and the message inbox.</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/users</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">List all users with link counts, plan, org name, and org plan.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/admin/users</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Create user. Body: <code>{"username": "…", "password": "…", "is_admin": bool, "plan": "…"}</code>.</td>
</tr>
<tr>
<td><span class="method method-patch">PATCH</span></td>
<td><span class="ep-path">/api/admin/users/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Update plan, admin status, and/or org. Body: <code>{plan?, is_admin?, org_id?}</code>. Set <code>org_id: null</code> to remove from org.</td>
</tr>
<tr>
<td><span class="method method-delete">DELETE</span></td>
<td><span class="ep-path">/api/admin/users/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Delete user. Cannot delete yourself.</td>
</tr>
<tr>
<td><span class="method method-patch">PATCH</span></td>
<td><span class="ep-path">/api/admin/users/<span class="ep-param">:id</span>/password</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Change user password. Body: <code>{"password": "…"}</code>.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/organizations</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">List all organizations with member counts and plan.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/admin/organizations</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Create organization. Body: <code>{"name": "…", "plan": "…"}</code>.</td>
</tr>
<tr>
<td><span class="method method-patch">PATCH</span></td>
<td><span class="ep-path">/api/admin/organizations/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Update organization name and/or plan. Body: <code>{name?, plan?}</code>.</td>
</tr>
<tr>
<td><span class="method method-delete">DELETE</span></td>
<td><span class="ep-path">/api/admin/organizations/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Delete organization. Members are unassigned but not deleted.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/messages</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">List all contact/portal messages, newest first.</td>
</tr>
<tr>
<td><span class="method method-delete">DELETE</span></td>
<td><span class="ep-path">/api/admin/messages/<span class="ep-param">:id</span></span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Delete a message.</td>
</tr>
<tr>
<td><span class="method method-patch">PATCH</span></td>
<td><span class="ep-path">/api/admin/messages/<span class="ep-param">:id</span>/read</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Mark a message as read.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/overview</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Platform overview — totals (users, orgs, links, clicks), a 30-day click series, top links, and per-user quota usage with near-limit flags.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/audit</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Audit log of administrative actions (user/org changes, password resets, key events, purges). Query: <code>?limit=</code> (default 200).</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/api/admin/keys</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">List all API keys across users — prefix, scope, last-used timestamp, revocation state.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/admin/purge</span></td>
<td><span class="auth-badge auth-admin">Admin</span></td>
<td class="ep-desc">Hard-delete soft-deleted links older than <code>{"days": N}</code> — frees their short codes and removes click history. Irreversible.</td>
</tr>
2026-03-06 10:16:10 -06:00
</tbody>
</table>
</section>
<!-- ── CONTACT ── -->
<section class="api-section reveal" id="contact">
<div class="api-section-header">
<div class="api-section-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
</div>
<div class="api-section-title">Contact</div>
</div>
<p class="api-section-desc">Public endpoint for submitting contact and support messages. No authentication required.</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/api/contact</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Submit a contact message. Body: <code>{"name": "…", "email": "…", "subject": "…", "body": "…"}</code>.</td>
</tr>
</tbody>
</table>
</section>
<!-- ── PUBLIC ── -->
<section class="api-section reveal" id="public">
<div class="api-section-header">
<div class="api-section-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
</div>
<div class="api-section-title">Public Redirects</div>
</div>
<p class="api-section-desc">Short link redirection and SEO endpoints. No authentication required.</p>
<table class="endpoint-table">
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/<span class="ep-param">:code</span></span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Redirect to the destination URL for a short code (302 by default, 301 if configured per link). Records click analytics asynchronously (referrer, device, browser, country, bot detection). Password-protected links show a branded unlock page; expired or missing links show branded visitor pages.</td>
</tr>
<tr>
<td><span class="method method-post">POST</span></td>
<td><span class="ep-path">/<span class="ep-param">:code</span>/unlock</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Password submission for protected links (form field <code>password</code>). Redirects to the destination on success.</td>
2026-03-06 10:16:10 -06:00
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/robots.txt</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Standard robots.txt for crawler directives.</td>
</tr>
<tr>
<td><span class="method method-get">GET</span></td>
<td><span class="ep-path">/sitemap.xml</span></td>
<td><span class="auth-badge auth-none"></span></td>
<td class="ep-desc">Auto-generated XML sitemap.</td>
</tr>
</tbody>
</table>
</section>
</div><!-- /page-wrap -->
<!-- ── FOOTER ── -->
<footer>
<div class="footer-logo">
<span class="logo-icon-wrap" style="isolation:isolate;position:relative;display:inline-flex"><img style="height:20px;width:20px;object-fit:contain;border-radius:2px;display:block" src="/static/qk-ico.png" alt=""><span style="position:absolute;inset:0;background:var(--accent);mix-blend-mode:hue;border-radius:2px;pointer-events:none"></span></span>
QR<span>knit</span>
</div>
<div class="footer-links">
<a href="/">Home</a>
<a href="/api-docs">API Docs</a>
<a href="/app">App</a>
</div>
<div class="footer-note">© 2025 QRknit · All rights reserved · <a href="/notices" style="color:inherit;text-decoration:none;opacity:.7">Third-party notices</a></div>
</footer>
<script>
// ── Theme picker ──────────────────────────────────────────────────────────
function applyTheme(name) {
if (name && name !== 'default') {
document.documentElement.setAttribute('data-theme', name);
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('qrknit-theme', name || 'default');
document.querySelectorAll('.theme-swatch').forEach(s => {
s.classList.toggle('active', s.dataset.theme === (name || 'default'));
});
}
document.querySelectorAll('.theme-swatch').forEach(s => {
s.addEventListener('click', () => applyTheme(s.dataset.theme));
});
(function(){
const t = localStorage.getItem('qrknit-theme') || 'default';
document.querySelectorAll('.theme-swatch').forEach(s => {
s.classList.toggle('active', s.dataset.theme === t);
});
})();
// ── Scroll reveal ─────────────────────────────────────────────────────────
(function(){
const els = document.querySelectorAll('.reveal');
if (!els.length) return;
const io = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add('visible'); io.unobserve(e.target); } });
}, { threshold: 0.08 });
els.forEach(el => io.observe(el));
})();
</script>
</body>
</html>