Every public endpoint the app exposes. public endpoints need no auth (Supabase RLS enforces read-only access). auth endpoints require a Supabase session cookie (signed in via /signin). cron endpoints require the CRON_SECRET bearer token — used by Vercel Cron.
33 / 33 endpoints
Public (16)
GET/api/version
public
Build + deploy metadata. Surfaces commit info from Vercel env vars, null locally. Useful for Sentry release tags, diffing staging vs prod, spotting stale caches.
Aggregate counters for public dashboards: DB row counts + share totals in one round trip. 60s CDN cache + SWR so growth shows up within a minute without hammering the DB.
Returns
{ generatedAt, db, shares: { pick, parlay, total } }
GET/api/market-baseline
public
Model probabilities vs the closing line on the same matches — the only metric that predicts profitability rather than scoring the model against itself. Paired Brier/log-loss per prediction source with the standard error, so a favourable sign inside the noise band reports as 'indistinguishable' rather than a win. Top-level fields are FOOTBALL; tennis runs the same comparator under 'tennis' and carries its own caveats — its absolute Brier is not comparable to football's, because tennis rows store the winner in the home slot, so only the model-minus-market difference means anything across sports. Also split by DATA DEPTH: Elo needs ~20 rated games per team to be informative, and 37% of the live-model sample (measured 2026-07-26) came from competitions sitting at 1-6, where the model emits its prior rather than a prediction — so `byDepth` separates 'informed' from 'thin' and the headline is diluted by the latter. Returns state='no-closing-odds-yet' with populate instructions when the backfill hasn't run.
Cheap health check for uptime monitors. Default returns { ok, generatedAt, demoMode, integrations } with no DB or external calls. HEAD supported for even lighter pings. Pass ?include=shares,db (comma-separated) to append optional sections.
Grade a batch of saved picks against recorded outcomes. Runs every market through gradeProp().
Returns
{ picks: [{ id, result, pnl }] }
POST/api/picks/share
public
Generate a signed shareable link for a single pick. Uses PICK_SHARE_SECRET; 503 when unconfigured. Rate-limited 10/IP/10min.
Params
sportIdbody— football | nba | tennis
externalMatchIdbody— Adapter-level match id
matchLabelbody— Human-readable matchup
startsAtbody— ISO kickoff timestamp
marketLabelbody— Market + selection label
decimalOddsbody— Odds taken
modelProbbody— 0..1 model probability
edgePctbody— Edge at save time
Returns
{ token, shareUrl }
GET/api/picks/shared/[token]
public
Decode a signed pick token into its SharedPickPayload JSON. 400 { reason } on tamper/format errors.
Params
tokenpath— v1.<body>.<sig> format
Returns
{ payload: SharedPickPayload }
POST/api/parlays/share
public
Generate a signed shareable link for a 2–6 leg parlay. Computes joint prob + combined odds + edge and freezes them into the token. Same rate limiter as pick share.
Params
legsbody— Array of leg objects (sportId, externalMatchId, matchLabel, marketLabel, decimalOdds, modelProb)
Returns
{ token, shareUrl }
GET/api/parlays/shared/[token]
public
Decode a signed parlay token into its SharedParlayPayload JSON. 400 { reason } on tamper/format errors.
Params
tokenpath— pv1.<body>.<sig> format
Returns
{ payload: SharedParlayPayload }
Auth required (14)
GET/api/user/picks
auth
Return all cloud-stored picks for the signed-in user. Anonymous callers get 401.
Returns
{ picks: StoredPick[] }
POST/api/user/picks
auth
Upsert a batch of picks (≤5000) for the signed-in user.
Params
picksbody— Array of StoredPick
Returns
{ upserted: number }
DELETE/api/user/picks
auth
Delete picks by id for the signed-in user.
Params
idsbody— Array of pick ids (≤1000)
Returns
{ deleted: number }
GET/api/user/picks/export
auth
Download all cloud-stored picks for the signed-in user as CSV (Content-Disposition: attachment). Same schema as the in-app CSV export.
Returns
text/csv
GET/api/user/watchlist
auth
Cloud-synced watchlist for the signed-in user.
Returns
{ refs: WatchRef[] }
POST/api/user/watchlist
auth
Upsert watchlist refs (≤500) for the signed-in user.
Returns
{ upserted: number }
DELETE/api/user/watchlist
auth
Delete watchlist refs by key for the signed-in user.
Returns
{ deleted: number }
GET/api/user/watchlist/export
auth
Download the signed-in user's cloud-stored watchlist as a JSON backup envelope (version 1). Triggers a file download.
Returns
{ version: 1, exportedAt, refs: WatchRef[] }
GET/api/user/prefs
auth
Read the signed-in user's notification + staking prefs.
Returns
{ prefs: NotificationPrefs | null, updatedAt }
PUT/api/user/prefs
auth
Replace the signed-in user's prefs.
Returns
{ ok: true }
GET/api/user/profile
auth
Read the signed-in user's profile (display name, digest opt-in, last digest sent).
Returns
{ profile: UserProfile }
PATCH/api/user/profile
auth
Patch profile fields. Currently supports email_digest_opt_in + display_name.
Returns
{ ok: true }
GET/api/user/export
auth
Download the signed-in user's full state (picks + watchlist + prefs + profile) as one JSON envelope. Single-call 'give me everything' export.
Backfill historical closing odds from football-data.co.uk, which is what /api/market-baseline compares the model against. Measures the name-based join and reports the exact unresolved club names by default; writes nothing unless &commit=1, and even then only for leagues whose measured match rate clears 90% — a wrong join would feed the wrong closing line into the calibration. Re-running is safe: it clears only the rows it previously wrote.
Params
seasonquery— football-data.co.uk 4-digit form — 2024/25 is '2425'. Defaults to 2425.
leaguesquery— Comma-separated subset of PL,PD,SA,BL1,FL1 (the default). Domestic only — this source has no European fixtures.
commitquery— Set to 1 to persist. Absent = dry-run report only.
Send the weekly email digest to opted-in users via Resend. Guarded by CRON_SECRET and gated on RESEND_API_KEY + EMAIL_FROM.
Returns
{ sent, failed, total, weekLabel, results[] }
All endpoints are demo-friendly: if the relevant API key isn't set, they return sensible empty-state responses instead of erroring. See /about for the model methodology.