← All work
Backtested · PRD · API TypeScript · Postgres (Neon) · Built with Claude Code

Prize Stats: every blank, filled

A small stats API that fills the weekly prize results two fantasy platforms could not answer.

Role
Product owner and first user; built with Claude as engineering partner
Stack
TypeScript, Hono, Postgres on Neon, nflverse stats

The story in three parts

01 · Problem

5 of 15 challenges left blanks

I run weekly side prizes for two 12-team leagues. One platform returns raw stats, the other fantasy points only, so 5 of the season's 15 weekly challenges left at least one league blank, filled by hand.

02 · Built

An API for the gaps only

An authenticated API on Postgres that matches each fantasy starter to official NFL weekly stats, totals and ranks every team, and flags anyone it cannot match.

03 · Where it stands

Two weeks matched exactly

Both weeks tested so far matched my hand-checked results for all 24 starters each. The weekly automation is not wired to the API yet.

What the work turned up

The audit cut the build to 5 challenges

Mapping every challenge to what each platform can answer showed 10 of 15 were already covered.

v1 needed exact logic, not AI

Each week has one right answer, so it is tested logic. The AI behavior contract waits for the layers where answers are open-ended.

A shared name was flagged, not guessed

Two NFL players share one name. The API refused to pick, and a league rule settled it.

01 · The problem

The problem

The scoring was already automated. The gap was data.

Each week, both leagues play a side challenge, such as most combined rushing yards from a team's two starting running backs. A scheduled automation already pulls each league's scores and rosters and drafts the weekly announcement.

One league's platform returns raw stats; the other returns fantasy points only. Some stats, like a quarterback's longest completion, come from neither. Wherever the data had no answer, the automation left a blank, and I filled it by hand: 5 of 15 weekly challenges leave at least one league blank, and one of them is blank in both.

02 · Discovery

Discovery

I audited all 15 weekly challenges against what each platform's data can answer.

Most challenges were already covered; the gaps clustered in raw player stats.

Weekly challenges with a data gap in at least one league
WeekChallengeLeague A (ESPN)League B (Yahoo)
2Two starting RBs' combined rushing yardsCoveredGap: fantasy points only
5Starting WRs' combined receiving yardsCoveredGap: fantasy points only
8Closest to projected scoreGap: projection gone after kickoffCovered
11Longest completion by the starting QBGapGap
12A starter with exactly 3 touchdownsCoveredGap: no TD counts

The other 10 challenges, plus a weekly elimination game, run on team scores and fantasy points both platforms already provide. The audit also showed the challenges reduce to 7 rule shapes, such as a sum of one stat across fixed lineup slots, or an exact count with a position priority. That shaped the data model.

03 · Decisions

Key decisions

I wrote the PRD as a decision record: the problem with its number, goals and non-goals, a golden test set, and a staged rollout with a kill switch I own.

  • Build only for the gaps. The existing automation already handled 10 of 15 weeks; rebuilding them added risk and no value.
  • Data API first, AI layers later. v1 has one correct answer per week, so it needs exact logic and tests, not a model. An MCP layer and retrieval only make sense once there is trusted data under them.
  • Prize rules as data. Both leagues share one codebase, and a new challenge is configuration rather than code.
  • Flag, never guess. A player who cannot be matched comes back flagged for a human. A wrong winner announced to the league costs more than a blank.
  • Free managed Postgres over HTTP. Neon's free plan fits a once-a-week workload, and its HTTP driver works on serverless hosts.
  • Starters only, slot-aware. Bench and flex rules differ by challenge, so the API takes each starter's lineup slot as input.
04 · API design

The API design

Five endpoints cover v1. Each answers a question the weekly scoring actually asks, and every response says how fresh its data is.

API endpoints and the question each answers
MethodEndpointAnswers
GET/healthIs the service up, and when were stats last refreshed? Open, no key.
GET/measuresWhich stats can a challenge rank on?
GET/weeks/{week}/players?name=What was one player's stat line that week?
POST/weeks/{week}/fillGiven each team's starters and a stat, what are the totals and the ranking?
POST/weeks/{week}/exact-tdsWho scored exactly 3 touchdowns, and who wins by position priority?

The caller sends starters by lineup slot, the same way the league platforms report them:

POST /weeks/2/fill
x-api-key: <key>

{ "measure": "rush_yards",
  "starters": [
    { "manager": "Team 1", "slot": "RB1", "name": "Chase Brown" },
    { "manager": "Team 1", "slot": "RB2", "name": "Aaron Jones Sr." } ] }

The response ranks the teams, shows each player's matched stat line, and says whether the result is complete:

{ "week": 2, "measure": "rush_yards",
  "stats_refreshed_at": "2026-10-22T04:12:02Z",
  "complete": true,
  "teams": [ { "manager": "Team 1", "total": 185, "complete": true,
    "players": [ { "slot": "RB1", "name": "Chase Brown", "value": 80 },
                 { "slot": "RB2", "name": "Aaron Jones Sr.", "value": 105 } ] } ] }
  • One generic fill endpoint, not one per challenge. The stat is a parameter, so a new challenge on an existing stat needs no new endpoint.
  • Unmatched players come back as data, not errors. A player with no stat line returns an empty value with a plain-language flag, and the team is marked incomplete, so the caller can still post everything else.
  • Freshness on every response. A refresh timestamp lets a reader see whether a stat correction could still change the result.
  • Key auth on everything except health. A host or a person can check the service is up without holding a key.
05 · Validation

Validation

The API had to earn trust against answers I already knew before it could replace any hand-filled cell.

  • Golden set. Every prize result already settled by hand this season, weeks 1 to 6 in both leagues, 12 results in all. The API must match every one before it fills a live week.
  • The shared-name catch. In the week 5 backtest, the API refused to score Justin Jefferson. Two active NFL players share the name, a wide receiver and a linebacker, and League B's platform does not send a player's NFL team. Instead of guessing, the API flagged him.
  • A domain rule settled it. Neither league has individual defensive player slots, so a defender can never be a fantasy starter. Excluding defenders, and using the lineup slot to break other ties, resolved the case. Two offensive players with one name in a flex slot are still flagged.
  • Regression check. After each change I re-ran both backtested weeks, and the totals did not move.
06 · Where it stands

Where it stands

  • Live on Postgres with 6,488 weekly stat lines loaded, weeks 1 to 6.
  • League B's week 2 rushing yards and week 5 receiving yards matched for 24 of 24 starters each, and both matched my hand-checked sheets exactly: 2 of the 12 golden results tested so far.
  • 10 of 10 automated tests pass, and requests without a valid key are refused.
  • The exactly-3-touchdowns rule is built and covered by tests, but it has not settled a live week yet.
  • The weekly automation is not wired to the API yet; the backtests so far were run by hand.

What I took from it: the most useful early work was the audit, not the code; a result with one right answer is better served by exact logic than a model; and a flag that asks for a human is worth more to a league than a confident wrong winner.