NuroPicks API Reference
Last updated: 2026-04-19
Status: scaffold. Endpoint bodies verified from source 2026-04-19. Requests/responses partially inferred where handlers return NextResponse.json without a typed return. Anything that could not be confirmed from source is marked TBD: verify.
This document covers:
- Next.js web API routes under
web/src/app/api/ - Discord bot slash commands under
src/bot/commands/ - Webhook endpoints (Whop, NOWPayments) and their signature scheme
- Known rate limits (both web and bot)
- Auth + session model
It does not cover the draft-room Socket.IO protocol (see services/draft-room/) or Python worker HTTP endpoints (internal, cron-only).
Auth and session
The web app currently runs in a hybrid model (Discord bot is still the primary user surface):
- Session cookies:
nuropicks_session(opaque id) +nuropicks_discord_id(fallback). Set by the Discord OAuth handshake.web/src/lib/userAuth.tsexposesresolveUserIdentity(),requireUserPage(),requireUserApi(req). - Header auth: Some routes (notably
POST /api/fantasy/deposit) acceptx-discord-id+x-nuropicks-authfor bot-to-web calls. - Admin auth:
web/src/lib/adminAuth.tsaccepts two signals, in priority:nuropicks_sessioncookie whose user hasusers.is_admin = TRUE(fallback:ADMIN_DISCORD_IDSenv allowlist).x-admin-tokenheader matchingADMIN_REVIEW_TOKEN(legacy, preserved for/admin/fraudand ops curl).
- Webhook auth: HMAC signature per provider. See Webhooks.
Discord bot identifies users by Discord user id only (no separate session). Every command reads interaction.user.id and looks up the profile via src/bot/store.js.
The bot and web share the same Postgres users table. The users.is_admin column referenced in adminAuth.ts is not yet shipped at the schema level. TBD: verify column lands before admin-via-cookie is relied on in prod.
Rate limits
Web (web/src/lib/rateLimit.ts)
In-memory sliding-window bucket keyed by ${key}. checkRateLimit(key, limit, windowMs) returns { ok: true } or { ok: false, retryAfter }. Swap-ready for Redis.
Known per-route limits (sampled from source):
| Route | Key | Limit | Window |
|---|---|---|---|
POST /api/apply |
apply:${ip} |
3 | 10 min |
POST /api/fantasy/deposit |
deposit:${discordId} |
5 | 10 min |
POST /api/fantasy/trade-advisor |
trade-advisor:${userId or ip} |
5 | 1 min |
POST /api/fantasy/waitlist |
waitlist:${ip} |
1 | 60 s |
POST /api/referrals/track |
see code - not documented here | ||
| Other rate-limited routes | see code - not documented here |
Bot (src/bot/utils/rateLimit.js)
Per-user + per-bucket sliding window. Buckets:
| Bucket | Limit | Window | Used by |
|---|---|---|---|
bet |
10 | 60 s | /bet |
predict |
20 | 60 s | /predict |
casino |
30 | 60 s | /play, /crash, /war, /lottery |
give |
5 | 60 s | /give |
duel |
10 | 60 s | /duel |
fantasy_write |
8 | 60 s | fantasy create/join/trade/waiver/dispute |
fantasy_read |
30 | 60 s | fantasy read commands |
rg_control |
15 | 60 s | /fantasy-rg, /self-exclude |
default |
60 | 60 s | unclassified |
Bot commands currently do not share a centralized rate-limit registry beyond this util; per-command cooldown choice lives in each command file.
External dependency limits
- The Odds API: free tier 500 req/month (documented in
.env.example). Bot workers cache aggressively; exact quota enforcement is worker-side and not yet instrumented. TBD: verify quota tracking. - Discord Gateway: discord.js v14 default rate-limit handling. No custom overrides.
- NOWPayments: no documented per-merchant limit; IPN webhook is idempotent via
idempotency_keyonledger_entries.
Web API routes
All routes live under web/src/app/api/ and use Next.js App Router route.ts / route.tsx handlers. Runtime is nodejs with dynamic = "force-dynamic" unless noted.
Public routes
GET /api/health
Liveness + build identity.
Response
{
"ok": true,
"service": "nuropicks-web",
"commit": "abc1234",
"env": "production",
"region": "iad1",
"ts": "2026-04-19T20:00:00.000Z"
}
Headers: Cache-Control: no-store.
POST /api/apply
Capper marketplace application. Public. Rate-limited 3/10min per IP.
Body
{
"handle": "string",
"email": "string",
"specialty": "string",
"trackRecordUrl": "string",
"pickCount": "string",
"startedAt": "string",
"bankrollStrategy": "string",
"notes": "string"
}
Required: handle, email, specialty. Email is regex-validated.
Response: { ok: true } or { ok: false, error: string } with 400/429.
Side effect: posts application summary to CAPPER_APPLY_WEBHOOK_URL (Discord relay).
POST /api/fantasy/waitlist
Landing page email capture. Public. Rate-limited 1/60s per IP.
Body
{ "email": "string", "state": "string (optional 2-letter)", "biggest_frustration": "string (optional, <=2000)" }
Duplicate email returns 200 with friendly message (no enumeration).
Response: { ok: true, message: string }. Fire-and-forget Resend confirmation email on new insert.
Other public routes
Shapes partially inferred; see route files for authoritative bodies.
| Route | Purpose |
|---|---|
GET /api/fantasy/waitlist/stats |
Public waitlist counter for landing page |
GET /api/fantasy/geo-check |
Is caller's geo allowed for paid fantasy entry |
POST /api/fingerprint |
Browser fingerprint telemetry for collusion detection (returns server-side token) |
GET /api/fingerprint |
Fingerprint handshake/health |
GET /api/fantasy/mock-draft/leaderboard |
Public top-N mock draft scores (query: limit, default 25) |
Account (session) routes
Require a valid nuropicks_session cookie. All return 401 if unauthenticated.
GET /api/account/profile
Returns the signed-in user's profile.
Response: { discordId, displayName, username, avatarUrl, email, homeState, timeZone, kycTier, tier, sessionId } (from UserIdentity in lib/userAuth.ts).
PUT /api/account/profile
Update displayName, homeState, timeZone, email. Non-admin users cannot change tier or kycTier.
Body: Partial<{ displayName, email, homeState, timeZone }>.
Response: { ok: true, user: UserIdentity }.
GET /api/account/close + POST /api/account/close
GET returns the pre-close preview (bankroll, open bets, escrow). POST executes the close (soft-delete + self-exclusion).
Response (POST): { ok: true, closedAt: string } (inferred).
GET /api/account/notifications + PUT /api/account/notifications
Notification preferences (email/push/Discord DM). PUT body is a NotificationPrefs struct from lib/notificationPrefs.ts. TBD: verify full shape.
Fantasy (session) routes
All require an identified caller (session cookie or x-discord-id header).
POST /api/fantasy/deposit
Create a league buy-in invoice. Rate-limited 5/10min per user.
Body
{ "leagueId": 123, "amountCents": 2500, "method": "crypto" }
Response
{ "ok": true, "invoiceUrl": "https://...", "escrowId": 456 }
Errors: 401 unauth, 402 RG-blocked (DepositBlockedByRGError), 409 withdrawals halted, 422 validation.
POST /api/fantasy/payout
Request league payout (after season settle / dispute close). TBD: verify exact body - likely { escrowId, walletAddress, currency }.
POST /api/fantasy/ipn
NOTE: This is a webhook endpoint, not user-callable. See Webhooks.
POST /api/fantasy/trade-advisor
Accepts a TradeProposal, returns a Claude-backed TradeAnalysis. Rate-limited 5/min per user-or-ip. Reads + writes to trade_analyses for idempotent replays.
Body: TradeProposal from src/services/ai/types.ts (team rosters + proposed swap).
Response: TradeAnalysis (fairness score + rationale + flags).
Other fantasy advisors (all POST unless noted)
Shapes partially inferred; see route files for authoritative bodies.
| Route | Purpose |
|---|---|
POST /api/fantasy/dispute-advisor |
Claude-backed dispute triage for commissioners |
POST /api/fantasy/lineup-optimizer |
Optimal lineup given roster + projections |
POST /api/fantasy/start-sit |
Claude start/sit for a specific player pair |
POST /api/fantasy/prop-alignment |
Mock-draft prop alignment score |
GET /api/fantasy/waiver-wire |
Ranked waiver adds/drops for a team |
Mock-draft + commissioner + draft-token
Commissioner routes require the caller to be the league commissioner; mock-draft and draft-token routes require a session. Body/response shapes inferred; see route files.
| Route | Purpose |
|---|---|
POST /api/fantasy/mock-draft/start |
Start a new mock draft session |
POST /api/fantasy/mock-draft/[draftId]/pick |
Submit a pick |
GET /api/fantasy/mock-draft/[draftId]/summary |
Draft recap + grades |
POST /api/fantasy/mock-draft/track |
Telemetry for abandon-rate analytics |
GET /api/fantasy/commissioner/[leagueId] |
League ops summary |
PUT /api/fantasy/commissioner/[leagueId] |
Update league settings |
POST /api/fantasy/commissioner/[leagueId]/remind |
Fire lineup reminders |
POST /api/fantasy/commissioner/[leagueId]/broadcast |
DM all league members |
POST /api/fantasy/commissioner/disputes/[disputeId]/resolve |
Close a dispute |
POST /api/fantasy/draft/[draftId]/token |
Short-lived JWT for draft-room Socket.IO handshake |
KYC, notifications, referrals, creators
Shapes partially inferred; see route files for authoritative bodies.
| Route | Purpose | Auth |
|---|---|---|
POST /api/kyc/submit |
Tier 1/2 KYC submission, enqueues kyc_review |
session |
POST /api/kyc/submit-w9 |
W9 for US payout recipients | session |
GET /api/notifications |
List notifications (query: unreadOnly, limit) |
session |
PATCH /api/notifications |
Mark read / dismissed (body: { ids, action }) |
session |
POST /api/notifications/subscribe-push |
Register Web Push subscription | session |
DELETE /api/notifications/subscribe-push |
Unregister Web Push subscription | session |
POST /api/referrals/track |
Record referral click (body: { code, source? }) |
public |
GET /api/referrals/track |
Pixel/beacon version, returns 1x1 gif or 204 | public |
GET /api/referrals/stats |
Stats for a referral code (query: code) |
public |
GET /api/referrals/card |
OG share card PNG via ImageResponse |
public |
POST /api/creators/[creatorId]/subscribe |
Subscribe to a capper feed | session |
GET /api/creators/content/[contentId] |
Fetch capper content (paywall applied server-side) | session |
Admin routes
All admin routes require requireAdminApi(req) to pass. Non-admin returns 403 JSON.
| Method + Path | Purpose |
|---|---|
GET /api/admin/overview |
Dashboard KPIs |
GET /api/admin/revenue |
Revenue breakdown |
GET /api/admin/alerts |
Open alert queue |
POST /api/admin/alerts/[alertId] |
Acknowledge / dismiss alert |
GET /api/admin/users |
Paginated user list |
GET /api/admin/users/[discordId] |
User detail |
POST /api/admin/users/[discordId]/actions |
Admin action on user (suspend / ban / promote / adjust-balance) |
GET /api/admin/leagues |
Paginated league list |
GET /api/admin/leagues/[leagueId] |
League detail |
POST /api/admin/leagues/[leagueId]/actions |
Admin action on league |
GET /api/admin/creators |
Capper marketplace admin |
POST /api/admin/creators/[capperId]/actions |
Approve / suspend / promote capper |
GET /api/admin/disputes |
Open disputes |
POST /api/admin/disputes/[disputeId] |
Resolve dispute |
POST /api/admin/fraud/[flagId] |
Act on collusion flag (suspend / ban / clear / escalate) |
GET /api/admin/escrow |
Escrow ledger summary |
GET /api/admin/escrow/payouts |
Pending payouts queue |
POST /api/admin/escrow/payouts/approve |
Approve a payout batch |
POST /api/admin/escrow/reconcile |
Trigger manual reconcile job |
GET /api/admin/rg-audit |
Responsible-gambling alerts |
GET /api/admin/waitlist |
Waitlist rows |
POST /api/admin/waitlist/invite |
Send an invite email |
GET /api/admin/export/csv |
CSV export (users / picks / escrow) |
Admin actions generally accept { action, note? }. Response is typically { ok: true, updated: {...} } or { ok: false, error }. See individual route files for exact shape.
Webhooks
All webhook endpoints reject unsigned or invalid-signature requests.
POST /api/webhooks/whop
Whop subscription lifecycle. Signature header: X-Whop-Signature (HMAC-SHA256 of raw body with WHOP_WEBHOOK_SECRET). Events: membership.created, membership.expired, membership.canceled, membership.renewed.
Response: { ok: true } on success, 401 bad sig, 501 not configured.
Idempotency: currently logs + relays to Discord. Tier upsert into DB is TODO(launch).
Also supports GET /api/webhooks/whop for Whop dashboard health-check.
POST /api/webhooks/nowpayments
NOWPayments IPN for crypto subscription payments. Signature header: x-nowpayments-sig (HMAC-SHA512 of canonical-sorted JSON body with NOWPAYMENTS_IPN_SECRET). Statuses: waiting | confirming | confirmed | finished | failed | refunded | expired.
Response: { ok: true } (always 200 after sig verify to prevent retry storms), 401 bad sig, 501 not configured.
Idempotency: downstream credit/upgrade logic is a stub; full idempotency lands with real-money flow at launch.
Also supports GET /api/webhooks/nowpayments for dashboard health-check.
POST /api/fantasy/ipn
NOWPayments IPN for fantasy money flow (league buy-ins). Same signature scheme as above but always returns 200 after signature verification; internal failures surface via reconciliation job. confirmDeposit is idempotent via deterministic idempotency_key on ledger_entries, so duplicate IPNs are no-ops.
The live retail and fantasy IPN endpoints are separate because the domain models diverge (subscription tier vs league escrow); do not merge.
Discord bot slash commands
Commands live in src/bot/commands/. Fantasy subcommands live in src/bot/commands/fantasy/. All commands auto-register via readdirSync walk in src/bot/index.js.
Tiers: free (default), pro, elite, admin. Tier is read from getProfile(userId).tier.
Picks and analysis
| Command | Purpose | Params | Tier |
|---|---|---|---|
/picks |
Today's AI picks (with why explainer per pick) |
sport? (nfl/nba/mlb/nhl) |
free |
/odds |
Compare odds across sportsbooks | sport?, game? |
free |
/predict |
Full AI prediction with reasoning | sport, game |
pro |
/bet |
Place a tracked virtual pick | sport, bet, odds, stake |
free |
/mybets |
View bet history | limit? |
free |
/settle |
Settle a tracked bet | bet_id, result |
admin |
/parlay |
Build and analyze parlays | legs |
free |
/potd |
Pick of the Day with Quarter-Kelly stake | none | free |
/compare |
Compare two picks side-by-side | pick_a, pick_b |
free |
Casino + tools
Casino commands (/play game=coinflip/dice/slots/blackjack, /crash, /war, /lottery) each award +5 XP per action; all gated by the casino rate-limit bucket (30/min).
| Command | Purpose | Params | Tier |
|---|---|---|---|
/bankroll |
Unit size / Kelly / EV calculators | subcommands: unit, kelly, ev |
free |
/convert |
Odds format converter | value, from, to |
free |
/glossary |
207 betting terms lookup | term? |
free |
/schedule |
Today's game schedule | sport? |
free |
/remind |
Set a lineup / game reminder | when, what |
free |
/accounthealth |
Book-limit risk assessment | none | pro |
Social
| Command | Purpose | Params | Tier |
|---|---|---|---|
/give |
Send credits to a member | user, amount (1-100000) |
free |
/refer |
Show referral link / claim code | code? |
free |
/top |
All-time rankings (5 categories) | category? |
free |
/leaderboard |
Composite ROI x log(vol) x CLV ranking | limit? |
free |
/serverstats |
Server dashboard | none | free |
/challenge |
Head-to-head pick competition | user, sport, game |
free |
/duel |
1v1 prediction duel (rate-limited 10/min) | user, stake |
free |
/poll |
Community prediction polls | question, options |
free |
/tip |
Tip a capper | user, amount |
free |
Account
| Command | Purpose | Params | Tier |
|---|---|---|---|
/daily |
Claim daily credits + streak bonus (+25 XP) | none | free |
/balance |
Check credits, XP, level | none | free |
/profile |
Full stats, badges, tier, Level N/100 | none | free |
/streak |
Current win streak | none | free |
/report |
Weekly betting report card | none | free |
/self-exclude |
Responsible-gambling lockout 7/30/90/365 days | duration |
free |
/selfexclude |
Alias of /self-exclude |
duration |
free |
/inventory |
Shop items owned | none | free |
/shop |
Virtual shop (cosmetics, boosts) | item? |
free |
/badges |
List badges earned | none | free |
/reset |
Reset profile stats | none | free |
/upgrade |
Upgrade tier (opens Whop link) | none | free |
/funnel |
Upgrade funnel analytics entry | none | free |
Education + onboarding + draft
| Command | Purpose | Params | Tier |
|---|---|---|---|
/quiz |
Interactive quizzes (13 modules, 65 Qs) | module? |
free |
/gettingstarted |
Bot tour for new users | none | free |
/about |
What is NuroPicks | none | free |
/help |
Full command list | none | free |
/setup |
Post rules, roles, welcome content | none | admin |
/feedback |
Submit feedback | message |
free |
/draft |
NFL draft tracker / pick grading | pick?, team? |
free |
Capper follows
Writes through to the capper_follows table (db/schema.sql). Follow set is per Discord user, unique on (discord_id, capper_id). The marketplace + 50/50 rev-share framework lives in docs/CAPPER_AGREEMENT.md.
| Command | Purpose | Params | Tier |
|---|---|---|---|
/follow-capper |
Subscribe to a verified capper (validates against cappers table, idempotent on re-follow) |
capper (handle) |
free |
/unfollow-capper |
Drop a capper from your follow list | capper (handle) |
free |
/my-cappers |
Show your current follows with record + CLV + ROI one-liner per capper | none | free |
Example:
/follow-capper capper:glizzysharp
/my-cappers
/unfollow-capper capper:glizzysharp
Fantasy (subcommand group under /fantasy-*)
These are separate top-level commands prefixed fantasy- because Discord limits subcommands per root.
| Command | Purpose | Tier |
|---|---|---|
/fantasy-create-league |
Create a paid fantasy league | free |
/fantasy-join |
Join a league by code | free |
/fantasy-leagues |
List your leagues | free |
/fantasy-channel-setup |
Bind league to a Discord channel | free |
/fantasy-schedule-draft |
Schedule draft start | commissioner |
/fantasy-draft |
Join live draft room | free |
/fantasy-rookie-draft |
Rookie-draft side game | free |
/fantasy-nba-create-league |
NBA variant | free |
/fantasy-roster |
View your roster | free |
/fantasy-standings |
League standings | free |
/fantasy-lineup-check |
Validate current lineup | free |
/fantasy-start-sit |
Claude start/sit advisor | free |
/fantasy-trade |
Propose trade (uses trade-advisor) | free |
/fantasy-waiver |
Submit waiver claim | free |
/fantasy-waiver-wire |
Ranked waiver recommendations | free |
/fantasy-dispute |
Open dispute | free |
/fantasy-payouts |
Payout status | free |
/fantasy-refer |
Fantasy-specific referral | free |
/fantasy-rg |
Responsible-gambling controls (looser rate limit, see rg_control bucket) |
free |
/fantasy-creator-publish |
Capper publishes to subscribers | capper |
/fantasy-creator-subscribe |
Subscribe to a capper | free |
Commissioner-gated commands (fantasy-schedule-draft, dispute-resolve, etc.) check league.commissioner_discord_id === interaction.user.id at command entry.
Example usage
/picks sport:nfl
/bet sport:nba bet:Warriors -4.5 odds:-110 stake:100
/bankroll kelly bankroll:1000 odds:-110 winprob:58
/give user:@mar amount:500
/fantasy-trade league:2026-best-ball proposed-to:@glizzy give:ceedee-lamb get:amon-ra
Unknowns / TBD
POST /api/fantasy/payoutexact body + response not verified from source in this scaffold pass.- Notification preference struct fields not enumerated here - see
lib/notificationPrefs.tsfor authoritative shape. users.is_admincolumn does not yet exist in schema; admin-via-cookie path falls back toADMIN_DISCORD_IDSenv allowlist until the column lands.- Odds API per-worker quota is not instrumented centrally; see workers for per-call caching logic.
- The draft-room Socket.IO protocol is not documented here; see
services/draft-room/README.mdandsrc/services/draft/for the event-sourced actor-per-draft model. - Command tier enforcement is inconsistent: most commands check
interaction.user.idorgetProfile(userId).tierinline rather than via middleware. A centralized tier-gate helper is flagged as a P2 cleanup task elsewhere. /settleis gated by a staff-role check inside the command, not a global admin middleware. TBD: migrate to sharedrequireAdminhelper once it exists on the bot side.
See also
- Docs: QUICKSTART.md, ONBOARDING.md, GLOSSARY.md, FAQ.md. Repo root:
CONTRIBUTING.md. - Source of truth for auth + limits:
web/src/lib/{adminAuth,userAuth,rateLimit}.ts,src/bot/{utils/rateLimit,store,config}.js.