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 : 12 px ;
--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 : 15 px ; line-height : 1.6 ; min-height : 100 vh ; 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 40 px ; height : 64 px ;
background : rgba ( 10 , 10 , 15 , .88 ); backdrop-filter : blur ( 20 px );
border-bottom : 1 px solid var ( -- border );
}
. logo { font-family : var ( -- font - head ); font-size : 18 px ; font-weight : 700 ; letter-spacing : .04 em ; text-decoration : none ; color : var ( -- text ); display : inline-flex ; align-items : center ; gap : 8 px ; }
. logo-dot-text { color : var ( -- accent ); text-shadow : 0 0 12 px var ( -- accent ); }
. logo-icon-wrap { position : relative ; display : inline-flex ; align-items : center ; flex-shrink : 0 ; isolation : isolate ; }
. logo-icon { height : 26 px ; width : 26 px ; object-fit : contain ; border-radius : 3 px ; display : block ; }
. logo-icon-tint { position : absolute ; inset : 0 ; background : var ( -- accent ); mix-blend-mode : hue ; border-radius : 3 px ; pointer-events : none ; }
. header-right { display : flex ; align-items : center ; gap : 12 px ; }
. theme-picker { display : flex ; align-items : center ; gap : 6 px ; padding : 0 4 px ; }
. theme-swatch { width : 14 px ; height : 14 px ; border-radius : 50 % ; border : 2 px solid transparent ; cursor : pointer ; padding : 0 ; transition : transform .2 s , border-color .2 s ; }
. 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 ( 135 deg , #00b8d4 , #1a52c8 ); }
. theme-swatch [ data-theme = "gold" ] { background : linear-gradient ( 135 deg , #f5a623 , #c47f17 ); }
. theme-swatch [ data-theme = "green" ] { background : linear-gradient ( 135 deg , #00c896 , #007a58 ); }
. theme-swatch [ data-theme = "red" ] { background : linear-gradient ( 135 deg , #ff4757 , #c0392b ); }
/* ── BUTTONS ── */
. btn-primary { background : linear-gradient ( 135 deg , var ( -- accent ), var ( -- accent - grad )); color : #fff ; border : none ; cursor : pointer ; font-family : var ( -- font - head ); font-weight : 700 ; font-size : 12 px ; letter-spacing : .04 em ; padding : 12 px 22 px ; border-radius : var ( -- radius ); transition : all .2 s ; white-space : nowrap ; text-decoration : none ; display : inline-flex ; align-items : center ; gap : 6 px ; }
. btn-primary : hover { transform : translateY ( -1 px ); box-shadow : 0 8 px 24 px rgba ( var ( -- accent - rgb ), .35 ); }
. btn-outline { background : transparent ; border : 1 px solid var ( -- border ); color : var ( -- text ); font-family : var ( -- font - mono ); font-size : 12 px ; padding : 11 px 20 px ; border-radius : var ( -- radius ); cursor : pointer ; transition : all .2 s ; text-decoration : none ; display : inline-flex ; align-items : center ; gap : 6 px ; white-space : nowrap ; }
. btn-outline : hover { border-color : rgba ( var ( -- accent - rgb ), .5 ); color : var ( -- accent ); }
/* ── PAGE LAYOUT ── */
. page-wrap { max-width : 1080 px ; margin : 0 auto ; padding : 0 40 px 80 px ; position : relative ; z-index : 1 ; }
/* ── HERO ── */
. hero { text-align : center ; padding : 80 px 40 px 60 px ; position : relative ; z-index : 1 ; overflow : hidden ; }
. hero-eyebrow { font-family : var ( -- font - mono ); font-size : 11 px ; letter-spacing : .16 em ; color : var ( -- accent ); text-transform : uppercase ; margin-bottom : 20 px ; }
. hero h1 { font-family : var ( -- font - head ); font-weight : 900 ; line-height : 1.05 ; font-size : clamp ( 30 px , 5 vw , 56 px ); letter-spacing : .04 em ; margin-bottom : 20 px ; }
. hero-accent { background : linear-gradient ( 135 deg , var ( -- accent ) 0 % , var ( -- accent - grad ) 100 % ); -webkit- background-clip : text ; -webkit- text-fill-color : transparent ; background-clip : text ; }
. hero-sub { font-size : clamp ( 14 px , 2 vw , 17 px ); color : var ( -- muted ); max-width : 560 px ; margin : 0 auto 36 px ; font-weight : 300 ; line-height : 1.7 ; }
. hero-note { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); letter-spacing : .06 em ; }
. hero-note span { color : var ( -- accent ); }
. hero :: after {
content : '' ; position : absolute ; left : 50 % ; top : 40 px ; transform : translateX ( -50 % );
width : 500 px ; height : 500 px ; 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 : 8 px ; flex-wrap : wrap ;
padding : 16 px 0 32 px ; border-bottom : 1 px solid var ( -- border ); margin-bottom : 48 px ;
}
. toc-label { font-family : var ( -- font - mono ); font-size : 10 px ; letter-spacing : .12 em ; color : var ( -- muted ); text-transform : uppercase ; margin-right : 4 px ; }
. toc-link {
font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); text-decoration : none ;
background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : 6 px ;
padding : 5 px 12 px ; transition : all .2 s ; letter-spacing : .04 em ;
}
. toc-link : hover { border-color : rgba ( var ( -- accent - rgb ), .4 ); color : var ( -- accent ); }
/* ── SECTION HEADINGS ── */
. api-section { margin-bottom : 60 px ; }
. api-section-header { display : flex ; align-items : center ; gap : 14 px ; margin-bottom : 24 px ; }
. api-section-icon {
width : 38 px ; height : 38 px ; border-radius : 10 px ; flex-shrink : 0 ;
background : rgba ( var ( -- accent - rgb ), .1 ); border : 1 px 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 : 16 px ; font-weight : 700 ; letter-spacing : .04 em ; }
. api-section-desc { font-size : 13 px ; color : var ( -- muted ); margin-bottom : 20 px ; line-height : 1.65 ; }
/* ── ENDPOINT TABLE ── */
. endpoint-table { width : 100 % ; border-collapse : collapse ; }
. endpoint-table thead tr {
border-bottom : 1 px solid var ( -- border );
}
. endpoint-table thead th {
font-family : var ( -- font - mono ); font-size : 10 px ; letter-spacing : .12 em ; color : var ( -- muted );
text-transform : uppercase ; text-align : left ; padding : 10 px 14 px ; font-weight : 400 ;
}
. endpoint-table tbody tr {
border-bottom : 1 px solid var ( -- border );
transition : background .15 s ;
}
. endpoint-table tbody tr : last-child { border-bottom : none ; }
. endpoint-table tbody tr : hover { background : rgba ( 255 , 255 , 255 , .025 ); }
. endpoint-table td { padding : 14 px ; vertical-align : top ; }
/* Method badges */
. method {
font-family : var ( -- font - mono ); font-size : 10 px ; font-weight : 700 ; letter-spacing : .08 em ;
padding : 3 px 9 px ; border-radius : 5 px ; white-space : nowrap ; display : inline-block ;
}
. method-get { background : rgba ( 0 , 200 , 120 , .12 ); color : #00c87a ; border : 1 px solid rgba ( 0 , 200 , 120 , .25 ); }
. method-post { background : rgba ( var ( -- accent - rgb ), .12 ); color : var ( -- accent ); border : 1 px solid rgba ( var ( -- accent - rgb ), .25 ); }
. method-patch { background : rgba ( 245 , 166 , 35 , .12 ); color : #f5a623 ; border : 1 px solid rgba ( 245 , 166 , 35 , .25 ); }
. method-delete { background : rgba ( 255 , 94 , 98 , .12 ); color : #ff5e62 ; border : 1 px solid rgba ( 255 , 94 , 98 , .25 ); }
/* Endpoint path */
. ep-path {
font-family : var ( -- font - mono ); font-size : 12 px ; color : var ( -- text );
background : var ( -- surface2 ); border : 1 px solid var ( -- border );
border-radius : 6 px ; padding : 4 px 10 px ; 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 : 9 px ; letter-spacing : .08 em ;
padding : 2 px 8 px ; border-radius : 4 px ; white-space : nowrap ; display : inline-block ;
}
. auth-yes { background : rgba ( var ( -- accent - rgb ), .1 ); color : var ( -- accent ); border : 1 px solid rgba ( var ( -- accent - rgb ), .2 ); }
. auth-admin { background : rgba ( 124 , 106 , 247 , .12 ); color : var ( -- accent3 - light ); border : 1 px solid rgba ( 124 , 106 , 247 , .2 ); }
. auth-none { background : var ( -- surface2 ); color : var ( -- muted ); border : 1 px solid var ( -- border ); }
/* Description */
. ep-desc { font-size : 13 px ; color : var ( -- muted ); line-height : 1.6 ; }
. ep-desc code {
font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- text );
background : var ( -- surface2 ); border : 1 px solid var ( -- border );
border-radius : 4 px ; padding : 1 px 6 px ;
}
. ep-desc . ep-tag { color : var ( -- accent3 - light ); font-style : italic ; }
/* ── INTRO BOX ── */
. intro-box {
background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius );
padding : 24 px 28 px ; margin-bottom : 48 px ; position : relative ; overflow : hidden ;
}
. intro-box :: before {
content : '' ; position : absolute ; top : 0 ; left : 0 ; right : 0 ; height : 2 px ;
background : linear-gradient ( 90 deg , var ( -- accent ), var ( -- accent - grad ));
}
. intro-box h2 { font-family : var ( -- font - head ); font-size : 13 px ; font-weight : 700 ; letter-spacing : .05 em ; margin-bottom : 10 px ; }
. intro-box p { font-size : 13 px ; color : var ( -- muted ); line-height : 1.7 ; }
. intro-box code {
font-family : var ( -- font - mono ); font-size : 12 px ; color : var ( -- accent );
background : rgba ( var ( -- accent - rgb ), .08 ); border : 1 px solid rgba ( var ( -- accent - rgb ), .2 );
border-radius : 4 px ; padding : 1 px 7 px ;
}
/* ── FOOTER ── */
footer {
border-top : 1 px solid var ( -- border ); padding : 32 px 40 px ;
display : flex ; align-items : center ; justify-content : space-between ; flex-wrap : wrap ; gap : 16 px ;
position : relative ; z-index : 1 ;
}
. footer-logo { font-family : var ( -- font - head ); font-size : 14 px ; font-weight : 700 ; color : var ( -- muted ); letter-spacing : .04 em ; display : flex ; align-items : center ; gap : 6 px ; }
. footer-logo span { color : var ( -- accent ); }
. footer-links { display : flex ; gap : 20 px ; align-items : center ; }
. footer-links a { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); text-decoration : none ; letter-spacing : .06 em ; transition : color .2 s ; }
. footer-links a : hover { color : var ( -- accent ); }
. footer-note { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); opacity : .5 ; letter-spacing : .05 em ; }
/* ── REVEAL ANIMATIONS ── */
. reveal { opacity : 0 ; transform : translateY ( 20 px ); transition : opacity .5 s ease , transform .5 s ease ; }
. reveal . visible { opacity : 1 ; transform : none ; }
. reveal-delay-1 { transition-delay : .08 s ; }
. reveal-delay-2 { transition-delay : .16 s ; }
. reveal-delay-3 { transition-delay : .24 s ; }
/* ── RESPONSIVE ── */
@ media ( max-width : 900px ) {
header { padding : 0 20 px ; }
. hero { padding : 60 px 20 px 40 px ; }
. page-wrap { padding : 0 20 px 60 px ; }
. endpoint-table { display : block ; overflow-x : auto ; }
footer { padding : 24 px 20 px ; }
}
@ media ( max-width : 600px ) {
. toc-bar { gap : 6 px ; }
. 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 > · Format: < span > application/json</ span ></ div >
</ section >
<!-- ── MAIN CONTENT ── -->
< div class = "page-wrap" >
<!-- Intro box -->
< div class = "intro-box reveal" >
< h2 > Authentication</ h2 >
< p >
2026-07-03 01:40:44 -05:00
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 >
2026-07-03 01:40:44 -05:00
< 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 >
2026-07-03 01:40:44 -05:00
< 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=< username> </ 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 >
2026-07-03 01:40:44 -05:00
< 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 >
2026-07-03 01:40:44 -05:00
< 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=< url> </ 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 >
2026-07-03 01:40:44 -05:00
< 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 >
2026-07-03 01:40:44 -05:00
< 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 >
2026-07-03 01:40:44 -05:00
< 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 >
2026-07-03 01:40:44 -05:00
< 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 >
2026-07-03 01:40:44 -05:00
< 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 >