Files
qrknit/api-docs.html
T
Jason Stedwell 48d3445b09 docs: document new features, ops env vars, and API surface; refresh roadmap
- README: feature map updated (redirect types, protected links, QR vector
  export, audience analytics, API keys, admin observability, operations);
  API reference covers new endpoints and params. Roadmap now lists
  self-serve accounts, security hardening (rate limiting/CSRF/SSRF guard),
  Stripe billing, custom domains, and remaining power features.
- INSTALL: new environment variables (WEBHOOK_URL, GEOIP_DB_PATH,
  GEO_API_FALLBACK, IP_ANONYMIZE, CLICK_RETENTION_DAYS, BACKUPS,
  BACKUP_DIR, BACKUP_KEEP) with GeoLite2 setup notes and the ip-api.com
  licensing caveat.
- api-docs page: API-key authentication, new link/analytics/QR params,
  aggregate analytics, admin overview/audit/keys/purge, unlock route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:40:44 -05:00

780 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</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>