Files
qrknit/api-docs.html
T
2026-03-06 10:16:10 -06:00

724 lines
37 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>
All write endpoints require an active session. 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.
Include that cookie in all subsequent requests. 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>
</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": "…", "code": "…", "title": "…", "expires_at": "…", "tags": […], "is_pinned": bool}</code>. <code>code</code> is optional — omit for a random code.</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>.</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). Returns <code>daily</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/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 PNG for a short link. Query params: <code>?fg=</code>, <code>?bg=</code>, <code>?size=</code>, <code>?style=</code>.</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 PNG for any URL. Query: <code>?url=</code>, <code>?fg=</code>, <code>?bg=</code>, <code>?size=</code>, <code>?style=</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 PNG with logo overlay. Body: <code>{url, fg, bg, size, style, logo}</code> where <code>logo</code> is a base64-encoded image string.</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>
</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. Records click analytics (referrer, device, browser, country).</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>