API reference
The server-rendered pages in this repo are just one frontend on top of this JSON API — build a different one (React, plain HTML, a mobile app, a bot) against the same endpoints without touching any server code.
Authentication
There are two ways to authenticate, and every endpoint below accepts whichever one applies to it — there's no separate "API mode."
Session cookie (for a human, or a frontend acting on a human's behalf)
POST /api/auth/login sets an httpOnly token cookie. A same-origin
frontend just needs credentials: 'include' on its fetch calls; nothing
else to configure. The cookie is sameSite: lax and (once COOKIE_SECURE=true
is set — see .env.example) secure, so it's never readable from JS and
never sent over plain HTTP once that flag is on.
Bearer token (for a bot account — see Bots below)
Authorization: Bearer bot_<64 hex chars>
A bot token identifies a real users row with is_bot = 1. Anywhere this
doc says "auth required," a valid bot token satisfies it exactly like a
session cookie would, with the specific bot restrictions noted under
Bots.
Both methods populate the same req.user server-side, so role checks
(mod+, admin+, owner) apply identically regardless of which one was
used — a bot can never hold an elevated role in the first place (enforced
at creation and at the role-change endpoint), so this only matters in
practice for the plain auth required endpoints.
Conventions
- All request/response bodies are JSON unless noted (file uploads are
multipart/form-data). - Errors are
{ "error": "message" }with a 4xx/5xx status. Some also add a machine-readable field (e.g.needsVerification,blocked) alongsideerror— noted per endpoint where it matters. mod+means mod, admin, or owner — anyone at that rank or above. Likewiseadmin+means admin or owner.ownermeans owner only.- IDs in paths (
:id) are numeric row IDs unless the path says:usernameor:name. - Timestamps are UTC,
YYYY-MM-DD HH:MM:SS. - Rate limits are keyed per account (
req.user.id), not per IP, so people sharing an IP (offices, NAT) aren't penalized together — see Rate limits for the full table. A limited request gets429 { "error": "You're doing that too fast. Slow down." }(or an auto-ban message if the pattern looks automated — seesrc/utils/antiAbuse.js).
Auth (/api/auth)
| Method & path | Auth | Body | Notes |
|---|---|---|---|
POST /register |
none | {username, email, password, age_confirmed} |
age_confirmed must be truthy (18+ confirmation). Username: 3-20 chars, letters/numbers/underscore. Password: 8+ chars. Disposable email domains rejected. Sends a 6-digit verification code by email (or console-logs it — see Password reset / verification email delivery). Does not log in — returns {ok, needsVerification: true, email} |
POST /verify-email |
none | {email, code} |
Confirms the code (15 min expiry), then logs in (sets the cookie) and returns {user} |
POST /resend-verification |
none | {email} |
Always returns the same generic {ok, message} whether or not the email exists/needs it, so it can't be used to enumerate accounts |
POST /login |
none | {username, password} |
username may be an email too. Sets the cookie, returns {user: {id, username, email, role}}. 403 with needsVerification: true if the email isn't verified yet; 403 with severity: "illegal_activity" for that ban category (generic message otherwise) |
POST /logout |
none | — | Clears the cookie |
GET /me |
required | — | Returns {user: req.user} — works with either a cookie or a bot token |
POST /forgot-password |
none | {email} |
Always returns the same generic response regardless of whether the email is registered. Emails a reset link valid 1 hour, single-use |
POST /reset-password |
none | {token, new_password} |
token from the emailed link's query string |
Posts (/api/posts)
| Method & path | Auth | Body / query | Notes |
|---|---|---|---|
GET / |
optional | ?sort=hot|new|top|rising&type=all|text|image|clip&q=<search>&community=<name> |
Public feed, max 50 results (300 candidates considered before ranking). IP addresses are never included. Pinned posts always sort first regardless of sort. If logged in, posts from blocked users are excluded |
GET /:id |
optional | — | Post + its comments. 404 if removed. 403 (blocked: true) if you've blocked the author |
POST / |
required | multipart/form-data: title, body?, community?, is_nsfw?, and either a media file field or media_url (must be http(s)) + post_type? |
Rate-limited (postLimiter, see below). Title ≤300 chars, body ≤40,000. A file upload is verified against its real file signature (not just the declared Content-Type). The post starts at score 1 (a default self-vote — see Scoring below). Returns {id} |
POST /:id/comments |
required | {body, parent_id?} |
Rate-limited (commentLimiter). Body ≤10,000 chars. Blocked if you're banned from the post's community. Same default self-vote as posts (starts at score 1). Returns {id} |
POST /:id/vote |
required | {value: 1 | -1 | 0} |
Rate-limited (voteLimiter). 0 removes your vote. Bots cannot vote (403). Voting on your own post is rejected (400) and flagged as possible manipulation — repeated attempts can auto-ban the account+IP |
POST /:id/pin |
mod+ | — | Toggles pinned state; logged to the mod-action audit trail. Pinned posts float to the top of every feed they'd normally appear in |
DELETE /:id |
required | {reason?} |
mod+ can remove any post; a community's own creator can also remove posts within their own community even at the user role. Soft-delete only (is_removed) |
GET /removed/history |
mod+ | — | Last 200 removed posts with reason/remover, for review |
Scoring defaults: every post/comment starts at score 1, not 0 — the author's own vote is inserted automatically on creation, same as Reddit's own behavior. This self-vote is excluded from XP and "upvotes received" stats (see src/utils/xp.js), so posting doesn't also farm the upvote-XP bonus. You can't vote on your own content afterward (see above) since you already have your one implicit vote.
Comments (/api/comments)
| Method & path | Auth | Body | Notes |
|---|---|---|---|
POST /:id/vote |
required | {value: 1 | -1 | 0} |
Identical semantics to post voting above, scoped to a comment id |
Communities (/api/communities)
| Method & path | Auth | Body | Notes |
|---|---|---|---|
GET / |
none | — | All communities with post counts |
POST / |
required | {name, description?, rules?} |
Name: 3-21 chars, letters/numbers/underscore, first-come-first-served. Bots cannot create communities (403). Creator becomes the community's mod automatically |
GET /:name |
none | — | Community details |
DELETE /:name |
owner | — | Deletes the community; its posts are soft-removed with reason, then the community row itself is deleted |
GET /:name/bans |
required | — | Community creator or admin+ only |
POST /:name/bans |
required | {username, reason?} |
Same permission as above. Can't ban the community's own creator |
DELETE /:name/bans/:username |
required | — | Same permission as above |
Community-level moderation (ban/unban within a community) is available to
that community's own creator or site admin/owner — a site-wide mod
role has no special power in a community they didn't create; that's the
point of per-community moderation existing separately from site staff.
Direct messages (/api/dms)
| Method & path | Auth | Body | Notes |
|---|---|---|---|
GET / |
required | — | Your threads, each with the other participant, last message preview, and unread count |
GET /:username |
required | — | Full message history with that user; marks their messages as read. 403 if either of you has blocked the other |
POST /:username |
required | {body} |
Rate-limited (dmLimiter). Body ≤5,000 chars. Creates the thread if it doesn't exist yet; revives it for both sides even if either had "deleted" it |
DELETE /:username |
required | — | Soft-deletes the conversation from your own view only — the other participant's view is unaffected, and the underlying messages are retained (not erased) for legal/compliance reasons regardless of what either side does |
Notifications (/api/notifications)
| Method & path | Auth | Notes |
|---|---|---|
GET / |
required | Last 50, plus an unread count |
POST /:id/read |
required | Marks one as read |
POST /read-all |
required | Marks all as read |
DELETE /:id |
required | Deletes one |
DELETE / |
required | Deletes all of yours |
Mod mail (/api/modmail)
Private messaging between a user and the staff team as a whole (not an individual staff member) — think "contact support," with the staff side acting as one shared inbox.
| Method & path | Auth | Body | Notes |
|---|---|---|---|
POST / |
required | {subject, body} |
Rate-limited (modmailLimiter). Body ≤10,000 chars. Notifies every mod/admin/owner |
GET /mine |
required | — | Your own threads |
GET / |
mod+ | ?status=open|closed |
The shared staff inbox — every thread from every user |
GET /:id |
required | — | A thread's full message history. Its owner or any staff member can view it |
POST /:id/reply |
required | {body} |
Rate-limited. Owner or staff only; reopens a closed thread. Staff replying notifies the owner; the owner replying notifies all staff |
POST /:id/status |
mod+ | {status: "open"|"closed"} |
|
DELETE /:id |
mod+ | — | Permanently deletes the thread and its messages (unlike DMs, no retention requirement here) |
Reports (/api/reports)
| Method & path | Auth | Body | Notes |
|---|---|---|---|
POST / |
required | {target_type: "post"|"comment"|"user", target_id, reason?} |
Snapshots the target's IP at report time, so staff have it without needing separate IP-lookup access. Notifies every mod/admin/owner |
GET / |
mod+ | ?status=open |
Includes the captured IP |
PATCH /:id |
mod+ | {status: "open"|"reviewed"|"actioned"|"dismissed"} |
Blocks (/api/blocks)
| Method & path | Auth | Body | Notes |
|---|---|---|---|
GET / |
required | — | Everyone you've blocked |
POST / |
required | {username} |
Blocks affect feed visibility, DMs, and comment visibility both ways |
DELETE /:username |
required | — | Unblocks |
Account (/api/account)
Everything here operates on your own account (req.user.id) — there's
no :id param, since you can't use these to touch someone else's account.
| Method & path | Auth | Body | Notes |
|---|---|---|---|
PATCH /profile |
required | {display_name?, location?} |
Both ≤80 chars |
POST /username |
required | {username} |
Same format rules as registration |
POST /avatar |
required | multipart/form-data: avatar file |
JPG/PNG/GIF, 5MB max. Verified against its real file signature |
POST /password |
required | {current_password, new_password} |
New password: 8+ chars |
POST /delete |
required | {current_password} |
Anonymizes, doesn't hard-delete: username/email/password become unusable, personal fields cleared, but posts/comments stay (soft-removed, attributed to a generic deleted identity) so other people's threads don't break. The sole remaining owner can't delete their own account without transferring ownership first |
Bots (/api/bots)
Self-service bot accounts for automation — see Authentication above for how a bot actually calls the rest of the API once it has a token. Every route here requires a human session (a bot can't manage other bots, including itself).
| Method & path | Auth | Body | Notes |
|---|---|---|---|
POST / |
required (human) | {username} |
Creates a bot account owned by you. Returns {id, username, token} — the token is shown exactly once, never retrievable again (only its hash is stored) |
GET / |
required (human) | — | Bots you own. Never includes tokens |
POST /:username/regenerate-token |
required (human, owner of that bot) | — | Old token stops working immediately. Returns the new {token} |
DELETE /:username |
required (human, owner of that bot) | — | Revokes the token (the bot account and its post/comment history remain, same as human account deletion) |
A bot account can use any endpoint a human can, except:
| Blocked for bots | Route | Why |
|---|---|---|
| Voting | POST /api/posts/:id/vote, POST /api/comments/:id/vote |
no vote manipulation by automation |
| Creating communities | POST /api/communities |
ditto |
| Being promoted | POST /api/admin/users/:id/role |
a bot can never hold mod/admin/owner |
| Managing bots | any /api/bots/* route |
a bot can't create/revoke/regenerate bots, even its own |
Full walkthrough with a working curl example: see the "Bot API" section
in README.md.
Admin (/api/admin)
Role requirements vary per route — see the table. As a quick summary: account bans/unbans/listing are mod+; IP visibility (lookups, the IP-ban list) and issuing IP bans are admin+; manual raw-IP entry, any unban (account or IP), role changes, the streamer badge, and DMCA takedown are owner-only.
A staff member can only ban/IP-ban someone with a strictly lower rank than themselves — mods can't touch other mods/admins, admins can't touch other admins, nobody can ever touch the owner.
| Method & path | Auth | Body | Notes |
|---|---|---|---|
GET /users |
mod+ | — | User list for the ban UI. Mods never see email or last_ip in the response. Admin sees the real email only once that user is account- or IP-banned |
POST /users/:id/ban |
mod+ | {reason, category?: "standard"|"illegal_activity"} |
reason required. illegal_activity category requires admin+ |
POST /users/:id/unban |
owner | — | Direct unban. Mod/admin must go through the request workflow below instead |
POST /unban-requests |
mod+ | {username, note} |
note required. Notifies every owner |
GET /unban-requests |
mod+ | — | Owner sees all; mod/admin see only their own submitted requests |
POST /unban-requests/:id/approve |
owner | — | Lifts the ban |
POST /unban-requests/:id/deny |
owner | — | |
POST /users/:id/role |
owner | {role: "user"|"mod"|"admin"} |
Can't set/target owner this way; bots can never hold an elevated role |
POST /users/:id/streamer |
owner | — | Toggles the verified-streamer badge, independent of role |
GET /ip-bans |
admin+ | — | The full IP-ban list |
POST /ip-bans |
owner | {ip_address, reason, category?} |
Manual raw-IP entry, not tied to a specific post/user |
DELETE /ip-bans/:ip |
owner | — | |
POST /ip-bans/by-post/:postId |
admin+ | {reason, category?} |
IP-bans whoever posted it; response includes the ip_address banned |
POST /ip-bans/by-username/:username |
admin+ | {reason, category?} |
Same, by username instead of a post |
GET /posts/:id/ip, GET /comments/:id/ip, GET /users/:id/ip, GET /users/by-username/:username/ip |
admin+ | — | Raw IP lookups ("who posted this / what's this user's IP") |
POST /dmca-takedown |
owner | {username, reason, category?, case_reference?} |
Bans the account and their last known IP in one call, and logs a fulfilled entry in the legal-requests log automatically |
Legal requests (/api/legal-requests)
A private paper trail of law-enforcement/legal data requests, not something the public or ordinary users can submit through the app.
| Method & path | Auth | Body | Notes |
|---|---|---|---|
GET / |
mod+ | ?status= |
View the log (situational awareness for staff) |
GET /export |
owner | — | Downloads the full log as a ZIP (CSV + JSON inside) |
GET /:id |
mod+ | — | |
POST / |
owner | {agency, requester_name?, requester_contact?, document_type, data_category?, case_number?, target_description, notes?} |
document_type ∈ subpoena/warrant/court_order/dmca/other |
PATCH /:id |
owner | {status?, data_provided?, notes?} |
status ∈ pending/fulfilled/rejected |
DELETE /:id |
owner | — |
User search (/api/users/search)
| Method & path | Auth | Query | Notes |
|---|---|---|---|
GET / |
none | ?q=<text> |
Up to 6 usernames matching (username + avatar only), for the typeahead in the header search bar |
Leaderboard (/api/leaderboard)
| Method & path | Auth | Query | Notes |
|---|---|---|---|
GET / |
none | ?limit=1-100 (default 50) |
XP leaderboard, computed live from posts/comments/votes — see src/utils/xp.js for the exact weights (10/post, 2/comment, 2/post-upvote, 1/comment-upvote; a post/comment's own default self-vote doesn't count toward the upvote weights) |
Chat (/api/chat)
A single, site-wide live chat room (flat message log, no threading, no per-community rooms — a reasonable future extension but out of scope today). Polling-based rather than WebSocket, to match the rest of this app's request/response style — client JS polls every few seconds.
| Method & path | Auth | Body / query | Notes |
|---|---|---|---|
GET /messages |
none | ?since_id=<id>&limit=1-200 |
Public read, same as the main feed. No since_id: returns the most recent limit (default 50), oldest-first. With since_id: everything newer than that id, for polling |
POST /messages |
required | {body} |
Rate-limited (chatLimiter, see below). Body ≤2,000 chars. Returns {message} |
DELETE /messages/:id |
required | {reason?} |
Your own message, or mod+ for anyone's. Soft-delete (is_removed) |
Content safety scanning
Any endpoint that accepts a file upload (post media, avatars) optionally
runs it through automated content screening before accepting it — a
client integrating against this API should be prepared for a 403 on an
otherwise-valid-looking upload request.
Arachnid Shield — CSAM-specific hash matching. A match immediately
bans the uploader's account and IP (illegal_activity category) and
returns a generic 403 {"error": "This account has been suspended."}
— deliberately generic, not describing why.
Configured via .env (ARACHNID_SHIELD_USERNAME/PASSWORD) and no-ops
safely if unset — uploads work normally without it, just without that
layer of screening. See src/utils/arachnidShield.js.
Rate limits
All keyed per-account, 60s windows unless noted:
| Action | Limit |
|---|---|
Auth endpoints as a whole (/api/auth/*) |
30 per 15 min |
| Voting (posts + comments share one budget) | 20 per minute |
| Creating a post | 10 per hour |
| Creating a comment | 20 per 10 min |
| Sending a DM | 20 per minute |
| Mod mail (new thread or reply) | 10 per 10 min |
| Chat message | 30 per minute |
Hitting a limit repeatedly (a pattern that looks automated rather than an
occasional burst) can escalate to an automatic account+IP ban — see
src/utils/antiAbuse.js.
Password reset / verification email delivery
If SMTP_HOST isn't configured in .env, verification codes and password
reset links are printed to the server console instead of emailed — fine
for local/self-hosted testing, see README.md for setting up real SMTP
delivery.
