Skip to content
Developer reference

API

16 public14 auth3 cron

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.

Returns
{ name, version, commitSha, commitRef, commitMessage, deploymentId, environment, generatedAt }
GET/api/counts
public

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.

Returns
{ state, closingLinesFound, matchesCompared, bySource: [{ source, samples, model, market, brierDelta, logLossDelta, modelHigherOnActualRate, verdict, reading }], bookmakers, dropped, caveats, byDepth: [{ source, depth: 'informed'|'thin', comparison, leagues[] }], tennis: { state, comparison, paired, unusablePrice, absoluteScoresComparable: false, caveats } }
GET/api/status
public

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.

Params
  • includequeryComma-separated extensions. Supported: 'shares' (share-counter totals), 'db' (analyses/matches/outcomes/ratings row counts).
Returns
{ ok: true, generatedAt, demoMode, integrations, shares?, db? }
GET/api/brief
public

Curated daily brief — top +EV edges, upcoming 24h, recent graded, streaks.

Params
  • sportqueryfootball | nba | tennis — optional filter
Returns
DailyBrief JSON
GET/api/feed.xml
public

RSS 2.0 feed of current +EV edges ≥ 4%. Subscribable in any reader.

Params
  • sportqueryOptional sport filter
Returns
application/rss+xml
GET/api/analyses/[sport]/[matchId]
public

Full cached analysis for a single match — probabilities, edges, props, news, line history, retrospective.

Params
  • sportpathfootball | nba | tennis
  • matchIdpathThe sport's external match identifier
Returns
{ analysis, fresh, lineHistory, retrospective }
GET/api/matches/[sport]
public

Upcoming matches for a sport — sourced from the sport's free data provider.

Params
  • sportpathfootball | nba | tennis
  • leaguequeryOptional league filter
  • limitqueryOptional result cap (default 40)
Returns
{ matches: MatchMeta[] }
POST/api/analyze
public

Generate or serve a cached analysis for a match. Rate-limited per match + per IP.

Params
  • sportIdbodyfootball | nba | tennis
  • matchIdbodyexternal match id
  • forcebodybool — regenerate (respects rate limit)
Returns
{ cached: boolean, analysis, demoMode }
POST/api/picks/grade
public

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
  • sportIdbodyfootball | nba | tennis
  • externalMatchIdbodyAdapter-level match id
  • matchLabelbodyHuman-readable matchup
  • startsAtbodyISO kickoff timestamp
  • marketLabelbodyMarket + selection label
  • decimalOddsbodyOdds taken
  • modelProbbody0..1 model probability
  • edgePctbodyEdge 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
  • tokenpathv1.<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
  • legsbodyArray 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
  • tokenpathpv1.<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
  • picksbodyArray of StoredPick
Returns
{ upserted: number }
DELETE/api/user/picks
auth

Delete picks by id for the signed-in user.

Params
  • idsbodyArray 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.

Returns
{ version: 1, exportedAt, user, picks, watchlist, prefs, profile }
DELETE/api/user/account
auth

Self-serve account deletion. Cascades to every user_* row via FK. Irreversible — client should redirect to / and wipe localStorage after.

Returns
{ ok: true }

Cron only (3)

GET/api/cron/ingest-outcomes
cron

Trigger outcome ingestion + facts write-back. Protected by CRON_SECRET.

Returns
{ ok: true, reports: [{ sport, scanned, graded, skipped, errors }] }
GET/api/cron/backfill-closing-odds
cron

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
  • seasonqueryfootball-data.co.uk 4-digit form — 2024/25 is '2425'. Defaults to 2425.
  • leaguesqueryComma-separated subset of PL,PD,SA,BL1,FL1 (the default). Domestic only — this source has no European fixtures.
  • commitquerySet to 1 to persist. Absent = dry-run report only.
Returns
{ ok, dryRun, verdict, overallMatchRate, unresolvedNames[], leagues: [{ league, matchRate, writable, oddsSource, counts }], writes?, totals }
GET/api/cron/digest-weekly
cron

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.