2026-03-08 18:11:26 -06:00

6359 lines
166 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" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="Complete REST API reference for both the Express API (port 4000) and Fastify Media API (port 4100).">
<meta name="author" content="Bunker Operations">
<link rel="canonical" href="https://bnkserve.org/docs/api/">
<link rel="prev" href="../services/">
<link rel="next" href="../troubleshooting/">
<link rel="icon" href="../../assets/favicon.png">
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.7.2">
<title>API Reference - Changemaker Lite</title>
<link rel="stylesheet" href="../../assets/stylesheets/main.484c7ddc.min.css">
<link rel="stylesheet" href="../../assets/stylesheets/palette.ab4e12ef.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Inter:300,300i,400,400i,700,700i%7CJetBrains+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Inter";--md-code-font:"JetBrains Mono"}</style>
<link rel="stylesheet" href="../../stylesheets/extra.css">
<link rel="stylesheet" href="../../stylesheets/home.css">
<link rel="stylesheet" href="../../stylesheets/docs-comments.css">
<link rel="stylesheet" href="../../assets/css/video-player.css">
<link rel="stylesheet" href="../../assets/css/image-gallery.css">
<link rel="stylesheet" href="../../assets/css/payment-widgets.css">
<script>__md_scope=new URL("../..",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
<script>
(function () {
// API URL injected from MkDocs config.extra (set by env_config_hook.py)
var apiUrl = "http://localhost:4002";
if (!apiUrl) return;
var trackUrl = apiUrl + "/api/docs-analytics/track";
// Anonymous session UUID (sessionStorage — dies with tab close, no cookies)
function getSessionHash() {
var key = "__docs_sh";
var hash = sessionStorage.getItem(key);
if (!hash) {
hash = crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
sessionStorage.setItem(key, hash);
}
return hash;
}
function trackPageView(path) {
var payload = JSON.stringify({
path: path,
referrer: document.referrer || undefined,
sessionHash: getSessionHash(),
});
// Prefer sendBeacon for reliability (works during tab close)
if (navigator.sendBeacon) {
navigator.sendBeacon(trackUrl, new Blob([payload], { type: "application/json" }));
} else {
fetch(trackUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: true,
}).catch(function () {});
}
}
// Track initial page load
trackPageView(location.pathname);
// Subscribe to Material's SPA navigation observable (instant loading)
if (typeof document$ !== "undefined") {
document$.subscribe(function () {
trackPageView(location.pathname);
});
}
})();
</script>
<script>"undefined"!=typeof __md_analytics&&__md_analytics()</script>
<meta property="og:type" content="website" />
<meta property="og:title" content="API Reference - Changemaker Lite" />
<meta property="og:description" content="Complete REST API reference for both the Express API (port 4000) and Fastify Media API (port 4100)." />
<meta property="og:image" content="https://bnkserve.org/assets/images/social/docs/api/index.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content="https://bnkserve.org/docs/api/" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content="API Reference - Changemaker Lite" />
<meta property="twitter:description" content="Complete REST API reference for both the Express API (port 4000) and Fastify Media API (port 4100)." />
<meta property="twitter:image" content="https://bnkserve.org/assets/images/social/docs/api/index.png" />
</head>
<body dir="ltr" data-md-color-scheme="slate" data-md-color-primary="deep-purple" data-md-color-accent="amber">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
<div data-md-component="skip">
<a href="#api-reference" class="md-skip">
Skip to content
</a>
</div>
<div data-md-component="announce">
<aside class="md-banner">
<div class="md-banner__inner md-grid md-typeset">
<button class="md-banner__button md-icon" aria-label="Don't show this again">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet">
<nav class="cm-header-nav" role="navigation" aria-label="Application">
<div class="cm-header-nav__brand">
<a href="#" data-path="/home" class="cm-header-nav__brand-link">
<span class="cm-header-nav__brand-text">Changemaker Lite</span>
</a>
</div>
<div class="cm-header-nav__links">
<div class="cm-header-nav__links-inner">
<a href="#" data-path="/home" class="cm-header-nav__link" data-nav-id="home"><span class="material-icons-outlined">home</span><span class="cm-header-nav__label">Home</span></a>
<a href="#" data-path="/campaigns" class="cm-header-nav__link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span class="cm-header-nav__label">Campaigns</span></a>
<a href="#" data-path="/map" class="cm-header-nav__link" data-nav-id="map"><span class="material-icons-outlined">place</span><span class="cm-header-nav__label">Map</span></a>
<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">apps</span>
<span class="cm-header-nav__label">Scheduling</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
<a href="#" data-path="/shifts" class="cm-header-nav__dropdown-item" data-nav-id="shifts"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__dropdown-item" data-nav-id="events"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
<a href="#" data-path="/polls" class="cm-header-nav__dropdown-item" data-nav-id="polls"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
</div>
</div>
<a href="#" data-path="/gallery" class="cm-header-nav__link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span class="cm-header-nav__label">Gallery</span></a>
<div class="cm-header-nav__dropdown">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">account_balance_wallet</span>
<span class="cm-header-nav__label">Commerce</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu">
<a href="#" data-path="/pricing" class="cm-header-nav__dropdown-item" data-nav-id="pricing"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__dropdown-item" data-nav-id="shop"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__dropdown-item" data-nav-id="donate"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
</div>
</div>
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span class="cm-header-nav__label">Wall of Fame</span></a>
<a href="#" data-path="/pages" class="cm-header-nav__link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span class="cm-header-nav__label">Pages</span></a>
<a href="/" class="cm-header-nav__link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span class="cm-header-nav__label">Website</span></a>
<a href="/docs/" class="cm-header-nav__link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span class="cm-header-nav__label">Docs</span></a>
<a href="#" data-path="/login" class="cm-header-nav__link" id="cm-signin-link">
<span class="material-icons-outlined">login</span>
<span class="cm-header-nav__label">Sign In</span>
</a>
<div class="cm-header-nav__dropdown" id="cm-admin-dropdown" style="display:none">
<span class="cm-header-nav__link cm-header-nav__dropdown-trigger">
<span class="material-icons-outlined">person</span>
<span class="cm-header-nav__label">Admin</span>
<span class="material-icons-outlined cm-header-nav__chevron">expand_more</span>
</span>
<div class="cm-header-nav__dropdown-menu cm-header-nav__dropdown-menu--right">
<a href="#" data-path="/app" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__dropdown-item"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
<button class="cm-header-nav__hamburger" aria-label="Open navigation menu">
<span class="material-icons-outlined">menu</span>
</button>
</div>
</nav>
<div class="cm-header-nav__mobile-drawer" id="cm-mobile-drawer">
<div class="cm-header-nav__mobile-header">
<span class="cm-header-nav__brand-text">Changemaker Lite</span>
<button class="cm-header-nav__mobile-close" aria-label="Close navigation menu">
<span class="material-icons-outlined">close</span>
</button>
</div>
<div class="cm-header-nav__mobile-links">
<a href="#" data-path="/home" class="cm-header-nav__mobile-link" data-nav-id="home"><span class="material-icons-outlined">home</span><span>Home</span></a>
<a href="#" data-path="/campaigns" class="cm-header-nav__mobile-link" data-nav-id="campaigns"><span class="material-icons-outlined">send</span><span>Campaigns</span></a>
<a href="#" data-path="/map" class="cm-header-nav__mobile-link" data-nav-id="map"><span class="material-icons-outlined">place</span><span>Map</span></a>
<div class="cm-header-nav__mobile-group" data-group-id="scheduling">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">apps</span>
<span style="flex:1">Scheduling</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/shifts" class="cm-header-nav__mobile-link" data-nav-id="shifts" style="padding-left:48px"><span class="material-icons-outlined">schedule</span><span>Shifts</span></a>
<a href="#" data-path="/events" class="cm-header-nav__mobile-link" data-nav-id="events" style="padding-left:48px"><span class="material-icons-outlined">event</span><span>Calendar</span></a>
<a href="#" data-path="/polls" class="cm-header-nav__mobile-link" data-nav-id="polls" style="padding-left:48px"><span class="material-icons-outlined">bar_chart</span><span>Polls</span></a>
</div>
</div>
<a href="#" data-path="/gallery" class="cm-header-nav__mobile-link" data-nav-id="gallery"><span class="material-icons-outlined">play_circle</span><span>Gallery</span></a>
<div class="cm-header-nav__mobile-group" data-group-id="commerce">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">account_balance_wallet</span>
<span style="flex:1">Commerce</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/pricing" class="cm-header-nav__mobile-link" data-nav-id="pricing" style="padding-left:48px"><span class="material-icons-outlined">attach_money</span><span>Pricing</span></a>
<a href="#" data-path="/shop" class="cm-header-nav__mobile-link" data-nav-id="shop" style="padding-left:48px"><span class="material-icons-outlined">shopping_bag</span><span>Shop</span></a>
<a href="#" data-path="/donate" class="cm-header-nav__mobile-link" data-nav-id="donate" style="padding-left:48px"><span class="material-icons-outlined">favorite_border</span><span>Donate</span></a>
</div>
</div>
<a href="#" data-path="/wall-of-fame" class="cm-header-nav__mobile-link" data-nav-id="wall-of-fame"><span class="material-icons-outlined">emoji_events</span><span>Wall of Fame</span></a>
<a href="#" data-path="/pages" class="cm-header-nav__mobile-link" data-nav-id="pages"><span class="material-icons-outlined">description</span><span>Pages</span></a>
<a href="/" class="cm-header-nav__mobile-link" data-nav-id="landing"><span class="material-icons-outlined">language</span><span>Website</span></a>
<a href="/docs/" class="cm-header-nav__mobile-link" data-nav-id="docs"><span class="material-icons-outlined">menu_book</span><span>Docs</span></a>
<a href="#" data-path="/login" class="cm-header-nav__mobile-link" id="cm-mobile-signin-link">
<span class="material-icons-outlined">login</span>
<span>Sign In</span>
</a>
<div class="cm-header-nav__mobile-group" data-group-id="admin" id="cm-mobile-admin-group" style="display:none">
<span class="cm-header-nav__mobile-link cm-header-nav__mobile-group-trigger" role="button">
<span class="material-icons-outlined">person</span>
<span style="flex:1">Admin</span>
<span class="material-icons-outlined cm-header-nav__mobile-chevron">expand_more</span>
</span>
<div class="cm-header-nav__mobile-group-children">
<a href="#" data-path="/app" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">dashboard</span><span>Admin Panel</span></a>
<a href="#" data-path="/volunteer" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">volunteer_activism</span><span>Volunteer Portal</span></a>
<a href="#" data-path="/volunteer/profile" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">account_circle</span><span>My Profile</span></a>
<a href="#" data-path="/logout" class="cm-header-nav__mobile-link" style="padding-left:48px"><span class="material-icons-outlined">logout</span><span>Logout</span></a>
</div>
</div>
</div>
</div>
<div class="cm-header-nav__mobile-overlay" id="cm-mobile-overlay"></div>
<script>
(function() {
var h = location.hostname;
var base;
if (h === 'localhost' || h === '127.0.0.1') {
base = location.protocol + '//localhost:' + (3002 || 3000);
} else {
var parts = h.split('.');
if (parts.length >= 3) { parts[0] = 'app'; }
else { parts.unshift('app'); }
base = location.protocol + '//' + parts.join('.');
}
var links = document.querySelectorAll('[data-path]');
for (var i = 0; i < links.length; i++) {
links[i].setAttribute('href', base + links[i].getAttribute('data-path'));
}
// Highlight active nav link based on current path
var path = location.pathname;
var activeLink = null;
if (path.indexOf('/docs') === 0) activeLink = 'docs';
document.querySelectorAll('.cm-header-nav__link[data-nav-id], .cm-header-nav__mobile-link[data-nav-id]').forEach(function(el) {
if (el.getAttribute('data-nav-id') === activeLink) {
el.classList.add('cm-header-nav__link--active');
}
});
// Hamburger toggle
var hamburger = document.querySelector('.cm-header-nav__hamburger');
var drawer = document.getElementById('cm-mobile-drawer');
var overlay = document.getElementById('cm-mobile-overlay');
var closeBtn = document.querySelector('.cm-header-nav__mobile-close');
function openDrawer() { drawer.classList.add('open'); overlay.classList.add('open'); }
function closeDrawer() { drawer.classList.remove('open'); overlay.classList.remove('open'); }
if (hamburger) hamburger.addEventListener('click', openDrawer);
if (closeBtn) closeBtn.addEventListener('click', closeDrawer);
if (overlay) overlay.addEventListener('click', closeDrawer);
// Mobile group expand/collapse toggles
document.querySelectorAll('.cm-header-nav__mobile-group-trigger').forEach(function(trigger) {
trigger.addEventListener('click', function() {
var group = this.closest('.cm-header-nav__mobile-group');
var children = group.querySelector('.cm-header-nav__mobile-group-children');
var isExpanded = group.classList.contains('expanded');
if (isExpanded) {
group.classList.remove('expanded');
children.style.display = 'none';
} else {
group.classList.add('expanded');
children.style.display = 'block';
}
});
});
// Auth-aware: show Admin dropdown for logged-in users, Sign In for guests.
// Uses hidden iframe + postMessage to read auth state from the app's origin.
function showAdminMenu() {
var s1 = document.getElementById('cm-signin-link');
var s2 = document.getElementById('cm-mobile-signin-link');
var a1 = document.getElementById('cm-admin-dropdown');
var a2 = document.getElementById('cm-mobile-admin-group');
if (s1) s1.style.display = 'none';
if (s2) s2.style.display = 'none';
if (a1) a1.style.display = '';
if (a2) a2.style.display = '';
}
// 1. Same-origin check (works when MkDocs served from same origin as app)
try {
var stored = localStorage.getItem('cml-auth');
if (stored) {
var parsed = JSON.parse(stored);
if (parsed && parsed.state && parsed.state.accessToken) {
showAdminMenu();
}
}
} catch(e) {}
// 2. Cross-origin check via hidden iframe + postMessage
var iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = base + '/auth-check.html?origin=' + encodeURIComponent(location.origin);
window.addEventListener('message', function(event) {
if (event.origin !== base) return;
if (event.data && event.data.type === 'cml-auth-status' && event.data.authenticated) {
showAdminMenu();
}
});
document.body.appendChild(iframe);
})();
</script>
<style>
.md-banner {
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%) !important;
color: #ffffff !important;
padding: 0 !important;
overflow: visible !important;
border: none !important;
box-shadow: none !important;
}
.md-banner__inner {
overflow: visible !important;
margin: 0 !important;
padding: 0 !important;
max-width: 100% !important;
}
.md-banner__button {
display: none !important;
}
.cm-header-nav {
background: linear-gradient(135deg, #005a9c 0%, #007acc 100%);
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
position: relative;
z-index: 100;
box-sizing: border-box;
}
.cm-header-nav a {
color: rgba(255, 255, 255, 0.85) !important;
}
.cm-header-nav__brand-link {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none !important;
color: #fff !important;
}
.cm-header-nav__brand-text {
font-size: 18px;
font-weight: 600;
color: #fff !important;
}
.cm-header-nav__links {
display: flex;
align-items: center;
}
.cm-header-nav__links-inner {
display: flex;
align-items: center;
gap: 16px;
}
.cm-header-nav__link {
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
transition: color 0.2s, border-color 0.2s;
white-space: nowrap;
padding-bottom: 2px;
border-bottom: 2px solid transparent;
}
.cm-header-nav__link:hover {
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__link--active,
.cm-header-nav__link--active:hover {
color: #fff !important;
font-weight: 600;
border-bottom-color: #fff;
}
.cm-header-nav__link .material-icons-outlined {
font-size: 16px;
}
.cm-header-nav__hamburger {
display: none;
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
color: #fff;
}
.cm-header-nav__hamburger .material-icons-outlined {
font-size: 24px;
}
/* Desktop dropdown menus */
.cm-header-nav__dropdown {
position: relative;
display: inline-flex;
align-items: center;
}
.cm-header-nav__dropdown-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__dropdown-trigger .cm-header-nav__chevron {
font-size: 14px;
transition: transform 0.2s;
}
.cm-header-nav__dropdown:hover .cm-header-nav__chevron {
transform: rotate(180deg);
}
.cm-header-nav__dropdown-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
background: #1b2838;
border-radius: 8px;
padding: 6px 0;
box-shadow: 0 6px 16px rgba(0,0,0,0.3);
z-index: 100;
margin-top: 4px;
}
.cm-header-nav__dropdown:hover .cm-header-nav__dropdown-menu {
display: block;
}
.cm-header-nav__dropdown-menu--right {
left: auto;
right: 0;
}
.cm-header-nav__dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: rgba(255, 255, 255, 0.85) !important;
text-decoration: none !important;
font-size: 14px;
white-space: nowrap;
transition: background 0.15s;
}
.cm-header-nav__dropdown-item:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__dropdown-item .material-icons-outlined {
font-size: 16px;
}
/* Mobile drawer */
.cm-header-nav__mobile-drawer {
position: fixed;
top: 0;
right: -280px;
width: 280px;
height: 100vh;
background: #0d1b2a;
z-index: 10001;
transition: right 0.3s ease;
display: flex;
flex-direction: column;
}
.cm-header-nav__mobile-drawer.open {
right: 0;
}
.cm-header-nav__mobile-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid rgba(255,255,255,0.1);
background: #1b2838;
}
.cm-header-nav__mobile-close {
background: none;
border: none;
cursor: pointer;
color: rgba(255,255,255,0.85);
padding: 4px;
}
.cm-header-nav__mobile-links {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 0;
}
.cm-header-nav__mobile-link {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
color: rgba(255,255,255,0.85) !important;
text-decoration: none !important;
font-size: 15px;
border-radius: 4px;
}
.cm-header-nav__mobile-link:hover {
background: rgba(255,255,255,0.1);
color: #fff !important;
text-decoration: none !important;
}
.cm-header-nav__mobile-link--active {
color: #fff !important;
font-weight: 600;
background: rgba(255,255,255,0.1);
}
.cm-header-nav__mobile-link .material-icons-outlined {
font-size: 18px;
}
/* Mobile group expand/collapse */
.cm-header-nav__mobile-group-trigger {
cursor: pointer;
user-select: none;
}
.cm-header-nav__mobile-chevron {
font-size: 14px !important;
transition: transform 0.2s;
}
.cm-header-nav__mobile-group.expanded .cm-header-nav__mobile-chevron {
transform: rotate(180deg);
}
.cm-header-nav__mobile-group-children {
display: none;
}
.cm-header-nav__mobile-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10000;
}
.cm-header-nav__mobile-overlay.open {
display: block;
}
@media (max-width: 768px) {
.cm-header-nav { padding: 0 16px; }
.cm-header-nav__links-inner { display: none; }
.cm-header-nav__hamburger { display: block; }
.cm-header-nav__dropdown-menu { display: none !important; }
}
</style>
</div>
<script>var el=document.querySelector("[data-md-component=announce]");if(el){var content=el.querySelector(".md-typeset");__md_hash(content.innerHTML)===__md_get("__announce")&&(el.hidden=!0)}</script>
</aside>
</div>
<header class="md-header md-header--shadow md-header--lifted" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="../.." title="Changemaker Lite" class="md-header__button md-logo" aria-label="Changemaker Lite" data-md-component="logo">
<img src="../../assets/logo.png" alt="logo">
</a>
<label class="md-header__button md-icon" for="__drawer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
Changemaker Lite
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
API Reference
</span>
</div>
</div>
</div>
<form class="md-header__option" data-md-component="palette">
<input class="md-option" data-md-color-media="" data-md-color-scheme="slate" data-md-color-primary="deep-purple" data-md-color-accent="amber" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_0">
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_1" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="m17.75 4.09-2.53 1.94.91 3.06-2.63-1.81-2.63 1.81.91-3.06-2.53-1.94L12.44 4l1.06-3 1.06 3zm3.5 6.91-1.64 1.25.59 1.98-1.7-1.17-1.7 1.17.59-1.98L15.75 11l2.06-.05L18.5 9l.69 1.95zm-2.28 4.95c.83-.08 1.72 1.1 1.19 1.85-.32.45-.66.87-1.08 1.27C15.17 23 8.84 23 4.94 19.07c-3.91-3.9-3.91-10.24 0-14.14.4-.4.82-.76 1.27-1.08.75-.53 1.93.36 1.85 1.19-.27 2.86.69 5.83 2.89 8.02a9.96 9.96 0 0 0 8.02 2.89m-1.64 2.02a12.08 12.08 0 0 1-7.8-3.47c-2.17-2.19-3.33-5-3.49-7.82-2.81 3.14-2.7 7.96.31 10.98 3.02 3.01 7.84 3.12 10.98.31"/></svg>
</label>
<input class="md-option" data-md-color-media="" data-md-color-scheme="default" data-md-color-primary="deep-purple" data-md-color-accent="amber" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_1">
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_0" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3m0-7 2.39 3.42C13.65 5.15 12.84 5 12 5s-1.65.15-2.39.42zM3.34 7l4.16-.35A7.2 7.2 0 0 0 5.94 8.5c-.44.74-.69 1.5-.83 2.29zm.02 10 1.76-3.77a7.131 7.131 0 0 0 2.38 4.14zM20.65 7l-1.77 3.79a7.02 7.02 0 0 0-2.38-4.15zm-.01 10-4.14.36c.59-.51 1.12-1.14 1.54-1.86.42-.73.69-1.5.83-2.29zM12 22l-2.41-3.44c.74.27 1.55.44 2.41.44.82 0 1.63-.17 2.37-.44z"/></svg>
</label>
</form>
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog">
<label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search">
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
</label>
<nav class="md-search__options" aria-label="Search">
<a href="javascript:void(0)" class="md-search__icon md-icon" title="Share" aria-label="Share" data-clipboard data-clipboard-text="" data-md-component="search-share" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3 3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66 0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"/></svg>
</a>
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</nav>
<div class="md-search__suggest" data-md-component="search-suggest"></div>
</form>
<div class="md-search__output">
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
<div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta">
Initializing search
</div>
<ol class="md-search-result__list" role="presentation"></ol>
</div>
</div>
</div>
</div>
</div>
<div class="md-header__source">
<a href="https://gitea.bnkops.com/admin/changemaker.lite" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
changemaker.lite
</div>
</a>
</div>
</nav>
<nav class="md-tabs" aria-label="Tabs" data-md-component="tabs">
<div class="md-grid">
<ul class="md-tabs__list">
<li class="md-tabs__item">
<a href="../.." class="md-tabs__link">
Home
</a>
</li>
<li class="md-tabs__item md-tabs__item--active">
<a href="../" class="md-tabs__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 21.5c-1.35-.85-3.8-1.5-5.5-1.5-1.65 0-3.35.3-4.75 1.05-.1.05-.15.05-.25.05-.25 0-.5-.25-.5-.5V6c.6-.45 1.25-.75 2-1 1.11-.35 2.33-.5 3.5-.5 1.95 0 4.05.4 5.5 1.5 1.45-1.1 3.55-1.5 5.5-1.5 1.17 0 2.39.15 3.5.5.75.25 1.4.55 2 1v14.6c0 .25-.25.5-.5.5-.1 0-.15 0-.25-.05-1.4-.75-3.1-1.05-4.75-1.05-1.7 0-4.15.65-5.5 1.5M12 8v11.5c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5V7c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5m1 3.5c1.11-.68 2.6-1 4.5-1 .91 0 1.76.09 2.5.28V9.23c-.87-.15-1.71-.23-2.5-.23q-2.655 0-4.5.84zm4.5.17c-1.71 0-3.21.26-4.5.79v1.69c1.11-.65 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24v-1.5c-.87-.16-1.71-.23-2.5-.23m2.5 2.9c-.87-.16-1.71-.24-2.5-.24-1.83 0-3.33.27-4.5.8v1.69c1.11-.66 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24z"/></svg>
Docs
</a>
</li>
<li class="md-tabs__item">
<a href="../../blog/" class="md-tabs__link">
Blog
</a>
</li>
</ul>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary md-nav--lifted" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="../.." title="Changemaker Lite" class="md-nav__button md-logo" aria-label="Changemaker Lite" data-md-component="logo">
<img src="../../assets/logo.png" alt="logo">
</a>
Changemaker Lite
</label>
<div class="md-nav__source">
<a href="https://gitea.bnkops.com/admin/changemaker.lite" title="Go to repository" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M439.6 236.1 244 40.5c-5.4-5.5-12.8-8.5-20.4-8.5s-15 3-20.4 8.4L162.5 81l51.5 51.5c27.1-9.1 52.7 16.8 43.4 43.7l49.7 49.7c34.2-11.8 61.2 31 35.5 56.7-26.5 26.5-70.2-2.9-56-37.3L240.3 199v121.9c25.3 12.5 22.3 41.8 9.1 55-6.4 6.4-15.2 10.1-24.3 10.1s-17.8-3.6-24.3-10.1c-17.6-17.6-11.1-46.9 11.2-56v-123c-20.8-8.5-24.6-30.7-18.6-45L142.6 101 8.5 235.1C3 240.6 0 247.9 0 255.5s3 15 8.5 20.4l195.6 195.7c5.4 5.4 12.7 8.4 20.4 8.4s15-3 20.4-8.4l194.7-194.7c5.4-5.4 8.4-12.8 8.4-20.4s-3-15-8.4-20.4"/></svg>
</div>
<div class="md-source__repository">
changemaker.lite
</div>
</a>
</div>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../.." class="md-nav__link">
<span class="md-ellipsis">
Home
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--active md-nav__item--section md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2" checked>
<div class="md-nav__link md-nav__container">
<a href="../" class="md-nav__link ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 21.5c-1.35-.85-3.8-1.5-5.5-1.5-1.65 0-3.35.3-4.75 1.05-.1.05-.15.05-.25.05-.25 0-.5-.25-.5-.5V6c.6-.45 1.25-.75 2-1 1.11-.35 2.33-.5 3.5-.5 1.95 0 4.05.4 5.5 1.5 1.45-1.1 3.55-1.5 5.5-1.5 1.17 0 2.39.15 3.5.5.75.25 1.4.55 2 1v14.6c0 .25-.25.5-.5.5-.1 0-.15 0-.25-.05-1.4-.75-3.1-1.05-4.75-1.05-1.7 0-4.15.65-5.5 1.5M12 8v11.5c1.35-.85 3.8-1.5 5.5-1.5 1.2 0 2.4.15 3.5.5V7c-1.1-.35-2.3-.5-3.5-.5-1.7 0-4.15.65-5.5 1.5m1 3.5c1.11-.68 2.6-1 4.5-1 .91 0 1.76.09 2.5.28V9.23c-.87-.15-1.71-.23-2.5-.23q-2.655 0-4.5.84zm4.5.17c-1.71 0-3.21.26-4.5.79v1.69c1.11-.65 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24v-1.5c-.87-.16-1.71-.23-2.5-.23m2.5 2.9c-.87-.16-1.71-.24-2.5-.24-1.83 0-3.33.27-4.5.8v1.69c1.11-.66 2.6-.99 4.5-.99 1.04 0 1.88.08 2.5.24z"/></svg>
<span class="md-ellipsis">
Docs
</span>
</a>
<label class="md-nav__link " for="__nav_2" id="__nav_2_label" tabindex="">
<span class="md-nav__icon md-icon"></span>
</label>
</div>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_2_label" aria-expanded="true">
<label class="md-nav__title" for="__nav_2">
<span class="md-nav__icon md-icon"></span>
Docs
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../getting-started/" class="md-nav__link">
<span class="md-ellipsis">
Getting Started
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../admin/" class="md-nav__link">
<span class="md-ellipsis">
Admin Guide
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../user-guide/" class="md-nav__link">
<span class="md-ellipsis">
User Guide
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../volunteer/" class="md-nav__link">
<span class="md-ellipsis">
Volunteer Guide
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../deployment/" class="md-nav__link">
<span class="md-ellipsis">
Deployment
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../architecture/" class="md-nav__link">
<span class="md-ellipsis">
Architecture
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../services/" class="md-nav__link">
<span class="md-ellipsis">
Services
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item md-nav__item--active md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_2_9" checked>
<div class="md-nav__link md-nav__container">
<a href="./" class="md-nav__link md-nav__link--active">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 7H5a2 2 0 0 0-2 2v8h2v-4h2v4h2V9a2 2 0 0 0-2-2m0 4H5V9h2m7-2h-4v10h2v-4h2a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2m0 4h-2V9h2m6 0v6h1v2h-4v-2h1V9h-1V7h4v2Z"/></svg>
<span class="md-ellipsis">
API Reference
</span>
</a>
</div>
<nav class="md-nav" data-md-level="2" aria-labelledby="__nav_2_9_label" aria-expanded="true">
<label class="md-nav__title" for="__nav_2_9">
<span class="md-nav__icon md-icon"></span>
API Reference
</label>
<ul class="md-nav__list" data-md-scrollfix>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../troubleshooting/" class="md-nav__link">
<span class="md-ellipsis">
Troubleshooting
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
<li class="md-nav__item">
<a href="../phil/" class="md-nav__link">
<span class="md-ellipsis">
Philosophy
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--pruned md-nav__item--nested">
<a href="../../blog/" class="md-nav__link">
<span class="md-ellipsis">
Blog
</span>
<span class="md-nav__icon md-icon"></span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--secondary" aria-label="On this page">
<label class="md-nav__title" for="__toc">
<span class="md-nav__icon md-icon"></span>
On this page
</label>
<ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>
<li class="md-nav__item">
<a href="#authentication" class="md-nav__link">
<span class="md-ellipsis">
Authentication
</span>
</a>
<nav class="md-nav" aria-label="Authentication">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#token-flow" class="md-nav__link">
<span class="md-ellipsis">
Token Flow
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#headers" class="md-nav__link">
<span class="md-ellipsis">
Headers
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#roles" class="md-nav__link">
<span class="md-ellipsis">
Roles
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#middleware-reference" class="md-nav__link">
<span class="md-ellipsis">
Middleware Reference
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#error-responses" class="md-nav__link">
<span class="md-ellipsis">
Error Responses
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#rate-limits" class="md-nav__link">
<span class="md-ellipsis">
Rate Limits
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#main-api-express-port-4000" class="md-nav__link">
<span class="md-ellipsis">
Main API (Express &mdash; Port 4000)
</span>
</a>
<nav class="md-nav" aria-label="Main API (Express — Port 4000)">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#health-metrics" class="md-nav__link">
<span class="md-ellipsis">
Health &amp; Metrics
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#auth" class="md-nav__link">
<span class="md-ellipsis">
Auth
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#users" class="md-nav__link">
<span class="md-ellipsis">
Users
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#dashboard" class="md-nav__link">
<span class="md-ellipsis">
Dashboard
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#campaigns" class="md-nav__link">
<span class="md-ellipsis">
Campaigns
</span>
</a>
<nav class="md-nav" aria-label="Campaigns">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#admin-crud" class="md-nav__link">
<span class="md-ellipsis">
Admin CRUD
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#public" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#user-submissions" class="md-nav__link">
<span class="md-ellipsis">
User Submissions
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#moderation" class="md-nav__link">
<span class="md-ellipsis">
Moderation
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#campaign-emails" class="md-nav__link">
<span class="md-ellipsis">
Campaign Emails
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#responses" class="md-nav__link">
<span class="md-ellipsis">
Responses
</span>
</a>
<nav class="md-nav" aria-label="Responses">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public_1" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#representatives" class="md-nav__link">
<span class="md-ellipsis">
Representatives
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#email-queue" class="md-nav__link">
<span class="md-ellipsis">
Email Queue
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#locations" class="md-nav__link">
<span class="md-ellipsis">
Locations
</span>
</a>
<nav class="md-nav" aria-label="Locations">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public_2" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin_1" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#bulk-geocode" class="md-nav__link">
<span class="md-ellipsis">
Bulk Geocode
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#nar-import" class="md-nav__link">
<span class="md-ellipsis">
NAR Import
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#area-import" class="md-nav__link">
<span class="md-ellipsis">
Area Import
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#cuts-polygons" class="md-nav__link">
<span class="md-ellipsis">
Cuts (Polygons)
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#shifts" class="md-nav__link">
<span class="md-ellipsis">
Shifts
</span>
</a>
<nav class="md-nav" aria-label="Shifts">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public_3" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#volunteer" class="md-nav__link">
<span class="md-ellipsis">
Volunteer
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin_2" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#shift-series" class="md-nav__link">
<span class="md-ellipsis">
Shift Series
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#canvassing" class="md-nav__link">
<span class="md-ellipsis">
Canvassing
</span>
</a>
<nav class="md-nav" aria-label="Canvassing">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#volunteer_1" class="md-nav__link">
<span class="md-ellipsis">
Volunteer
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin_3" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#gps-tracking" class="md-nav__link">
<span class="md-ellipsis">
GPS Tracking
</span>
</a>
<nav class="md-nav" aria-label="GPS Tracking">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#volunteer_2" class="md-nav__link">
<span class="md-ellipsis">
Volunteer
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin_4" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#map-settings" class="md-nav__link">
<span class="md-ellipsis">
Map Settings
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#geocoding" class="md-nav__link">
<span class="md-ellipsis">
Geocoding
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#landing-pages" class="md-nav__link">
<span class="md-ellipsis">
Landing Pages
</span>
</a>
<nav class="md-nav" aria-label="Landing Pages">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public_4" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin_5" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#block-library" class="md-nav__link">
<span class="md-ellipsis">
Block Library
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#email-templates" class="md-nav__link">
<span class="md-ellipsis">
Email Templates
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#qr-codes" class="md-nav__link">
<span class="md-ellipsis">
QR Codes
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#site-settings" class="md-nav__link">
<span class="md-ellipsis">
Site Settings
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#listmonk-newsletter-sync" class="md-nav__link">
<span class="md-ellipsis">
Listmonk (Newsletter Sync)
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#documentation-management" class="md-nav__link">
<span class="md-ellipsis">
Documentation Management
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#services" class="md-nav__link">
<span class="md-ellipsis">
Services
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#pangolin-tunnel-management" class="md-nav__link">
<span class="md-ellipsis">
Pangolin (Tunnel Management)
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#observability" class="md-nav__link">
<span class="md-ellipsis">
Observability
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#payments" class="md-nav__link">
<span class="md-ellipsis">
Payments
</span>
</a>
<nav class="md-nav" aria-label="Payments">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public_5" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#admin_6" class="md-nav__link">
<span class="md-ellipsis">
Admin
</span>
</a>
</li>
</ul>
</nav>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#media-api-fastify-port-4100" class="md-nav__link">
<span class="md-ellipsis">
Media API (Fastify &mdash; Port 4100)
</span>
</a>
<nav class="md-nav" aria-label="Media API (Fastify — Port 4100)">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#health" class="md-nav__link">
<span class="md-ellipsis">
Health
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#videos-admin" class="md-nav__link">
<span class="md-ellipsis">
Videos (Admin)
</span>
</a>
<nav class="md-nav" aria-label="Videos (Admin)">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#crud-publishing" class="md-nav__link">
<span class="md-ellipsis">
CRUD &amp; Publishing
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#upload" class="md-nav__link">
<span class="md-ellipsis">
Upload
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#actions" class="md-nav__link">
<span class="md-ellipsis">
Actions
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#scheduling" class="md-nav__link">
<span class="md-ellipsis">
Scheduling
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#video-fetch" class="md-nav__link">
<span class="md-ellipsis">
Video Fetch
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#streaming-public" class="md-nav__link">
<span class="md-ellipsis">
Streaming (Public)
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#public-gallery" class="md-nav__link">
<span class="md-ellipsis">
Public Gallery
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#tracking" class="md-nav__link">
<span class="md-ellipsis">
Tracking
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#reactions" class="md-nav__link">
<span class="md-ellipsis">
Reactions
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#comments-chat" class="md-nav__link">
<span class="md-ellipsis">
Comments &amp; Chat
</span>
</a>
<nav class="md-nav" aria-label="Comments &amp; Chat">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public-comments" class="md-nav__link">
<span class="md-ellipsis">
Public Comments
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#comment-admin" class="md-nav__link">
<span class="md-ellipsis">
Comment Admin
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#word-filters" class="md-nav__link">
<span class="md-ellipsis">
Word Filters
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#chat-threads-notifications" class="md-nav__link">
<span class="md-ellipsis">
Chat Threads &amp; Notifications
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#shorts" class="md-nav__link">
<span class="md-ellipsis">
Shorts
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#upvotes" class="md-nav__link">
<span class="md-ellipsis">
Upvotes
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#playlists" class="md-nav__link">
<span class="md-ellipsis">
Playlists
</span>
</a>
<nav class="md-nav" aria-label="Playlists">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#public_6" class="md-nav__link">
<span class="md-ellipsis">
Public
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#user-playlists" class="md-nav__link">
<span class="md-ellipsis">
User Playlists
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#playlist-admin" class="md-nav__link">
<span class="md-ellipsis">
Playlist Admin
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#user-profile" class="md-nav__link">
<span class="md-ellipsis">
User Profile
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#route-summary" class="md-nav__link">
<span class="md-ellipsis">
Route Summary
</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<nav class="md-path" aria-label="Navigation" >
<ol class="md-path__list">
<li class="md-path__item">
<a href="../.." class="md-path__link">
<span class="md-ellipsis">
Home
</span>
</a>
</li>
<li class="md-path__item">
<a href="../" class="md-path__link">
<span class="md-ellipsis">
Docs
</span>
</a>
</li>
<li class="md-path__item">
<a href="./" class="md-path__link">
<span class="md-ellipsis">
API Reference
</span>
</a>
</li>
</ol>
</nav>
<article class="md-content__inner md-typeset">
<a href="https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/mkdocs/docs/docs/api/index.md" title="Edit this page" class="md-content__button md-icon" rel="edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10 20H6V4h7v5h5v3.1l2-2V8l-6-6H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h4zm10.2-7c.1 0 .3.1.4.2l1.3 1.3c.2.2.2.6 0 .8l-1 1-2.1-2.1 1-1c.1-.1.2-.2.4-.2m0 3.9L14.1 23H12v-2.1l6.1-6.1z"/></svg>
</a>
<a href="https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/mkdocs/docs/docs/api/index.md" title="View source of this page" class="md-content__button md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M17 18c.56 0 1 .44 1 1s-.44 1-1 1-1-.44-1-1 .44-1 1-1m0-3c-2.73 0-5.06 1.66-6 4 .94 2.34 3.27 4 6 4s5.06-1.66 6-4c-.94-2.34-3.27-4-6-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5 2.5 2.5 0 0 1 2.5-2.5 2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1-2.5 2.5M9.27 20H6V4h7v5h5v4.07c.7.08 1.36.25 2 .49V8l-6-6H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h4.5a8.2 8.2 0 0 1-1.23-2"/></svg>
</a>
<h1 id="api-reference">API Reference<a class="headerlink" href="#api-reference" title="Permanent link">&para;</a></h1>
<p>Changemaker Lite exposes two REST APIs sharing a single PostgreSQL database.</p>
<table>
<thead>
<tr>
<th>Server</th>
<th>Framework</th>
<th>Port</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Main API</strong></td>
<td>Express.js</td>
<td><code>4000</code></td>
<td>Auth, campaigns, map, shifts, canvassing, pages, email, settings</td>
</tr>
<tr>
<td><strong>Media API</strong></td>
<td>Fastify</td>
<td><code>4100</code></td>
<td>Video library, analytics, playlists, reactions, comments</td>
</tr>
</tbody>
</table>
<p>Both APIs use <strong>JWT Bearer authentication</strong> and return JSON. All request/response bodies are <code>application/json</code> unless noted otherwise.</p>
<hr />
<h2 id="authentication">Authentication<a class="headerlink" href="#authentication" title="Permanent link">&para;</a></h2>
<h3 id="token-flow">Token Flow<a class="headerlink" href="#token-flow" title="Permanent link">&para;</a></h3>
<pre class="mermaid"><code>sequenceDiagram
participant Client
participant API
participant DB
Client-&gt;&gt;API: POST /api/auth/login {email, password}
API-&gt;&gt;DB: Verify credentials
DB--&gt;&gt;API: User record
API--&gt;&gt;Client: {accessToken, refreshToken}
Note over Client: Store tokens
Client-&gt;&gt;API: GET /api/campaigns (Authorization: Bearer &lt;accessToken&gt;)
API--&gt;&gt;Client: 200 OK
Note over Client: Access token expires (15 min)
Client-&gt;&gt;API: POST /api/auth/refresh {refreshToken}
API-&gt;&gt;DB: Rotate token (atomic transaction)
DB--&gt;&gt;API: New token pair
API--&gt;&gt;Client: {accessToken, refreshToken}</code></pre>
<h3 id="headers">Headers<a class="headerlink" href="#headers" title="Permanent link">&para;</a></h3>
<p>All authenticated requests require:</p>
<div class="language-http highlight"><pre><span></span><code><span id="__span-0-1"><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a><span class="err">Authorization: Bearer &lt;accessToken&gt;</span>
</span></code></pre></div>
<p>The Media API also accepts tokens via query parameter for SSE streams:</p>
<div class="language-text highlight"><pre><span></span><code><span id="__span-1-1"><a id="__codelineno-1-1" name="__codelineno-1-1" href="#__codelineno-1-1"></a>GET /api/public/:id/chat-stream?token=&lt;accessToken&gt;
</span></code></pre></div>
<h3 id="roles">Roles<a class="headerlink" href="#roles" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Role</th>
<th>Access Level</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>SUPER_ADMIN</code></td>
<td>Full platform access</td>
</tr>
<tr>
<td><code>INFLUENCE_ADMIN</code></td>
<td>Campaign and advocacy management</td>
</tr>
<tr>
<td><code>MAP_ADMIN</code></td>
<td>Map, locations, shifts, canvassing</td>
</tr>
<tr>
<td><code>USER</code></td>
<td>Volunteer portal, public features</td>
</tr>
<tr>
<td><code>TEMP</code></td>
<td>Limited access (auto-created on public shift signup)</td>
</tr>
</tbody>
</table>
<h3 id="middleware-reference">Middleware Reference<a class="headerlink" href="#middleware-reference" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Middleware</th>
<th>Effect</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>authenticate</code></td>
<td>Requires valid JWT. Sets <code>req.user</code> with <code>id</code>, <code>email</code>, <code>role</code>. Returns <code>401</code> if missing or invalid.</td>
</tr>
<tr>
<td><code>optionalAuth</code></td>
<td>Same as <code>authenticate</code> but continues without user if token is absent.</td>
</tr>
<tr>
<td><code>requireRole(...roles)</code></td>
<td>Checks user role against allowed list. Returns <code>403</code> if not authorized.</td>
</tr>
<tr>
<td><code>requireNonTemp</code></td>
<td>Blocks <code>TEMP</code> users. Returns <code>403</code>.</td>
</tr>
<tr>
<td><code>validate(schema, source)</code></td>
<td>Validates request body/query/params against a Zod schema. Returns <code>400</code> on failure.</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="error-responses">Error Responses<a class="headerlink" href="#error-responses" title="Permanent link">&para;</a></h2>
<p>All errors follow a consistent format:</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-2-1"><a id="__codelineno-2-1" name="__codelineno-2-1" href="#__codelineno-2-1"></a><span class="p">{</span>
</span><span id="__span-2-2"><a id="__codelineno-2-2" name="__codelineno-2-2" href="#__codelineno-2-2"></a><span class="w"> </span><span class="nt">&quot;error&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
</span><span id="__span-2-3"><a id="__codelineno-2-3" name="__codelineno-2-3" href="#__codelineno-2-3"></a><span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Human-readable error description&quot;</span><span class="p">,</span>
</span><span id="__span-2-4"><a id="__codelineno-2-4" name="__codelineno-2-4" href="#__codelineno-2-4"></a><span class="w"> </span><span class="nt">&quot;code&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ERROR_CODE&quot;</span><span class="p">,</span>
</span><span id="__span-2-5"><a id="__codelineno-2-5" name="__codelineno-2-5" href="#__codelineno-2-5"></a><span class="w"> </span><span class="nt">&quot;statusCode&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">400</span>
</span><span id="__span-2-6"><a id="__codelineno-2-6" name="__codelineno-2-6" href="#__codelineno-2-6"></a><span class="w"> </span><span class="p">}</span>
</span><span id="__span-2-7"><a id="__codelineno-2-7" name="__codelineno-2-7" href="#__codelineno-2-7"></a><span class="p">}</span>
</span></code></pre></div>
<table>
<thead>
<tr>
<th>Status</th>
<th>Code</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>400</code></td>
<td><code>VALIDATION_ERROR</code></td>
<td>Request body/query failed schema validation</td>
</tr>
<tr>
<td><code>401</code></td>
<td><code>UNAUTHORIZED</code></td>
<td>Missing or invalid access token</td>
</tr>
<tr>
<td><code>403</code></td>
<td><code>FORBIDDEN</code></td>
<td>Valid token but insufficient role</td>
</tr>
<tr>
<td><code>404</code></td>
<td><code>NOT_FOUND</code></td>
<td>Resource does not exist</td>
</tr>
<tr>
<td><code>429</code></td>
<td><code>RATE_LIMITED</code></td>
<td>Too many requests (see Rate Limits)</td>
</tr>
<tr>
<td><code>500</code></td>
<td><code>INTERNAL_ERROR</code></td>
<td>Unexpected server error</td>
</tr>
</tbody>
</table>
<div class="admonition note">
<p class="admonition-title">Enumeration Prevention</p>
<p>Auth endpoints (<code>/login</code>, <code>/register</code>, <code>/forgot-password</code>) return generic success messages to prevent user enumeration. A <code>401</code> from <code>/api/auth/me</code> does <strong>not</strong> reveal whether the user exists.</p>
</div>
<hr />
<h2 id="rate-limits">Rate Limits<a class="headerlink" href="#rate-limits" title="Permanent link">&para;</a></h2>
<p>Rate limits are Redis-backed and keyed by IP address.</p>
<table>
<thead>
<tr>
<th>Endpoint Group</th>
<th>Window</th>
<th>Max Requests</th>
<th>Redis Prefix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Auth (login, register, refresh)</td>
<td>15 min</td>
<td>10</td>
<td><code>rl:auth:</code></td>
</tr>
<tr>
<td>Email sending</td>
<td>1 hour</td>
<td>30</td>
<td><code>rl:email:</code></td>
</tr>
<tr>
<td>Response submission</td>
<td>1 hour</td>
<td>10</td>
<td><code>rl:response:</code></td>
</tr>
<tr>
<td>Shift signup</td>
<td>1 hour</td>
<td>10</td>
<td><code>rl:shift-signup:</code></td>
</tr>
<tr>
<td>Canvass visits</td>
<td>1 min</td>
<td>30</td>
<td><code>rl:canvass-visit:</code></td>
</tr>
<tr>
<td>Canvass bulk visits</td>
<td>1 min</td>
<td>5</td>
<td><code>rl:canvass-visit-bulk:</code></td>
</tr>
<tr>
<td>GPS tracking</td>
<td>1 min</td>
<td>6</td>
<td><code>rl:gps-tracking:</code></td>
</tr>
<tr>
<td>Canvass geocode</td>
<td>1 min</td>
<td>10</td>
<td><code>rl:canvass-geocode:</code></td>
</tr>
<tr>
<td>Observability</td>
<td>1 min</td>
<td>20</td>
<td><code>rl:observability:</code></td>
</tr>
<tr>
<td>Health/metrics</td>
<td>1 min</td>
<td>30</td>
<td><code>rl:health-metrics:</code></td>
</tr>
<tr>
<td>Global (all other)</td>
<td>Configurable</td>
<td>Configurable</td>
<td><code>rl:global:</code></td>
</tr>
</tbody>
</table>
<p>When rate-limited, the API returns:</p>
<div class="language-json highlight"><pre><span></span><code><span id="__span-3-1"><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a><span class="p">{</span>
</span><span id="__span-3-2"><a id="__codelineno-3-2" name="__codelineno-3-2" href="#__codelineno-3-2"></a><span class="w"> </span><span class="nt">&quot;error&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
</span><span id="__span-3-3"><a id="__codelineno-3-3" name="__codelineno-3-3" href="#__codelineno-3-3"></a><span class="w"> </span><span class="nt">&quot;message&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Too many requests, please try again later&quot;</span><span class="p">,</span>
</span><span id="__span-3-4"><a id="__codelineno-3-4" name="__codelineno-3-4" href="#__codelineno-3-4"></a><span class="w"> </span><span class="nt">&quot;code&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;RATE_LIMITED&quot;</span><span class="p">,</span>
</span><span id="__span-3-5"><a id="__codelineno-3-5" name="__codelineno-3-5" href="#__codelineno-3-5"></a><span class="w"> </span><span class="nt">&quot;statusCode&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">429</span>
</span><span id="__span-3-6"><a id="__codelineno-3-6" name="__codelineno-3-6" href="#__codelineno-3-6"></a><span class="w"> </span><span class="p">}</span>
</span><span id="__span-3-7"><a id="__codelineno-3-7" name="__codelineno-3-7" href="#__codelineno-3-7"></a><span class="p">}</span>
</span></code></pre></div>
<hr />
<h2 id="main-api-express-port-4000">Main API (Express &mdash; Port 4000)<a class="headerlink" href="#main-api-express-port-4000" title="Permanent link">&para;</a></h2>
<h3 id="health-metrics">Health &amp; Metrics<a class="headerlink" href="#health-metrics" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/health</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Health check &mdash; PostgreSQL + Redis ping</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/metrics</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Prometheus metrics (text/plain)</td>
</tr>
</tbody>
</table>
<details class="example">
<summary>Health response</summary>
<div class="language-json highlight"><pre><span></span><code><span id="__span-4-1"><a id="__codelineno-4-1" name="__codelineno-4-1" href="#__codelineno-4-1"></a><span class="p">{</span>
</span><span id="__span-4-2"><a id="__codelineno-4-2" name="__codelineno-4-2" href="#__codelineno-4-2"></a><span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;healthy&quot;</span><span class="p">,</span>
</span><span id="__span-4-3"><a id="__codelineno-4-3" name="__codelineno-4-3" href="#__codelineno-4-3"></a><span class="w"> </span><span class="nt">&quot;checks&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
</span><span id="__span-4-4"><a id="__codelineno-4-4" name="__codelineno-4-4" href="#__codelineno-4-4"></a><span class="w"> </span><span class="nt">&quot;database&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span><span class="p">,</span>
</span><span id="__span-4-5"><a id="__codelineno-4-5" name="__codelineno-4-5" href="#__codelineno-4-5"></a><span class="w"> </span><span class="nt">&quot;redis&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;ok&quot;</span>
</span><span id="__span-4-6"><a id="__codelineno-4-6" name="__codelineno-4-6" href="#__codelineno-4-6"></a><span class="w"> </span><span class="p">}</span>
</span><span id="__span-4-7"><a id="__codelineno-4-7" name="__codelineno-4-7" href="#__codelineno-4-7"></a><span class="p">}</span>
</span></code></pre></div>
</details>
<hr />
<h3 id="auth">Auth<a class="headerlink" href="#auth" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/auth</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Rate Limited</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/auth/login</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Email + password login</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/register</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Create account (always <code>USER</code> role)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/verify-email</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Verify email with token</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/resend-verification</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Resend verification email</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/forgot-password</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Send password reset email</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/reset-password</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Set new password with reset token</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/refresh</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Rotate refresh token &rarr; new token pair</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/auth/logout</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Invalidate refresh token</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/auth/me</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Current user profile</td>
</tr>
</tbody>
</table>
<details class="example">
<summary>Login request &amp; response</summary>
<p><strong>Request:</strong>
<div class="language-json highlight"><pre><span></span><code><span id="__span-5-1"><a id="__codelineno-5-1" name="__codelineno-5-1" href="#__codelineno-5-1"></a><span class="p">{</span>
</span><span id="__span-5-2"><a id="__codelineno-5-2" name="__codelineno-5-2" href="#__codelineno-5-2"></a><span class="w"> </span><span class="nt">&quot;email&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;admin@example.com&quot;</span><span class="p">,</span>
</span><span id="__span-5-3"><a id="__codelineno-5-3" name="__codelineno-5-3" href="#__codelineno-5-3"></a><span class="w"> </span><span class="nt">&quot;password&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;SecurePass123!&quot;</span>
</span><span id="__span-5-4"><a id="__codelineno-5-4" name="__codelineno-5-4" href="#__codelineno-5-4"></a><span class="p">}</span>
</span></code></pre></div>
<strong>Response:</strong>
<div class="language-json highlight"><pre><span></span><code><span id="__span-6-1"><a id="__codelineno-6-1" name="__codelineno-6-1" href="#__codelineno-6-1"></a><span class="p">{</span>
</span><span id="__span-6-2"><a id="__codelineno-6-2" name="__codelineno-6-2" href="#__codelineno-6-2"></a><span class="w"> </span><span class="nt">&quot;accessToken&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;eyJhbG...&quot;</span><span class="p">,</span>
</span><span id="__span-6-3"><a id="__codelineno-6-3" name="__codelineno-6-3" href="#__codelineno-6-3"></a><span class="w"> </span><span class="nt">&quot;refreshToken&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;eyJhbG...&quot;</span><span class="p">,</span>
</span><span id="__span-6-4"><a id="__codelineno-6-4" name="__codelineno-6-4" href="#__codelineno-6-4"></a><span class="w"> </span><span class="nt">&quot;user&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
</span><span id="__span-6-5"><a id="__codelineno-6-5" name="__codelineno-6-5" href="#__codelineno-6-5"></a><span class="w"> </span><span class="nt">&quot;id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;uuid&quot;</span><span class="p">,</span>
</span><span id="__span-6-6"><a id="__codelineno-6-6" name="__codelineno-6-6" href="#__codelineno-6-6"></a><span class="w"> </span><span class="nt">&quot;email&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;admin@example.com&quot;</span><span class="p">,</span>
</span><span id="__span-6-7"><a id="__codelineno-6-7" name="__codelineno-6-7" href="#__codelineno-6-7"></a><span class="w"> </span><span class="nt">&quot;name&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Admin&quot;</span><span class="p">,</span>
</span><span id="__span-6-8"><a id="__codelineno-6-8" name="__codelineno-6-8" href="#__codelineno-6-8"></a><span class="w"> </span><span class="nt">&quot;role&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;SUPER_ADMIN&quot;</span>
</span><span id="__span-6-9"><a id="__codelineno-6-9" name="__codelineno-6-9" href="#__codelineno-6-9"></a><span class="w"> </span><span class="p">}</span>
</span><span id="__span-6-10"><a id="__codelineno-6-10" name="__codelineno-6-10" href="#__codelineno-6-10"></a><span class="p">}</span>
</span></code></pre></div></p>
</details>
<div class="admonition info">
<p class="admonition-title">Password Policy</p>
<p>Passwords must be at least <strong>12 characters</strong> with at least one uppercase letter, one lowercase letter, and one digit.</p>
</div>
<hr />
<h3 id="users">Users<a class="headerlink" href="#users" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/users</code> &middot; <strong>Auth:</strong> All routes require authentication</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Role</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/users</code></td>
<td>Admin</td>
<td>Paginated user list with search, role, and status filters</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/users/:id</code></td>
<td>Admin or self</td>
<td>Single user profile</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/users</code></td>
<td>Admin</td>
<td>Create user</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/users/:id</code></td>
<td>Admin or self</td>
<td>Update user (non-admins cannot change role/status)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/users/:id/approve</code></td>
<td>Admin</td>
<td>Approve pending user; sends approval email</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/users/:id/reject</code></td>
<td>Admin</td>
<td>Reject pending user</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/users/:id</code></td>
<td>Admin</td>
<td>Delete user</td>
</tr>
</tbody>
</table>
<p><strong>Query parameters for <code>GET /api/users</code>:</strong></p>
<table>
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>page</code></td>
<td>number</td>
<td>Page number (default 1)</td>
</tr>
<tr>
<td><code>limit</code></td>
<td>number</td>
<td>Items per page (default 20)</td>
</tr>
<tr>
<td><code>search</code></td>
<td>string</td>
<td>Search by name or email</td>
</tr>
<tr>
<td><code>role</code></td>
<td>string</td>
<td>Filter by role</td>
</tr>
<tr>
<td><code>status</code></td>
<td>string</td>
<td>Filter by status</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="dashboard">Dashboard<a class="headerlink" href="#dashboard" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/dashboard</code> &middot; <strong>Auth:</strong> Admin roles required</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Role</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/dashboard/summary</code></td>
<td>Any admin</td>
<td>Platform-wide counts (users, campaigns, locations, shifts)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/dashboard/system</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Hardware + OS info (CPU, memory, disk)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/dashboard/containers</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Docker container statuses</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/dashboard/weather</code></td>
<td>Any admin</td>
<td>Current weather at map center coordinates</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/dashboard/api-metrics</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Prometheus API performance metrics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/dashboard/time-series</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Prometheus time-series data</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/dashboard/container-resources</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>cAdvisor CPU/memory/network per container</td>
</tr>
</tbody>
</table>
<p><strong>Query parameters for <code>GET /api/dashboard/time-series</code>:</strong></p>
<table>
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>metrics</code></td>
<td>string</td>
<td>Comma-separated metric keys (whitelist-validated)</td>
</tr>
<tr>
<td><code>range</code></td>
<td>string</td>
<td>Time range (e.g., <code>1h</code>, <code>24h</code>, <code>7d</code>)</td>
</tr>
<tr>
<td><code>step</code></td>
<td>string</td>
<td>Sample interval (e.g., <code>5m</code>, <code>1h</code>)</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="campaigns">Campaigns<a class="headerlink" href="#campaigns" title="Permanent link">&para;</a></h3>
<h4 id="admin-crud">Admin CRUD<a class="headerlink" href="#admin-crud" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/campaigns</code> &middot; <strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/campaigns</code></td>
<td>Paginated campaign list</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/:id</code></td>
<td>Single campaign detail</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/campaigns</code></td>
<td>Create campaign</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/campaigns/:id</code></td>
<td>Update campaign</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/campaigns/:id</code></td>
<td>Delete campaign</td>
</tr>
</tbody>
</table>
<h4 id="public">Public<a class="headerlink" href="#public" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/campaigns/public</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>List all active campaigns</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/:slug/details</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Campaign detail by slug (ACTIVE only)</td>
</tr>
</tbody>
</table>
<h4 id="user-submissions">User Submissions<a class="headerlink" href="#user-submissions" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Authenticated, non-TEMP users</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/campaigns/user/submit</code></td>
<td>Submit campaign for moderation (5/hour limit)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/user/my-campaigns</code></td>
<td>List own submitted campaigns</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/campaigns/user/:id</code></td>
<td>Edit own pending campaign</td>
</tr>
</tbody>
</table>
<h4 id="moderation">Moderation<a class="headerlink" href="#moderation" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/campaigns/moderation/queue</code></td>
<td>Campaigns pending moderation</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/moderation/stats</code></td>
<td>Moderation queue statistics</td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/campaigns/moderation/:id</code></td>
<td>Approve or reject campaign</td>
</tr>
</tbody>
</table>
<h4 id="campaign-emails">Campaign Emails<a class="headerlink" href="#campaign-emails" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/campaigns/:slug/send-email</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Send advocacy email to representatives (rate limited: 30/hour)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/campaigns/:slug/track-mailto</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Track mailto link click</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/:id/emails</code></td>
<td>Admin</td>
<td>Paginated emails for campaign</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/:id/email-stats</code></td>
<td>Admin</td>
<td>Email statistics</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="responses">Responses<a class="headerlink" href="#responses" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/campaigns</code> (public) and <code>/api/responses</code> (admin + actions)</p>
<h4 id="public_1">Public<a class="headerlink" href="#public_1" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/campaigns/:slug/responses</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>List approved public responses</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/campaigns/:slug/response-stats</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Response statistics</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/campaigns/:slug/responses</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Submit response (rate limited: 10/hour)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/responses/:id/upvote</code></td>
<td>Optional</td>
<td>Upvote a response</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/responses/:id/upvote</code></td>
<td>Optional</td>
<td>Remove upvote</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/responses/:id/verify/:token</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Verify response via email link</td>
</tr>
</tbody>
</table>
<h4 id="admin">Admin<a class="headerlink" href="#admin" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/responses</code></td>
<td>All responses with filters</td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/responses/:id/status</code></td>
<td>Approve or reject response</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/responses/:id/resend-verification</code></td>
<td>Resend verification email</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/responses/:id</code></td>
<td>Delete response</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="representatives">Representatives<a class="headerlink" href="#representatives" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/representatives</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/representatives/by-postal/:postalCode</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Lookup representatives by postal code (cache-first)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/representatives/test-connection</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Represent API health check</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/representatives/cache-stats</code></td>
<td>Admin</td>
<td>Cache statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/representatives</code></td>
<td>Admin</td>
<td>Paginated cached representatives</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/representatives/:id</code></td>
<td>Admin</td>
<td>Single cached representative</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/representatives/by-postal/:postalCode</code></td>
<td>Admin</td>
<td>Clear cache for postal code</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/representatives/:id</code></td>
<td>Admin</td>
<td>Delete cached representative</td>
</tr>
</tbody>
</table>
<p><strong>Query parameters for postal code lookup:</strong></p>
<table>
<thead>
<tr>
<th>Param</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>refresh</code></td>
<td>boolean</td>
<td>Force API call, bypass cache</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="email-queue">Email Queue<a class="headerlink" href="#email-queue" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/email-queue</code> &middot; <strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/email-queue/stats</code></td>
<td>BullMQ queue statistics (waiting, active, completed, failed)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-queue/pause</code></td>
<td>Pause email processing</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-queue/resume</code></td>
<td>Resume email processing</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-queue/clean</code></td>
<td>Clean completed jobs</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="locations">Locations<a class="headerlink" href="#locations" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/locations</code></p>
<h4 id="public_2">Public<a class="headerlink" href="#public_2" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/locations/public</code></td>
<td>All geocoded locations for map (no PII); optional <code>?bounds=</code></td>
</tr>
</tbody>
</table>
<h4 id="admin_1">Admin<a class="headerlink" href="#admin_1" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> <code>SUPER_ADMIN</code> or <code>MAP_ADMIN</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/locations</code></td>
<td>Paginated locations with filters</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/stats</code></td>
<td>Location statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/all</code></td>
<td>All geocoded locations for admin map</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/export-csv</code></td>
<td>CSV export</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/:id</code></td>
<td>Single location</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/:id/history</code></td>
<td>Edit history</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations</code></td>
<td>Create location</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/map/locations/:id</code></td>
<td>Update location</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/map/locations/:id</code></td>
<td>Delete location</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations/bulk-delete</code></td>
<td>Bulk delete</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations/geocode</code></td>
<td>Geocode single address</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations/geocode-missing</code></td>
<td>Batch geocode all ungeocoded</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations/reverse-geocode</code></td>
<td>Reverse geocode lat/lng to address</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations/import-csv</code></td>
<td>Import from CSV (10 MB limit)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/locations/import-bulk</code></td>
<td>Bulk NAR or standard CSV import (100 MB limit)</td>
</tr>
</tbody>
</table>
<h4 id="bulk-geocode">Bulk Geocode<a class="headerlink" href="#bulk-geocode" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/map/locations/bulk-geocode</code> &middot; <strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/map/locations/bulk-geocode</code></td>
<td>Start BullMQ bulk geocoding job</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/bulk-geocode/:jobId</code></td>
<td>Poll job status</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/locations/bulk-geocode/stats</code></td>
<td>Queue statistics</td>
</tr>
</tbody>
</table>
<h4 id="nar-import">NAR Import<a class="headerlink" href="#nar-import" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/map/nar-import</code> &middot; <strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/nar-import/datasets</code></td>
<td>Available NAR datasets by province</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/nar-import</code></td>
<td>Start province import (fire-and-forget)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/nar-import/status/:importId</code></td>
<td>Poll import progress</td>
</tr>
</tbody>
</table>
<details class="info">
<summary>NAR Import body</summary>
<div class="language-json highlight"><pre><span></span><code><span id="__span-7-1"><a id="__codelineno-7-1" name="__codelineno-7-1" href="#__codelineno-7-1"></a><span class="p">{</span>
</span><span id="__span-7-2"><a id="__codelineno-7-2" name="__codelineno-7-2" href="#__codelineno-7-2"></a><span class="w"> </span><span class="nt">&quot;provinceCode&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;24&quot;</span><span class="p">,</span>
</span><span id="__span-7-3"><a id="__codelineno-7-3" name="__codelineno-7-3" href="#__codelineno-7-3"></a><span class="w"> </span><span class="nt">&quot;filterType&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;city&quot;</span><span class="p">,</span>
</span><span id="__span-7-4"><a id="__codelineno-7-4" name="__codelineno-7-4" href="#__codelineno-7-4"></a><span class="w"> </span><span class="nt">&quot;filterCity&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Edmonton&quot;</span><span class="p">,</span>
</span><span id="__span-7-5"><a id="__codelineno-7-5" name="__codelineno-7-5" href="#__codelineno-7-5"></a><span class="w"> </span><span class="nt">&quot;residentialOnly&quot;</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
</span><span id="__span-7-6"><a id="__codelineno-7-6" name="__codelineno-7-6" href="#__codelineno-7-6"></a><span class="w"> </span><span class="nt">&quot;deduplicateRadius&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">10</span><span class="p">,</span>
</span><span id="__span-7-7"><a id="__codelineno-7-7" name="__codelineno-7-7" href="#__codelineno-7-7"></a><span class="w"> </span><span class="nt">&quot;batchSize&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">500</span>
</span><span id="__span-7-8"><a id="__codelineno-7-8" name="__codelineno-7-8" href="#__codelineno-7-8"></a><span class="p">}</span>
</span></code></pre></div>
</details>
<h4 id="area-import">Area Import<a class="headerlink" href="#area-import" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/map/area-import</code> &middot; <strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/map/area-import/preview</code></td>
<td>Preview bounds + estimated record counts</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/area-import</code></td>
<td>Start area import (fire-and-forget)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/area-import/status/:importId</code></td>
<td>Poll import progress</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="cuts-polygons">Cuts (Polygons)<a class="headerlink" href="#cuts-polygons" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/cuts</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/cuts/public</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>All public cuts as GeoJSON</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/cuts</code></td>
<td>Map admin</td>
<td>Paginated cuts list</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/cuts/:id</code></td>
<td>Map admin</td>
<td>Single cut</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/cuts</code></td>
<td>Map admin</td>
<td>Create cut (polygon GeoJSON)</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/map/cuts/:id</code></td>
<td>Map admin</td>
<td>Update cut</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/map/cuts/:id</code></td>
<td>Map admin</td>
<td>Delete cut</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/cuts/:id/locations</code></td>
<td>Map admin</td>
<td>All locations within cut polygon</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/cuts/:id/statistics</code></td>
<td>Map admin</td>
<td>Support level breakdown</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/cuts/export-geojson</code></td>
<td>Map admin</td>
<td>All cuts as GeoJSON FeatureCollection</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/cuts/:id/export-geojson</code></td>
<td>Map admin</td>
<td>Single cut as GeoJSON Feature</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/cuts/import-geojson</code></td>
<td>Map admin</td>
<td>Import cuts from GeoJSON file</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="shifts">Shifts<a class="headerlink" href="#shifts" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/shifts</code></p>
<h4 id="public_3">Public<a class="headerlink" href="#public_3" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/public</code></td>
<td>List upcoming public shifts</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/shifts/public/:id/signup</code></td>
<td>Public signup (creates TEMP user if needed; rate limited: 10/hour)</td>
</tr>
</tbody>
</table>
<h4 id="volunteer">Volunteer<a class="headerlink" href="#volunteer" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Any authenticated user</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/volunteer/upcoming</code></td>
<td>Upcoming shifts with signup status</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/volunteer/my-signups</code></td>
<td>Own confirmed signups</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/shifts/volunteer/:id/signup</code></td>
<td>Sign up for shift</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/map/shifts/volunteer/:id/signup</code></td>
<td>Cancel signup</td>
</tr>
</tbody>
</table>
<h4 id="admin_2">Admin<a class="headerlink" href="#admin_2" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/shifts</code></td>
<td>Paginated shifts with filters</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/stats</code></td>
<td>Statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/calendar</code></td>
<td>Calendar data (<code>?startDate=&amp;endDate=</code>)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/:id</code></td>
<td>Single shift with signups</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/shifts</code></td>
<td>Create shift</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/map/shifts/:id</code></td>
<td>Update shift</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/map/shifts/:id</code></td>
<td>Delete shift</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/shifts/:id/signups</code></td>
<td>Admin-add volunteer</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/map/shifts/:id/signups/:signupId</code></td>
<td>Remove volunteer</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/shifts/:id/email-details</code></td>
<td>Email details to all volunteers</td>
</tr>
</tbody>
</table>
<h4 id="shift-series">Shift Series<a class="headerlink" href="#shift-series" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/map/shifts/series</code></td>
<td>Create recurring shift series</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/shifts/series/:id</code></td>
<td>Get series</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/map/shifts/series/:id</code></td>
<td>Update series</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/map/shifts/series/:id</code></td>
<td>Delete series</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="canvassing">Canvassing<a class="headerlink" href="#canvassing" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/canvass</code></p>
<h4 id="volunteer_1">Volunteer<a class="headerlink" href="#volunteer_1" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Any authenticated user</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/my/assignments</code></td>
<td>Shift assignments</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/my/stats</code></td>
<td>Personal canvass statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/my/visits</code></td>
<td>Visit history</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/my/session</code></td>
<td>Active canvass session</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/sessions</code></td>
<td>Start canvass session</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/sessions/:id/end</code></td>
<td>End session</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/cuts/:cutId/locations</code></td>
<td>Locations in cut with visit annotations</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/cuts/:cutId/route</code></td>
<td>Walking route algorithm for cut</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/locations</code></td>
<td>All locations with visit annotations</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/map/canvass/locations/:id</code></td>
<td>Edit address (role-gated fields)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/locations</code></td>
<td>Create location</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/reverse-geocode</code></td>
<td>Reverse geocode lat/lng</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/geocode-search</code></td>
<td>Geocode address for map (rate limited: 10/min)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/visits</code></td>
<td>Record door knock (rate limited: 30/min)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/canvass/visits/bulk</code></td>
<td>Record visit for all unvisited units (rate limited: 5/min)</td>
</tr>
</tbody>
</table>
<h4 id="admin_3">Admin<a class="headerlink" href="#admin_3" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> <code>SUPER_ADMIN</code> or <code>MAP_ADMIN</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/stats</code></td>
<td>Platform-wide canvass statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/stats/cuts/:cutId</code></td>
<td>Statistics for specific cut</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/activity</code></td>
<td>Recent activity feed</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/volunteers</code></td>
<td>All volunteers with canvass activity</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/volunteers/:userId</code></td>
<td>Individual volunteer statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/canvass/visits</code></td>
<td>All visits with filters</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="gps-tracking">GPS Tracking<a class="headerlink" href="#gps-tracking" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/tracking</code></p>
<h4 id="volunteer_2">Volunteer<a class="headerlink" href="#volunteer_2" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Any authenticated user</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/map/tracking/sessions</code></td>
<td>Start GPS tracking session</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/tracking/sessions/:id/end</code></td>
<td>End tracking session</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/tracking/sessions/:id/points</code></td>
<td>Submit GPS point batch (rate limited: 6/min)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/map/tracking/sessions/:id/link-canvass</code></td>
<td>Link to canvass session</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/tracking/my/session</code></td>
<td>Active tracking session</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/tracking/my/sessions</code></td>
<td>Own historical sessions</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/tracking/my/sessions/:id/route</code></td>
<td>Full route for own session</td>
</tr>
</tbody>
</table>
<h4 id="admin_4">Admin<a class="headerlink" href="#admin_4" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/tracking/live</code></td>
<td>Live volunteer positions + trails</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/tracking/sessions</code></td>
<td>All historical tracking sessions</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/map/tracking/sessions/:id/route</code></td>
<td>Full route for any session</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="map-settings">Map Settings<a class="headerlink" href="#map-settings" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/settings</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/settings</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Public map settings (center, zoom, walk sheet config)</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/map/settings</code></td>
<td>Map admin</td>
<td>Update map settings</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="geocoding">Geocoding<a class="headerlink" href="#geocoding" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/map/geocoding</code> &middot; <strong>Auth:</strong> Map admins</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/map/geocoding/search</code></td>
<td>Geocode address search (<code>?q=&amp;limit=1-10</code>)</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="landing-pages">Landing Pages<a class="headerlink" href="#landing-pages" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/pages</code> and <code>/api/page-blocks</code></p>
<h4 id="public_4">Public<a class="headerlink" href="#public_4" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/pages/:slug/view</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Get published page by slug</td>
</tr>
</tbody>
</table>
<h4 id="admin_5">Admin<a class="headerlink" href="#admin_5" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/pages</code></td>
<td>Paginated landing pages</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pages/:id</code></td>
<td>Single page</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pages</code></td>
<td>Create page</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/pages/:id</code></td>
<td>Update page</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/pages/:id</code></td>
<td>Delete page</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pages/sync</code></td>
<td>Sync MkDocs overrides from filesystem</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pages/validate</code></td>
<td>Validate and repair MkDocs exports</td>
</tr>
</tbody>
</table>
<h4 id="block-library">Block Library<a class="headerlink" href="#block-library" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/page-blocks</code></td>
<td>List blocks</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/page-blocks/:id</code></td>
<td>Single block</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/page-blocks</code></td>
<td>Create block</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/page-blocks/:id</code></td>
<td>Update block</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/page-blocks/:id</code></td>
<td>Delete block</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="email-templates">Email Templates<a class="headerlink" href="#email-templates" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/email-templates</code> &middot; <strong>Auth:</strong> Admin roles (seed/cache require <code>SUPER_ADMIN</code>)</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/email-templates</code></td>
<td>List templates</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/email-templates/:id</code></td>
<td>Single template</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-templates</code></td>
<td>Create template</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/email-templates/:id</code></td>
<td>Update template</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/email-templates/:id</code></td>
<td>Delete template</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/email-templates/:id/versions</code></td>
<td>Version history</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/email-templates/:id/versions/:versionNumber</code></td>
<td>Specific version</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-templates/:id/rollback</code></td>
<td>Rollback to prior version</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-templates/validate</code></td>
<td>Validate Handlebars syntax</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-templates/:id/test</code></td>
<td>Send test email (rate limited: 10/15min)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/email-templates/:id/test-logs</code></td>
<td>Test send logs</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-templates/seed</code></td>
<td>Seed templates from filesystem</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/email-templates/clear-cache</code></td>
<td>Clear template cache</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="qr-codes">QR Codes<a class="headerlink" href="#qr-codes" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/qr</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Generate QR code PNG (<code>?text=&amp;size=50-500</code>)</td>
</tr>
</tbody>
</table>
<p>Cached for 1 hour. Returns <code>image/png</code>.</p>
<hr />
<h3 id="site-settings">Site Settings<a class="headerlink" href="#site-settings" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/settings</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/settings</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Public site settings (SMTP credentials stripped)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/settings/admin</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Full settings including SMTP credentials</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/settings</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Update settings</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/settings/email/test-connection</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Test SMTP connection</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/settings/email/test-send</code></td>
<td><code>SUPER_ADMIN</code></td>
<td>Send test email</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="listmonk-newsletter-sync">Listmonk (Newsletter Sync)<a class="headerlink" href="#listmonk-newsletter-sync" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/listmonk</code> &middot; <strong>Auth:</strong> <code>SUPER_ADMIN</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/listmonk</code></td>
<td>Sync status + connection check</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/listmonk/stats</code></td>
<td>Subscriber counts from Listmonk</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/listmonk/test-connection</code></td>
<td>Health check</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/listmonk/sync/participants</code></td>
<td>Sync campaign participants</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/listmonk/sync/locations</code></td>
<td>Sync locations</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/listmonk/sync/users</code></td>
<td>Sync users</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/listmonk/sync/all</code></td>
<td>Run all sync operations</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/listmonk/reinitialize</code></td>
<td>Reinitialize Listmonk lists</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/listmonk/proxy-url</code></td>
<td>Proxy port + JWT for iframe</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="documentation-management">Documentation Management<a class="headerlink" href="#documentation-management" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/docs</code> &middot; <strong>Auth:</strong> Authenticated, non-TEMP (write operations require <code>SUPER_ADMIN</code>)</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/docs/status</code></td>
<td>MkDocs + Code Server availability</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/docs/config</code></td>
<td>Port numbers for iframe URLs</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/docs/mkdocs-config</code></td>
<td>Read raw <code>mkdocs.yml</code></td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/docs/mkdocs-config</code></td>
<td>Write <code>mkdocs.yml</code></td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/docs/build</code></td>
<td>Trigger MkDocs build</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/docs/upload</code></td>
<td>Upload asset (20 MB, whitelisted extensions)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/docs/files</code></td>
<td>File tree (<code>?force=true</code> bypasses cache)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/docs/files/rename</code></td>
<td>Rename or move file</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/docs/files/*</code></td>
<td>Read file content</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/docs/files/*</code></td>
<td>Write file content</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/docs/files/*</code></td>
<td>Create file or folder</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/docs/files/*</code></td>
<td>Delete file or empty folder</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="services">Services<a class="headerlink" href="#services" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/services</code> &middot; <strong>Auth:</strong> <code>SUPER_ADMIN</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/services/status</code></td>
<td>Health check all managed services (NocoDB, n8n, Gitea, MailHog, Mini QR, Excalidraw, Homepage)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/services/config</code></td>
<td>Port numbers + subdomain info</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="pangolin-tunnel-management">Pangolin (Tunnel Management)<a class="headerlink" href="#pangolin-tunnel-management" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/pangolin</code> &middot; <strong>Auth:</strong> <code>SUPER_ADMIN</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/pangolin/status</code></td>
<td>Tunnel health + connection info</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/config</code></td>
<td>Current env configuration</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/newt-status</code></td>
<td>Newt container status</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pangolin/newt-restart</code></td>
<td>Restart Newt container</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/sites</code></td>
<td>List Pangolin sites</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/exit-nodes</code></td>
<td>Available exit nodes</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/resource-definitions</code></td>
<td>Resource definitions from YAML</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/resources</code></td>
<td>List resources</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pangolin/setup</code></td>
<td>Create site + all resources (rate limited: &#8535;min)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pangolin/sync</code></td>
<td>Sync resources (create missing, update changed)</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/pangolin/resource/:id</code></td>
<td>Update resource</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/pangolin/resource/:id</code></td>
<td>Delete resource</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/resource/:id/clients</code></td>
<td>Connected clients</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/pangolin/certificate/:domainId/:domain</code></td>
<td>Certificate info</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/pangolin/certificate/:certId</code></td>
<td>Update certificate</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="observability">Observability<a class="headerlink" href="#observability" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/observability</code> &middot; <strong>Auth:</strong> <code>SUPER_ADMIN</code> &middot; <strong>Rate limited:</strong> 20/min</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/observability/status</code></td>
<td>Check 7 monitoring services</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/observability/metrics-summary</code></td>
<td>Key metrics from Prometheus</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/observability/alerts</code></td>
<td>Active alerts from Alertmanager</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="payments">Payments<a class="headerlink" href="#payments" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/payments</code></p>
<h4 id="public_5">Public<a class="headerlink" href="#public_5" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/payments/config</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Stripe publishable key + donation settings</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/plans</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Active subscription plans</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/products</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Active products (<code>?type=</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/subscribe</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Create subscription checkout</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/purchase</code></td>
<td>Optional</td>
<td>Product checkout (guest or logged-in)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/donate</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Donation checkout</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/my-subscription</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Current subscription</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/my-subscription/cancel</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Cancel subscription</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/webhook</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Stripe webhook (raw body)</td>
</tr>
</tbody>
</table>
<h4 id="admin_6">Admin<a class="headerlink" href="#admin_6" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> <code>SUPER_ADMIN</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/settings</code></td>
<td>Payment settings (secrets masked)</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/payments/admin/settings</code></td>
<td>Update payment settings</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/settings/test-connection</code></td>
<td>Test Stripe connection</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/dashboard</code></td>
<td>Subscription + donation statistics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/plans</code></td>
<td>All subscription plans</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/plans</code></td>
<td>Create plan</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/payments/admin/plans/:id</code></td>
<td>Update plan</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/payments/admin/plans/:id</code></td>
<td>Delete plan</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/plans/:id/sync-stripe</code></td>
<td>Sync plan to Stripe</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/subscriptions</code></td>
<td>All subscriptions with filters</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/subscriptions/:id/cancel</code></td>
<td>Cancel subscription</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/products</code></td>
<td>All products</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/products</code></td>
<td>Create product</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/payments/admin/products/:id</code></td>
<td>Update product</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/payments/admin/products/:id</code></td>
<td>Delete product</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/products/:id/sync-stripe</code></td>
<td>Sync product to Stripe</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/orders</code></td>
<td>List orders</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/payments/admin/orders/:id/refund</code></td>
<td>Refund order</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/donations</code></td>
<td>List donations</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/payments/admin/export</code></td>
<td>CSV export of completed orders</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="media-api-fastify-port-4100">Media API (Fastify &mdash; Port 4100)<a class="headerlink" href="#media-api-fastify-port-4100" title="Permanent link">&para;</a></h2>
<p>The Media API is a separate Fastify server sharing the same PostgreSQL database. It handles all video-related functionality.</p>
<h3 id="health">Health<a class="headerlink" href="#health" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/health</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Media API health check</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="videos-admin">Videos (Admin)<a class="headerlink" href="#videos-admin" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/videos</code> &middot; <strong>Auth:</strong> Admin roles</p>
<h4 id="crud-publishing">CRUD &amp; Publishing<a class="headerlink" href="#crud-publishing" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/videos</code></td>
<td>List videos (<code>?limit=&amp;offset=&amp;search=&amp;orientation=&amp;producers=&amp;isShort=</code>)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/producers</code></td>
<td>Distinct producer list</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/health</code></td>
<td>Video count health check</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id</code></td>
<td>Single video detail</td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/videos/:id</code></td>
<td>Update metadata (title, producer, tags, quality, etc.)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/publish</code></td>
<td>Publish to category</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/unpublish</code></td>
<td>Unpublish</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/bulk-publish</code></td>
<td>Bulk publish</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/bulk-unpublish</code></td>
<td>Bulk unpublish</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/lock</code></td>
<td>Lock published video</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/unlock</code></td>
<td>Unlock video</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/generate-thumbnail</code></td>
<td>Generate thumbnail via FFmpeg</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/bulk-generate-thumbnails</code></td>
<td>Bulk thumbnail generation</td>
</tr>
</tbody>
</table>
<h4 id="upload">Upload<a class="headerlink" href="#upload" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/videos/upload</code></td>
<td>Single video upload (multipart, 10 GB limit, streams to disk)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/upload/batch</code></td>
<td>Batch upload (returns 207 multi-status)</td>
</tr>
</tbody>
</table>
<h4 id="actions">Actions<a class="headerlink" href="#actions" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/duplicate</code></td>
<td>Duplicate video record</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/replace</code></td>
<td>Replace video file, keep metadata</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id/analytics</code></td>
<td>Detailed analytics (<code>?startDate=&amp;endDate=</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/reset-analytics</code></td>
<td>Reset all analytics</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id/preview-link</code></td>
<td>Generate 24-hour JWT preview link</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/analytics/top</code></td>
<td>Top videos (<code>?metric=views|watchTime&amp;limit=</code>)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/analytics/overview</code></td>
<td>Global analytics overview</td>
</tr>
</tbody>
</table>
<h4 id="scheduling">Scheduling<a class="headerlink" href="#scheduling" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/schedule-publish</code></td>
<td>Schedule future publish (<code>{publishAt, timezone?}</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/:id/schedule-unpublish</code></td>
<td>Schedule future unpublish</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/videos/:id/schedule/:action</code></td>
<td>Cancel scheduled operation</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/schedules/upcoming</code></td>
<td>Upcoming scheduled operations</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id/schedule-history</code></td>
<td>Schedule history for video</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/schedules/stats</code></td>
<td>Schedule queue statistics</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/schedules/pause</code></td>
<td>Pause schedule queue</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/schedules/resume</code></td>
<td>Resume schedule queue</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/videos/schedules/cleanup</code></td>
<td>Clean old completed jobs</td>
</tr>
</tbody>
</table>
<h4 id="video-fetch">Video Fetch<a class="headerlink" href="#video-fetch" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/videos/fetch</code></td>
<td>Submit fetch job (<code>{urls: string[]}</code>, 1&ndash;20 URLs)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/fetch/jobs</code></td>
<td>List recent fetch jobs</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/fetch/jobs/:jobId</code></td>
<td>Job detail + log</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/fetch/jobs/:jobId/log</code></td>
<td>SSE log stream (Redis pub/sub)</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/videos/fetch/jobs/:jobId</code></td>
<td>Cancel fetch job</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="streaming-public">Streaming (Public)<a class="headerlink" href="#streaming-public" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/videos</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/videos/stream/health</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Streaming health check</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id/stream</code></td>
<td>Optional</td>
<td>HTTP range-supporting video stream</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id/thumbnail</code></td>
<td>Optional</td>
<td>Serve thumbnail image</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/videos/:id/metadata</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Public video metadata for embedding</td>
</tr>
</tbody>
</table>
<div class="admonition note">
<p class="admonition-title">Note</p>
<p>Admins can stream unpublished videos by providing a valid JWT.</p>
</div>
<hr />
<h3 id="public-gallery">Public Gallery<a class="headerlink" href="#public-gallery" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/public</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/public</code></td>
<td>Optional</td>
<td>Published videos (<code>?limit=&amp;offset=&amp;search=&amp;sort=recent|popular|oldest&amp;category=</code>)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/categories</code></td>
<td>Optional</td>
<td>Categories with video counts</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/producers</code></td>
<td>Optional</td>
<td>Published producers</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/:id</code></td>
<td>Optional</td>
<td>Single published video</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/:id/thumbnail</code></td>
<td>Optional</td>
<td>Published thumbnail</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/:id/stream</code></td>
<td>Optional</td>
<td>Published video stream</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="tracking">Tracking<a class="headerlink" href="#tracking" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/track</code> &middot; <strong>Auth:</strong> None required</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/track/health</code></td>
<td>Tracking health check</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/track/view</code></td>
<td>Record video view (returns <code>{viewId}</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/track/event</code></td>
<td>Record play/pause/seek/complete event</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/track/heartbeat</code></td>
<td>Update watch time (10s interval, <code>sendBeacon</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/track/batch</code></td>
<td>Batch up to 50 tracking events</td>
</tr>
</tbody>
</table>
<details class="info">
<summary>Tracking is GDPR-compliant</summary>
<p>IP addresses are hashed with a daily-rotating salt. Raw IPs are never stored. Tracking data is retained for 90 days.</p>
</details>
<hr />
<h3 id="reactions">Reactions<a class="headerlink" href="#reactions" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/reactions</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/reactions/config</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Available reaction types + emoji mappings</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/reactions</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>List reactions (<code>?mediaId=&amp;userId=&amp;limit=</code>)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/reactions/:mediaId/chat</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Reactions in chat timeline format</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/reactions</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M21 7 9 19l-5.5-5.5 1.41-1.41L9 16.17 19.59 5.59z"/></svg></span></td>
<td>Add reaction (30s cooldown per type)</td>
</tr>
</tbody>
</table>
<p>Available types: <code>like</code>, <code>love</code>, <code>laugh</code>, <code>wow</code>, <code>sad</code>, <code>angry</code></p>
<hr />
<h3 id="comments-chat">Comments &amp; Chat<a class="headerlink" href="#comments-chat" title="Permanent link">&para;</a></h3>
<h4 id="public-comments">Public Comments<a class="headerlink" href="#public-comments" title="Permanent link">&para;</a></h4>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/public/:id/comments</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>List comments (<code>?limit=&amp;offset=</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/public/:id/comments</code></td>
<td>Optional</td>
<td>Create comment (word-filtered; rate limited: 5/min)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/:id/chat-stream</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>SSE stream for real-time chat (30s keepalive)</td>
</tr>
</tbody>
</table>
<h4 id="comment-admin">Comment Admin<a class="headerlink" href="#comment-admin" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/media/admin/comments</code> &middot; <strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/media/admin/comments/stats</code></td>
<td>Counts by status</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/media/admin/comments</code></td>
<td>All comments with filters</td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/media/admin/comments/:id/approve</code></td>
<td>Approve comment</td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/media/admin/comments/:id/hide</code></td>
<td>Hide comment</td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/media/admin/comments/:id/unhide</code></td>
<td>Unhide comment</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/media/admin/comments/:id/notes</code></td>
<td>Update moderation notes</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/media/admin/comments/:id</code></td>
<td>Delete comment</td>
</tr>
</tbody>
</table>
<h4 id="word-filters">Word Filters<a class="headerlink" href="#word-filters" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/media/admin/word-filters</code> &middot; <strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/media/admin/word-filters</code></td>
<td>List filter entries grouped by level</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/media/admin/word-filters</code></td>
<td>Add word (<code>{word, level: low|medium|high|custom}</code>)</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/media/admin/word-filters/:id</code></td>
<td>Remove word</td>
</tr>
</tbody>
</table>
<h4 id="chat-threads-notifications">Chat Threads &amp; Notifications<a class="headerlink" href="#chat-threads-notifications" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Authenticated</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/media/chat/threads</code></td>
<td>Videos with user's comments + unread counts</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/media/chat/threads/:mediaId/read</code></td>
<td>Mark thread as read</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/media/notifications/stream</code></td>
<td>Per-user SSE notification stream (<code>?token=</code>)</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="shorts">Shorts<a class="headerlink" href="#shorts" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/shorts</code></td>
<td>Optional</td>
<td>Shorts feed (<code>?sort=recent|popular|random</code>)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/shorts/scan</code></td>
<td>Admin</td>
<td>Auto-classify short videos by duration</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="upvotes">Upvotes<a class="headerlink" href="#upvotes" title="Permanent link">&para;</a></h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>POST</td>
<td><code>/api/public/:id/upvote</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Toggle upvote (session-based via <code>X-Session-ID</code> header)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/public/:id/upvote-status</code></td>
<td><span class="twemoji"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></span></td>
<td>Check upvote status for current session</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="playlists">Playlists<a class="headerlink" href="#playlists" title="Permanent link">&para;</a></h3>
<h4 id="public_6">Public<a class="headerlink" href="#public_6" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/playlists</code></p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Auth</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/playlists/featured</code></td>
<td>Optional</td>
<td>Featured playlists</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/playlists/popular</code></td>
<td>Optional</td>
<td>Popular public playlists (<code>?search=</code>)</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/playlists/share/:token</code></td>
<td>Optional</td>
<td>Playlist by share token</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/playlists/:id</code></td>
<td>Optional</td>
<td>Playlist detail (public, owner, or share token)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/playlists/:id/view</code></td>
<td>Optional</td>
<td>Record playlist view</td>
</tr>
</tbody>
</table>
<h4 id="user-playlists">User Playlists<a class="headerlink" href="#user-playlists" title="Permanent link">&para;</a></h4>
<p><strong>Auth:</strong> Authenticated</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/playlists/my</code></td>
<td>Own playlists</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/playlists</code></td>
<td>Create playlist</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/playlists/:id</code></td>
<td>Update playlist (ownership check)</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/playlists/:id</code></td>
<td>Delete playlist</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/playlists/:id/videos</code></td>
<td>Add video (<code>{mediaId}</code>)</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/playlists/:id/videos/:mediaId</code></td>
<td>Remove video</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/playlists/:id/videos/reorder</code></td>
<td>Reorder videos</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/playlists/:id/share</code></td>
<td>Generate share token</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/playlists/:id/share</code></td>
<td>Revoke share token</td>
</tr>
</tbody>
</table>
<h4 id="playlist-admin">Playlist Admin<a class="headerlink" href="#playlist-admin" title="Permanent link">&para;</a></h4>
<p><strong>Prefix:</strong> <code>/api/media/playlists</code> &middot; <strong>Auth:</strong> Admin roles</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/media/playlists</code></td>
<td>All playlists</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/media/playlists/featured</code></td>
<td>Featured playlists with admin info</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/media/playlists/:id/feature</code></td>
<td>Feature a playlist</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/media/playlists/:id/feature</code></td>
<td>Unfeature a playlist</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/media/playlists/featured/reorder</code></td>
<td>Reorder featured playlists</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/media/playlists/:id</code></td>
<td>Admin update any playlist</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/media/playlists/:id/duplicate</code></td>
<td>Duplicate playlist</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/api/media/playlists/:id</code></td>
<td>Admin delete any playlist</td>
</tr>
</tbody>
</table>
<hr />
<h3 id="user-profile">User Profile<a class="headerlink" href="#user-profile" title="Permanent link">&para;</a></h3>
<p><strong>Prefix:</strong> <code>/api/media/me</code> &middot; <strong>Auth:</strong> Authenticated</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>GET</td>
<td><code>/api/media/me/stats</code></td>
<td>User stats + 30-day activity + achievements</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/media/me/watch-history</code></td>
<td>Paginated watch history</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/media/me/stats/recalculate</code></td>
<td>Recompute stats from raw data</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/media/me/settings</code></td>
<td>Privacy settings</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/media/me/settings</code></td>
<td>Update privacy settings</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/media/me/profile</code></td>
<td>Update display name</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/api/media/me/password</code></td>
<td>Change password</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="route-summary">Route Summary<a class="headerlink" href="#route-summary" title="Permanent link">&para;</a></h2>
<table>
<thead>
<tr>
<th>API</th>
<th>Module</th>
<th style="text-align: center;">Endpoint Count</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Express</strong></td>
<td>Auth</td>
<td style="text-align: center;">9</td>
</tr>
<tr>
<td></td>
<td>Users</td>
<td style="text-align: center;">7</td>
</tr>
<tr>
<td></td>
<td>Dashboard</td>
<td style="text-align: center;">7</td>
</tr>
<tr>
<td></td>
<td>Campaigns (CRUD + public + user + moderation + emails)</td>
<td style="text-align: center;">16</td>
</tr>
<tr>
<td></td>
<td>Responses</td>
<td style="text-align: center;">10</td>
</tr>
<tr>
<td></td>
<td>Email Queue</td>
<td style="text-align: center;">4</td>
</tr>
<tr>
<td></td>
<td>Representatives</td>
<td style="text-align: center;">7</td>
</tr>
<tr>
<td></td>
<td>Locations (CRUD + geocode + import)</td>
<td style="text-align: center;">21</td>
</tr>
<tr>
<td></td>
<td>Cuts</td>
<td style="text-align: center;">11</td>
</tr>
<tr>
<td></td>
<td>Shifts (CRUD + series)</td>
<td style="text-align: center;">19</td>
</tr>
<tr>
<td></td>
<td>Canvassing</td>
<td style="text-align: center;">20</td>
</tr>
<tr>
<td></td>
<td>GPS Tracking</td>
<td style="text-align: center;">10</td>
</tr>
<tr>
<td></td>
<td>Map Settings + Geocoding</td>
<td style="text-align: center;">3</td>
</tr>
<tr>
<td></td>
<td>Pages + Blocks</td>
<td style="text-align: center;">12</td>
</tr>
<tr>
<td></td>
<td>Email Templates</td>
<td style="text-align: center;">13</td>
</tr>
<tr>
<td></td>
<td>QR Codes</td>
<td style="text-align: center;">1</td>
</tr>
<tr>
<td></td>
<td>Site Settings</td>
<td style="text-align: center;">5</td>
</tr>
<tr>
<td></td>
<td>Listmonk</td>
<td style="text-align: center;">9</td>
</tr>
<tr>
<td></td>
<td>Docs Management</td>
<td style="text-align: center;">11</td>
</tr>
<tr>
<td></td>
<td>Services</td>
<td style="text-align: center;">2</td>
</tr>
<tr>
<td></td>
<td>Pangolin</td>
<td style="text-align: center;">16</td>
</tr>
<tr>
<td></td>
<td>Observability</td>
<td style="text-align: center;">3</td>
</tr>
<tr>
<td></td>
<td>Payments (public + admin)</td>
<td style="text-align: center;">29</td>
</tr>
<tr>
<td></td>
<td>Health + Metrics</td>
<td style="text-align: center;">3</td>
</tr>
<tr>
<td><strong>Express Total</strong></td>
<td></td>
<td style="text-align: center;"><strong>~248</strong></td>
</tr>
<tr>
<td><strong>Fastify</strong></td>
<td>Videos (CRUD + upload + actions + schedule + fetch)</td>
<td style="text-align: center;">39</td>
</tr>
<tr>
<td></td>
<td>Streaming</td>
<td style="text-align: center;">4</td>
</tr>
<tr>
<td></td>
<td>Public Gallery</td>
<td style="text-align: center;">6</td>
</tr>
<tr>
<td></td>
<td>Tracking</td>
<td style="text-align: center;">5</td>
</tr>
<tr>
<td></td>
<td>Reactions</td>
<td style="text-align: center;">4</td>
</tr>
<tr>
<td></td>
<td>Comments + Chat</td>
<td style="text-align: center;">13</td>
</tr>
<tr>
<td></td>
<td>Shorts + Upvotes</td>
<td style="text-align: center;">4</td>
</tr>
<tr>
<td></td>
<td>Playlists (public + user + admin)</td>
<td style="text-align: center;">18</td>
</tr>
<tr>
<td></td>
<td>User Profile</td>
<td style="text-align: center;">7</td>
</tr>
<tr>
<td></td>
<td>Health</td>
<td style="text-align: center;">1</td>
</tr>
<tr>
<td><strong>Fastify Total</strong></td>
<td></td>
<td style="text-align: center;"><strong>~101</strong></td>
</tr>
<tr>
<td><strong>Grand Total</strong></td>
<td></td>
<td style="text-align: center;"><strong>~349</strong></td>
</tr>
</tbody>
</table>
</article>
</div>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
<button type="button" class="md-top md-icon" data-md-component="top" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg>
Back to top
</button>
</main>
<footer class="md-footer">
<nav class="md-footer__inner md-grid" aria-label="Footer" >
<a href="../services/" class="md-footer__link md-footer__link--prev" aria-label="Previous: Services">
<div class="md-footer__button md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
</div>
<div class="md-footer__title">
<span class="md-footer__direction">
Previous
</span>
<div class="md-ellipsis">
Services
</div>
</div>
</a>
<a href="../troubleshooting/" class="md-footer__link md-footer__link--next" aria-label="Next: Troubleshooting">
<div class="md-footer__title">
<span class="md-footer__direction">
Next
</span>
<div class="md-ellipsis">
Troubleshooting
</div>
</div>
<div class="md-footer__button md-icon">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11z"/></svg>
</div>
</a>
</nav>
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
<div class="md-copyright__highlight">
Copyright &copy; 2024 The Bunker Operations <a href="#__consent">Change cookie settings</a>
</div>
</div>
<div class="md-social">
<a href="https://gitea.bnkops.com/admin" target="_blank" rel="noopener" title="Gitea Repository" class="md-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9M252.8 8C114.1 8 8 113.3 8 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C436.2 457.8 504 362.9 504 252 504 113.3 391.5 8 252.8 8M105.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2"/></svg>
</a>
<a href="https://listmonk.bnkops.com/subscription/form" target="_blank" rel="noopener" title="Newsletter" class="md-social__link">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M536.4-26.3c9.8-3.5 20.6-1 28 6.3s9.8 18.2 6.3 28l-178 496.9c-5 13.9-18.1 23.1-32.8 23.1-14.2 0-27-8.6-32.3-21.7l-64.2-158c-4.5-11-2.5-23.6 5.2-32.6l94.5-112.4c5.1-6.1 4.7-15-.9-20.6s-14.6-6-20.6-.9l-112.4 94.3c-9.1 7.6-21.6 9.6-32.6 5.2L38.1 216.8c-13.1-5.3-21.7-18.1-21.7-32.3 0-14.7 9.2-27.8 23.1-32.8z"/></svg>
</a>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"annotate": null, "base": "../..", "features": ["announce.dismiss", "content.action.edit", "content.action.view", "content.code.annotate", "content.code.copy", "content.tooltips", "navigation.footer", "navigation.indexes", "navigation.path", "navigation.prune", "navigation.tabs", "navigation.tabs.sticky", "navigation.top", "navigation.tracking", "search.highlight", "search.share", "search.suggest", "toc.follow"], "search": "../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script>
<script src="../../assets/javascripts/bundle.79ae519e.min.js"></script>
<script src="../../javascripts/home.js"></script>
<script src="../../javascripts/github-widget.js"></script>
<script src="../../javascripts/gitea-widget.js"></script>
<script src="../../assets/js/env-config.js"></script>
<script src="../../assets/js/video-player.js"></script>
<script src="../../assets/js/image-gallery.js"></script>
<script src="../../assets/js/gancio-events.js"></script>
<script src="../../assets/js/payment-widgets.js"></script>
<script src="../../assets/js/scheduling-poll.js"></script>
<script src="../../javascripts/ad-widgets.js"></script>
<script src="../../javascripts/docs-comments.js"></script>
</body>
</html>