Base URL: /api
All responses follow the format:
// Success
{ "ok": true, "data": { ... } }
// Error
{ "ok": false, "error": { "code": "ERROR_CODE", "message": "Description" } }Unhandled error messages are redacted by default. Set VERBOSE_ERRORS=1 (env var / wrangler secret) to include the original error message in the response.
No auth required.
Response:
{ "ok": true, "data": { "status": "healthy", "timestamp": "2026-02-26T00:00:00.000Z" } }Creates a one-time auth token for a Discord user. Called by the Discord bot.
Auth: X-Bot-Token header (required when BOT_API_KEY secret is set)
Body (Zod validated):
{
"discord_id": "123456789", // 1-30 chars, required
"discord_username": "GamerDave", // 1-50 chars, required
"avatar_url": "https://cdn.discordapp.com/...", // max 500 chars, optional
"guild_name": "My Server" // max 100 chars, optional (saved to settings)
}Response (201):
{ "ok": true, "data": { "token": "abc123...", "url": "https://host/auth/abc123..." } }Creates a one-time admin auth token. Called by the Discord bot after verifying the requesting member has ADMINISTRATOR permission. The resulting session grants admin privileges.
Auth: X-Bot-Token header (required when BOT_API_KEY secret is set)
Body: same schema as /api/auth/token
Response (201):
{ "ok": true, "data": { "token": "abc123...", "url": "https://host/auth/abc123..." } }Exchanges a one-time token for a session cookie. Redirects to /.
- Regular token:
Set-Cookie: session_id=...; Max-Age=604800; HttpOnly; SameSite=Strict; Path=/(7-day persistent) - Admin token:
Set-Cookie: session_id=...; HttpOnly; SameSite=Strict; Path=/(noMax-Age— browser-session only; DB row expires after 1 hour)
Response: 302 Found
Requires session cookie. Destroys the session.
Response:
{ "ok": true, "data": null }All endpoints require session cookie.
Returns all registered users (for user pickers in gather/shame).
Response:
{
"ok": true,
"data": [
{ "id": "uuid", "discord_username": "GamerDave", "avatar_url": "https://..." }
]
}Returns the current authenticated user. Includes is_admin: boolean — true when the session was created via an admin token.
Updates the current user's profile.
Body (all fields optional):
{
"discord_username": "NewName",
"display_name": "Dave",
"sync_name_from_discord": true,
"timezone": "America/New_York",
"time_granularity_minutes": 30
}display_name overrides the Discord username for display purposes (max 50 chars). sync_name_from_discord controls whether the name auto-updates on next login (default true).
User-auth endpoints require session cookie. Bot-auth endpoints require X-Bot-Token header.
Lists games with pool filtering, reaction counts, user reactions, and per-user reaction avatars.
Query params: ?pool=active|archive|all (default active). Legacy ?include_archived=true is supported (maps to all).
On active/all pool fetch, stale games are auto-archived in the background if auto_archive_enabled is true (based on game_pool_lifespan_days, default 7).
Response per game:
{
"id": "uuid", "name": "...", "steam_app_id": "730", "image_url": "...",
"proposed_by": "uuid", "is_archived": false, "note": "optional note",
"last_activity_at": "2026-03-10T...",
"like_count": 3, "dislike_count": 1,
"user_reaction": "like",
"reaction_users": [
{ "user_id": "uuid", "type": "like", "display_name": "Alice", "avatar_url": "..." }
]
}Proposes a new game. Duplicate detection by steam_app_id (returns 409 with DUPLICATE_GAME or ARCHIVED_DUPLICATE). Steam header image auto-upgraded when steam_app_id is provided.
Body:
{
"name": "Counter-Strike 2", // required, max 100 chars
"steam_app_id": "730", // optional
"image_url": "https://...", // optional, max 500 chars
"note": "Great FPS" // optional, max 500 chars
}Updates a game. Only the proposer can update. Accepts name, image_url, note (max 500 chars).
Archives a game (soft delete). Any user can archive. Accepts optional body { "reason": "not_interested" }.
Permanently deletes an archived game and all related data (reactions, activity, shares). Only the proposer can permanently delete. Game must be archived first (returns 400 otherwise).
Restores an archived game to the active pool. Any user can restore. Resets last_activity_at to now.
Sets a reaction (like or dislike) on a game. Updates last_activity_at.
Body:
{ "type": "like" }Removes the current user's reaction from a game.
Returns recent game activity (propose, like, dislike, archive, restore, share events). Paginated.
Query params: ?limit=20&before=<iso-timestamp> (max 50 per page, cursor-based).
Broadcasts a game to the Discord channel. Creates a share record for bot polling. Logs activity and updates last_activity_at.
Response (201):
{ "ok": true, "data": { "id": "uuid", "game_id": "uuid", "requested_by": "uuid", "delivered": false, "created_at": "..." } }Returns undelivered game shares with joined game data (name, note, image, Steam app ID, like/dislike counts, requester name).
Auth: X-Bot-Token header
Marks a game share as delivered.
Auth: X-Bot-Token header
All endpoints require session cookie.
Sets or updates a vote for a game.
Body:
{
"rank": 1,
"is_approved": true // optional, defaults to true
}Removes a vote.
Returns all votes for a specific game. Note: user_id is stripped from the response for privacy.
Returns aggregated Borda count ranking of all active games.
Returns the current user's votes with game data (name, image_url), ordered by rank.
Bulk updates vote ranks after drag-to-reorder.
Body:
{
"rankings": [
{ "game_id": "uuid-1", "rank": 1 },
{ "game_id": "uuid-2", "rank": 2 }
]
}Requires session cookie. Searches Steam by partial game name.
Query: q — 2-100 characters
Response:
{
"ok": true,
"data": [
{ "app_id": "730", "name": "Counter-Strike 2", "image_url": "https://..." }
]
}Returns up to 10 results.
Looks up a Steam game by App ID. No auth required.
Response:
{
"ok": true,
"data": {
"name": "Counter-Strike 2",
"header_image": "https://cdn.akamai.steamstatic.com/steam/apps/730/header.jpg"
}
}All endpoints require session cookie.
Query params: ?user_id=...&date=YYYY-MM-DD (both optional).
Returns slots with per-slot slot_status field ('available' or 'tentative') when available.
Note: user_id is scoped to the authenticated user when no date filter is provided (prevents cross-user personal data access).
Bulk-replaces all availability slots for a given date.
Body:
{
"date": "2026-03-01",
"slots": [
{ "start_time": "19:00", "end_time": "19:15", "slot_status": "available" },
{ "start_time": "19:15", "end_time": "19:30", "slot_status": "tentative" }
]
}slot_status defaults to 'available' if omitted. Upserts an availability_status record for the user+date.
Clears all slots for the given date. Writes status = 'filled' to block auto-seed re-triggering.
Returns the user's availability status for a date range.
Query params: ?from=YYYY-MM-DD&to=YYYY-MM-DD (both required, max 31-day range). Dates are validated with calendar round-trip checks.
Response:
{
"ok": true,
"data": {
"2026-03-10": "filled",
"2026-03-11": "tentative_auto",
"2026-03-12": null
}
}Confirms auto-filled availability for a date. Transitions status from tentative_auto to tentative_confirmed. Date is validated with calendar round-trip check.
Response:
{ "ok": true, "data": null }Seeds a future date with slots from the same weekday 7 days ago. Idempotent: returns null if already seeded, slots exist, or no prior-week data.
Body:
{ "date": "2026-03-15" }Requires session cookie. Rings the gather bell. Two independent rate limits apply:
- Per-ping cooldown (Check B):
gather_cooldown_secondssetting (default 10s). Must wait this long between pings. - Hourly limit (Check A, checked first):
gather_hourly_limitsetting (default 30). If ≥ 30 pings in the last 60 minutes, locked out until the oldest ping ages out. Set to 0 to disable either limit.
Both return 429 with { error: { code: "RATE_LIMITED", message: "... Try again in Xs" } }.
Body (all fields optional):
{
"message": "CS2 anyone?", // max 500 chars
"is_anonymous": false, // hide sender identity
"target_user_ids": ["uuid-1"] // null = everyone, max 20 users
}Returns undelivered gather pings.
Auth: X-Bot-Token header (required when BOT_API_KEY secret is set)
Response:
{
"ok": true,
"data": [
{
"id": "ping-uuid",
"user_id": "user-uuid",
"sender_discord_id": "123456789012345678",
"sender_username": "GamerDave",
"message": "CS2 anyone?",
"delivered": false,
"is_anonymous": false,
"target_user_ids": null,
"target_discord_ids": null,
"created_at": "2026-02-26T19:00:00.000Z"
}
]
}sender_discord_id and target_discord_ids are pre-resolved numeric Discord IDs — use <@id> syntax directly. No bot-side ID mapping needed.
Marks a gather ping as delivered.
Auth: X-Bot-Token header (required when BOT_API_KEY secret is set)
All endpoints require session cookie.
Shames another user. One shame per voter-target pair per day.
Body:
{
"reason": "No-showed last night", // optional, max 200 chars
"is_anonymous": false // optional, default false
}Withdraws today's shame vote against a user.
Returns an array of target user IDs the current user has shamed today.
Returns the shame leaderboard sorted by weekly shame count. Votes older than 7 days are automatically cleaned up. Each entry includes the latest 3 reasons and today's voters.
Response:
{
"ok": true,
"data": [
{
"user_id": "uuid",
"discord_username": "GamerDave",
"avatar_url": "https://...",
"shame_count_today": 1,
"shame_count_week": 3,
"recent_reasons": [
{
"reason": "No-showed last night",
"voter_id": "uuid-or-null",
"voter_name": "Alice-or-null",
"voter_avatar": "url-or-null"
}
],
"today_voters": [
{ "voter_id": "uuid-or-null", "voter_name": "Alice-or-null", "voter_avatar": "url-or-null", "is_anonymous": false }
]
}
]
}voter_id, voter_name, and voter_avatar are null for anonymous votes.
Requires session cookie. Creates or gets today's rally and records a call action.
Body (all fields optional):
{
"timing": "now" // "now" or "later", defaults to "now"
}Response (201):
{
"ok": true,
"data": {
"rally": { "id": "uuid", "creator_id": "uuid", "timing": "now", "day_key": "2026-02-26", "status": "open", "created_at": "..." },
"action": { "id": "uuid", "rally_id": "uuid", "actor_id": "uuid", "action_type": "call", ... }
}
}Requires session cookie. Records an action (in/out/ping/brb/where). Auto-attaches to today's active rally.
Body:
{
"action_type": "in", // required: "in", "out", "ping", "brb", "where"
"target_user_ids": ["uuid"], // required for ping/where
"message": "on my way" // optional, max 500 chars
}Requires session cookie. Computes all overlapping availability windows for today where 2+ users are available simultaneously.
Response (201):
{
"ok": true,
"data": {
"metadata": {
"windows": [
{ "start": "19:00", "end": "21:00", "user_count": 3, "user_ids": ["..."], "user_names": ["Alice", "Bob", "Dave"] }
],
"day_key": "2026-02-26"
}
}
}start/end are UTC HH:MM strings. Windows are sorted by user_count descending, then start ascending. Adjacent windows with the same user set are merged.
Requires session cookie. Nudges a user to set availability.
Body:
{ "target_user_ids": ["uuid"] }Requires session cookie. Broadcasts the current game ranking to Discord via a share_ranking rally action. The top 10 games (by Borda score) are stored in metadata.ranking.
Requires session cookie. Returns today's active rally and all actions. Optional ?day_key=YYYY-MM-DD.
Requires session cookie. Returns tree DAG data (nodes, edges, rallies) for visualization. Optional ?day_key=YYYY-MM-DD.
Response:
{
"ok": true,
"data": {
"nodes": [{ "id": "...", "action_type": "call", "actor_username": "Dave", ... }],
"edges": [{ "source": "id1", "target": "id2", "type": "response" }],
"rallies": [{ "id": "...", "day_key": "2026-02-26", "status": "open" }]
}
}Returns undelivered rally actions with resolved Discord IDs.
Auth: X-Bot-Token header
Marks a rally action as delivered.
Auth: X-Bot-Token header
Requires session cookie. Uploads a base64 PNG for Discord sharing.
Body:
{ "image_data": "base64-png-data..." }Returns undelivered tree share images.
Auth: X-Bot-Token header
Marks a tree share as delivered.
Auth: X-Bot-Token header
Returns all settings as a key-value map. Requires session cookie.
Updates settings. Requires session cookie. Admin only -- session must have been created via POST /api/auth/admin-token (Discord-gated: requires ADMINISTRATOR guild permission).
Returns all settings. Auth: X-Bot-Token header. Used by the bot to fetch channel_id on startup.
Updates settings. Auth: X-Bot-Token header. Used by the bot's /setchannel command to persist channel_id in D1.
Body (all fields optional):
{
"time_granularity_minutes": 15,
"auto_archive_enabled": true,
"game_pool_lifespan_days": 7,
"gather_cooldown_seconds": 10,
"gather_hourly_limit": 30,
"avail_start_hour_et": 17,
"avail_end_hour_et": 3,
"day_reset_hour_et": 8,
"rally_button_labels": { "call": "Call", "in": "In" },
"rally_suggested_phrases": { "call": ["anyone?", "hop on!"] },
"rally_show_discord_command": true
}