Prize Stats: every blank, filled
A small stats API that fills the weekly prize results two fantasy platforms could not answer.
The story in three parts
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.
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.
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.
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.
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.
| Week | Challenge | League A (ESPN) | League B (Yahoo) |
|---|---|---|---|
| 2 | Two starting RBs' combined rushing yards | Covered | Gap: fantasy points only |
| 5 | Starting WRs' combined receiving yards | Covered | Gap: fantasy points only |
| 8 | Closest to projected score | Gap: projection gone after kickoff | Covered |
| 11 | Longest completion by the starting QB | Gap | Gap |
| 12 | A starter with exactly 3 touchdowns | Covered | Gap: 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.
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.
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.
| Method | Endpoint | Answers |
|---|---|---|
| GET | /health | Is the service up, and when were stats last refreshed? Open, no key. |
| GET | /measures | Which stats can a challenge rank on? |
| GET | /weeks/{week}/players?name= | What was one player's stat line that week? |
| POST | /weeks/{week}/fill | Given each team's starters and a stat, what are the totals and the ranking? |
| POST | /weeks/{week}/exact-tds | Who 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.
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.
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.