18 KiB
Public API v1 — Implementation Plan
Overview
Add a public REST API to Brackt.com, starting with a single GET /api/v1/leagues/:leagueId/draft-board endpoint. The API is read-only, requires API keys (via Clerk's built-in API keys feature), rate-limited at 60 req/min per user, versioned, and documented with OpenAPI + Scalar.
Decisions Made
| Decision | Choice | Rationale |
|---|---|---|
| Auth | Clerk API keys (beta) | Free during beta, zero custom UI needed — tab auto-appears in UserProfile |
| Access model | Public leagues only + key owner must be member | Balances openness with privacy |
| "Member" definition | Team owner OR commissioner OR system admin | Admins can always access for debugging |
| Read/write | Read-only for now | Dramatically simpler to secure |
| Rate limiting | 60 req/min per userId | Conservative starting point, easy to raise |
| Versioning | /api/v1/ URL prefix |
Best DX for curl/spreadsheet users |
| Docs | OpenAPI spec + Scalar renderer | Auto-generates interactive playground, spec is reusable |
| Timer state | Excluded from response | Stale immediately; document as "snapshot data" |
| Recommended polling interval | Every 15 seconds | Document in API docs, not enforced in code |
| Rate limit store | In-memory to start | Swap to rate-limit-redis when going multi-instance |
Scope
In Scope (Phase 1)
- Clerk API keys enabled in dashboard (manual setup step)
- API key verification middleware with in-memory caching (60s TTL)
- Per-userId rate limiting middleware
GET /api/v1/leagues/:leagueId/draft-boardendpoint- Standard JSON error format for all v1 routes
- OpenAPI spec (
docs/openapi.yaml) - Scalar docs page at
/api/docs
Out of Scope (Future)
- Additional endpoints (standings, participants, teams, etc.)
- Redis rate limit store (add when going multi-instance)
- Usage analytics / per-key call counting
- SDK generation from OpenAPI spec
- Webhook notifications for draft events
- Tiered rate limits
Files
New Files
server/middleware/apiAuth.ts API key verification + scope check + in-memory cache
server/middleware/apiRateLimit.ts Per-userId rate limiting (60 req/min)
server/routes/apiV1.ts Express router for /api/v1/*
docs/openapi.yaml OpenAPI 3.1 spec
Modified Files
server/app.ts Mount apiV1 router before React Router handler
No Changes Needed
app/routes.ts— these are Express routes, not React Router routes- Database schema — no new tables (Clerk manages key storage)
- Existing models — reuse as-is for data fetching
Step-by-Step Implementation
Step 0: Clerk Dashboard Setup (Manual — Do First)
- Go to Clerk Dashboard → Configure → API Keys
- Click Enable API Keys
- Select Enable User API Keys
- Click Enable
That's it. An "API Keys" tab will now automatically appear in the existing <UserProfile /> component — users can create, view, and revoke their own keys without any additional frontend code.
Scope enforcement (read:draft-board) is handled in your own middleware, not configured in Clerk.
Step 1: API Key Auth Middleware
File: server/middleware/apiAuth.ts
Responsibilities:
- Extract Bearer token from
Authorizationheader - Check in-memory cache (avoid Clerk network call on every request)
- On cache miss: call
clerkClient.apiKeys.verify(token) - Validate that the key has the
read:draft-boardscope - Attach
req.apiUser = { userId: string, scopes: string[] }for downstream handlers - Return standardized JSON errors (never HTML)
In-memory cache design:
type CachedKey = {
userId: string // Clerk user ID (subject from Clerk)
scopes: string[]
cachedAt: number // Date.now()
}
const cache = new Map<string, CachedKey>()
const CACHE_TTL_MS = 60_000 // 60 seconds
On each request:
- Extract token
- Hash it (sha256) for the cache key — don't store raw tokens in memory
- Check cache: if hit and
Date.now() - cachedAt < TTL, use cached value - On miss: call
clerkClient.apiKeys.verify(token), cache result
Error responses:
- Missing/malformed header →
401 { "error": "Missing API key", "status": 401 } - Invalid/revoked key →
401 { "error": "Invalid API key", "status": 401 } - Missing required scope →
403 { "error": "Insufficient scope", "status": 403 }
Extend Express Request type:
// In server/types/express.d.ts (create if needed)
declare namespace Express {
interface Request {
apiUser?: {
userId: string
scopes: string[]
}
}
}
Step 2: Rate Limit Middleware
File: server/middleware/apiRateLimit.ts
Package: express-rate-limit (already a common dep, or install it)
Config:
rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60, // 60 requests per window
keyGenerator: (req) => req.apiUser!.userId, // keyed on userId, not IP
standardHeaders: true, // Returns X-RateLimit-* headers
legacyHeaders: false,
handler: (req, res) => {
res.status(429).json({
error: 'Rate limit exceeded. Maximum 60 requests per minute.',
status: 429,
retryAfter: res.getHeader('Retry-After'),
})
},
})
Important: This middleware must run after apiAuth (needs req.apiUser.userId).
Future: Swap store to new RedisStore(...) from rate-limit-redis when adding Redis sidecar for multi-instance support.
Step 3: Draft Board Endpoint
File: server/routes/apiV1.ts
Route: GET /api/v1/leagues/:leagueId/draft-board
Middleware chain: apiAuth → apiRateLimit → handler
Handler logic:
- Load league by
leagueId(use existingfindLeagueByIdfromapp/models/league.ts) - Check
league.isPublicDraftBoard === true→ else403 { "error": "This league's draft board is not public" } - Load league's seasons to get the current/active season
- Authorization: is
req.apiUser.userIdany of:- System admin:
user.isAdmin === true - Commissioner: exists in
commissionerstable for this league - Team owner: owns a team in any of the league's seasons
→ else
403 { "error": "You are not a member of this league" }
- System admin:
- Load draft data using existing model functions:
draftPicksfor the current seasondraftSlotsfor draft order- Available participants (not yet picked)
- Team list
- Return shaped response (see below)
Response shape:
{
"league": {
"id": "uuid",
"name": "My Fantasy League"
},
"season": {
"id": "uuid",
"year": 2025,
"status": "draft",
"draftRounds": 10,
"currentPickNumber": 15,
"totalPicks": 100,
"draftPaused": false,
"draftStartedAt": "2025-01-15T18:00:00Z"
},
"teams": [
{
"id": "uuid",
"name": "Team Alpha",
"draftOrder": 1
}
],
"picks": [
{
"pickNumber": 1,
"round": 1,
"pickInRound": 1,
"team": { "id": "uuid", "name": "Team Alpha" },
"participant": {
"id": "uuid",
"name": "Patrick Mahomes",
"sport": { "name": "NFL", "slug": "nfl" }
}
}
],
"availableParticipants": [
{
"id": "uuid",
"name": "Josh Allen",
"sport": { "name": "NFL", "slug": "nfl" }
}
],
"retrievedAt": "2025-01-15T18:05:30Z"
}
Notes on response design:
retrievedAttimestamp helps clients know how stale the data is- Timer/time bank state intentionally omitted — stale immediately
- No autodraft settings or queue data — those are private per-team
totalPicks=draftRounds * teams.length, useful for clients to calculate completion %
Step 4: Standard Error Handler for v1 Router
All errors from /api/v1/* must return JSON, never HTML (Express's default).
In apiV1.ts, add an error handler at the bottom of the router:
// Catch-all error handler for v1 routes
router.use((err, req, res, next) => {
console.error('[API v1 error]', err)
res.status(err.status ?? 500).json({
error: err.message ?? 'Internal server error',
status: err.status ?? 500,
})
})
Also handle 404s within the v1 namespace:
router.use((req, res) => {
res.status(404).json({ error: 'Endpoint not found', status: 404 })
})
Step 5: Mount in Express
File: server/app.ts
Add before the React Router handler:
import { apiV1Router } from './routes/apiV1'
// Public API — must come before React Router catch-all
app.use('/api/v1', apiV1Router)
This ensures /api/v1/* requests never reach React Router.
Step 6: OpenAPI Spec
File: docs/openapi.yaml
openapi: 3.1.0
info:
title: Brackt API
version: 1.0.0
description: |
The Brackt public API provides read-only access to fantasy draft data
for public leagues you are a member of.
## Authentication
All requests require an API key sent as a Bearer token:
```
Authorization: Bearer your_api_key
```
Generate API keys from your account settings page.
## Rate Limiting
60 requests per minute per API key. Responses include X-RateLimit-*
headers. Exceeding the limit returns HTTP 429.
## Polling
This is a snapshot API. For live drafts, we recommend polling no more
frequently than every 10–15 seconds. Timer state is not included in
responses as it changes every second.
servers:
- url: https://brackt.com/api/v1
description: Production
security:
- bearerAuth: []
paths:
/leagues/{leagueId}/draft-board:
get:
summary: Get draft board state
description: |
Returns the current state of a league's draft board, including all
picks made, available participants, and team draft order.
The league must have its draft board set to public, and your API key
must belong to a member of the league (team owner, commissioner, or
admin).
operationId: getDraftBoard
tags: [Draft]
parameters:
- name: leagueId
in: path
required: true
schema:
type: string
format: uuid
description: The league's UUID
responses:
'200':
description: Draft board state
content:
application/json:
schema:
$ref: '#/components/schemas/DraftBoard'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/RateLimited'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: API key generated from your account settings
responses:
Unauthorized:
description: Missing or invalid API key
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
Forbidden:
description: Valid key but insufficient permissions
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
RateLimited:
description: Rate limit exceeded
headers:
Retry-After:
schema:
type: integer
description: Seconds until the rate limit window resets
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
schemas:
Error:
type: object
required: [error, status]
properties:
error:
type: string
status:
type: integer
DraftBoard:
type: object
required: [league, season, teams, picks, availableParticipants, retrievedAt]
properties:
league:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
season:
type: object
properties:
id: { type: string, format: uuid }
year: { type: integer }
status:
type: string
enum: [pre_draft, draft, active, completed]
draftRounds: { type: integer }
currentPickNumber: { type: integer }
totalPicks: { type: integer }
draftPaused: { type: boolean }
draftStartedAt: { type: string, format: date-time, nullable: true }
teams:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
draftOrder: { type: integer }
picks:
type: array
items:
type: object
properties:
pickNumber: { type: integer }
round: { type: integer }
pickInRound: { type: integer }
team:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
participant:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
sport:
type: object
properties:
name: { type: string }
slug: { type: string }
availableParticipants:
type: array
items:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
sport:
type: object
properties:
name: { type: string }
slug: { type: string }
retrievedAt:
type: string
format: date-time
Step 7: Scalar Docs Page
Scalar can be served as a single HTML file from Express. No npm package needed for the basic setup (it loads from CDN):
In server/routes/apiV1.ts or a separate route in server/app.ts:
app.get('/api/docs', (req, res) => {
res.send(`
<!doctype html>
<html>
<head>
<title>Brackt API Reference</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script
id="api-reference"
data-url="/api/docs/openapi.yaml"
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
`)
})
// Serve the OpenAPI spec itself
app.get('/api/docs/openapi.yaml', (req, res) => {
res.sendFile(path.join(process.cwd(), 'docs/openapi.yaml'))
})
This gives a full interactive API playground at https://brackt.com/api/docs.
Authorization Logic (Detailed)
For the draft board endpoint, the userId extracted from the API key needs to map to a Clerk user in the database. The Clerk subject field returns a Clerk user ID (e.g., user_abc123). This matches the clerkId field on the users table.
Lookup chain:
// 1. Find user by clerkId
const user = await findUserByClerkId(req.apiUser.userId)
if (!user) return 403
// 2. Check admin
if (user.isAdmin) → authorized
// 3. Check commissioner
const isCommissioner = await isLeagueCommissioner(leagueId, user.id)
if (isCommissioner) → authorized
// 4. Check team owner — user owns a team in any season of this league
const isTeamOwner = await hasTeamInLeague(leagueId, user.clerkId)
if (isTeamOwner) → authorized
// else → 403
The ownerId on teams stores the Clerk user ID directly, so the team owner check queries:
SELECT 1 FROM teams t
JOIN seasons s ON t.season_id = s.id
WHERE s.league_id = :leagueId AND t.owner_id = :clerkId
LIMIT 1
Future Endpoints (Not In Scope Now — For Reference)
When ready to expand, natural next endpoints:
GET /api/v1/leagues/:leagueId/seasons List seasons for a league
GET /api/v1/leagues/:leagueId/standings/:seasonId Final standings
GET /api/v1/leagues/:leagueId/teams/:teamId Team roster/picks
GET /api/v1/participants/:sportSlug All participants for a sport
Each would need the same auth + rate limit middleware chain, with scopes like read:standings, read:teams, etc.
Security Checklist
- API keys never logged (only hash stored in cache)
- Rate limiting keyed on userId, not IP (harder to bypass with rotating IPs)
isPublicDraftBoardchecked before any data is returned- Membership verified before any data is returned
- All errors return JSON (no stack traces in production)
retrievedAttimestamp but no internal IDs beyond what's needed- No autodraft, queue, or timer data in response (private/volatile)
- OpenAPI spec served from static file (no runtime generation risk)
Testing Plan
Unit Tests
apiAuthmiddleware: valid key, invalid key, expired cache, scope missing, no headerapiRateLimitmiddleware: under limit passes, over limit returns 429 with headers- Draft board handler: public league + member → 200, private league → 403, non-member → 403
Manual Tests
- Enable Clerk API keys in dashboard
- Generate a key from UserProfile in the app
curl -H "Authorization: Bearer <key>" https://localhost:3000/api/v1/leagues/<id>/draft-board- Verify 403 on private league
- Verify 403 on public league where user is not a member
- Verify 429 after 60 requests in a minute
- Verify
/api/docsloads and shows the playground
Dependencies to Install
npm install express-rate-limit
@clerk/backend is already installed (used for webhook verification). The clerkClient.apiKeys.verify() method is available in the existing Clerk backend client.
Scalar docs load from CDN — no npm install needed for basic setup. If you want to self-host:
npm install @scalar/api-reference # optional