I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads the two big pick'em apps — PrizePicks and Underdog Fantasy — and returns both boards as one table.
Pick'em is not sportsbook betting. There is no moneyline and no spread; the app posts a number for a player and a stat, and you say over or under. The number is the product. So the obvious thing to build is a comparison: same player, same stat, two apps, who asks for less.
That join is where everyone — me included, on the first try — gets it wrong. Not because the data is dirty, but because the two boards are not the same shape, and nothing in either payload announces that.
Here is a run from this afternoon, 16 October 2026: props mode, both apps, NFL, 600 rows, one run.
The shape problem in one table
| PrizePicks | Underdog | |
|---|---|---|
| Rows returned | 473 | 127 |
| Distinct player + stat pairs | 125 | 127 |
| Rows per pair | 3.78 | 1.00 |
| Prices published | none | all 127 rows |
Underdog shipped exactly one row for every player-and-stat pair — 127 of 127, no duplicates, every one two-sided and priced. PrizePicks shipped 473 rows covering 125 pairs.
Those extra 348 rows are not a paging bug. PrizePicks posts a ladder: the board line plus boosted alternates at other numbers. oddsType names them — standard is the board line, a demon is a harder number that pays more, a goblin an easier one that pays less.
Across the whole pull:
oddsType |
Rows |
|---|---|
| demon | 249 |
| goblin | 128 |
| standard | 96 |
| balanced (all Underdog) | 127 |
Only 96 of 473 PrizePicks rows are the number on the board. If you average line grouped by player and stat, you are averaging a marketing ladder.
Nine rungs, one board number
The deepest ladder in the run, C.J. Stroud's passing yards:
line |
oddsType |
allowedPicks |
|---|---|---|
| 189.5 | goblin | over |
| 199.5 | goblin | over |
| 224.5 | goblin | over |
| 246.5 | goblin | over |
| 256.5 | standard | under_or_over |
| 264.5 | demon | over |
| 266.5 | demon | over |
| 269.5 | demon | over |
| 374.5 | demon | over |
Nine rows, one player, one stat, a 185-yard spread from bottom to top. The board number is 256.5. Everything else is a priced-up offer sitting on the same player.
And look at the third column, because that is the one that actually bites. Eight of those nine rows are over-only. You cannot take the under on a goblin at 189.5. It isn't a line in the sense you mean when you say "line" — it is a one-sided offer that happens to be rendered as a number. Pull these rows into a model as symmetric over/under lines and you have invented bets the app does not sell.
Ladder depth across the 125 pairs:
| Rows for the pair | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| Pairs | 38 | 10 | 17 | 10 | 16 | 9 | 12 | 11 | 2 |
Nearly 70% of pairs carry at least two rows, and in the compare mode only 2 of 46 standard lines had no alternates behind them at all. The median standard line was hiding six or seven.
oddsType and allowedPicks disagree, so read both
The tempting shortcut is oddsType == 'standard'. I used it, then cross-tabbed the two fields on the same 473 rows:
oddsType |
allowedPicks |
Rows |
|---|---|---|
| demon | over | 240 |
| goblin | over | 117 |
| standard | under_or_over | 75 |
| standard | null | 16 |
| goblin | under_or_over | 11 |
| demon | under_or_over | 9 |
| standard | over | 5 |
Three surprises in that table. Twenty boosted alternates are two-sided. Five standard lines are not — they are over-only despite being the board number. And 16 rows don't say at all: allowedPicks comes back null on every Anytime TDs (Combo) row and, less explicably, on seven plain Rec Targets rows.
So the payout type and the permitted sides are independent facts, and one of them is sometimes missing. Treat null as unknown, never as two-sided.
The other thing you lose with a standard filter: 29 of the 125 pairs have no standard row at all. Chris Rodriguez Jr.'s longest rush, Nico Collins' receptions — demons and goblins only. Filter to standard and 23% of the board silently disappears, which is worse than keeping the ladder, because a short list looks like a correct list.
Seven of thirty stat names match
Now the join. PrizePicks used 30 distinct stat names in this run, Underdog 31. Names that match exactly: seven.
FG Made, Kicking Points, Longest Rush, Pass Attempts, Pass TDs, Pass Yards, Rush Yards. That's it. Everything else needs translating:
| PrizePicks | Underdog |
|---|---|
| Rec Yards | Receiving Yards |
| Recs | Receptions |
| Rec Targets | Targets |
| Rush Atts | Rush Attempts |
| Longest Rec | Longest Reception |
| Rush+Rec Yds | Rush + Rec Yards |
Thirty of the 46 comparison rows in my compare run pair two differently-spelled names. A join on raw strings would have returned 16 rows and looked like it worked.
Fuzzy matching is worse than useless here, and this run has the proof sitting right in it. PrizePicks publishes eight (Combo) markets — multi-player parlay-style props — and one of them is literally named Receiving Yards (Combo). Any substring or token-overlap matcher pairs that against Underdog's single-player Receiving Yards. Meanwhile Underdog's plain NFL board carries eight partial-game markets inline — 1H Rush Yards, 1Q Receptions, 1H Rush + Rec TDs — which share every meaningful token with the full-game stat and are a completely different bet. Rush+Rec Yds versus Receiving Yards scores high on most similarity metrics too.
The only thing that works is an explicit alias table and an explicit exclusion list. Boring, finite, and it does not quietly invent pairs on a slate you didn't look at.
What the comparison actually found
Compare mode, same slate, 46 pairs:
| Line difference | Pairs |
|---|---|
| 0 | 17 |
| 0.5 – 2 | 14 |
| 3 – 5 | 11 |
| 6 – 7 | 4 |
The marketing claim about these apps is that they rarely agree. On this slate they agreed exactly on 37% of shared props. Where they differed, PrizePicks had the easier over 16 times and Underdog 13.
The widest gaps, and the thing to notice about them:
| Player | Stat | PrizePicks | Underdog | Gap | Easier over | PP alternates |
|---|---|---|---|---|---|---|
| C.J. Stroud | Pass Yards | 256.5 | 249.5 | 7 | underdog | 8 |
| Dalton Schultz | Rec Yards | 31.5 | 24.5 | 7 | underdog | 7 |
| Bijan Robinson | Rush+Rec Yds | 123.5 | 129.5 | −6 | prizepicks | 6 |
| Bhayshul Tuten | Rec Yards | 8.5 | 14.5 | −6 | prizepicks | 5 |
Seven yards on Stroud's 250 is noise. Seven yards on Schultz's 24.5 is a 29% disagreement about a tight end. Absolute line difference is the wrong unit for comparing across stats, and the field is deliberately raw so you can divide by whatever you like.
Also: easierOver is null on all 17 ties rather than naming a winner. Those are ties, not wins.
Calling it
One player, both boards, everything including the ladder:
curl -X POST "https://api.apify.com/v2/acts/lergassy~dfs-props-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode":"props","players":["Stroud"],"maxRows":50}'
Board lines only, with the ladder depth kept as a number:
from apify_client import ApifyClient
client = ApifyClient("<TOKEN>")
run = client.actor("lergassy/dfs-props-scraper").call(run_input={
"mode": "compare", "leagues": ["NFL"], "maxRows": 200,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
rel = abs(row["lineDifference"]) / row["underdogLine"]
print(row["player"], row["market"], row["prizePicksLine"],
row["underdogLine"], f"{rel:.1%}", row["prizePicksAlternateLines"])
Both endpoints are public JSON — no account, no cookies, no proxy, no browser. PrizePicks answers 403 to a filtered request on its main host and 429 to bursts, so requests go to the partner host and get spaced out; a rate limit is retried slowly rather than hammered. My three runs today — 11 league rows, 600 props, 46 comparisons — cost about $0.15 in total, with no per-run fee. The Actor is DFS Player Props API if you want to point it at tonight's slate.
When this is the wrong tool
- You want sportsbook odds. Pick'em apps don't publish a moneyline or a vig. For prices across DraftKings, Pinnacle and the like you need an odds feed and a de-vig step — a different tool (this one, in my case).
-
You want implied probabilities on both apps. PrizePicks publishes a payout type, not a price. There is no honest implied probability to compute from
goblin. QuoteoddsTypefor PrizePicks rows and leave the percentage column empty. - You want the whole board, historically. Boards move all day and these endpoints serve now. Schedule the run if you want a time series; nobody will hand you yesterday.
- You want picks. This returns a table. It has no opinion.
One run, two boards, one asymmetry worth internalising: Underdog hands you a row per bet, PrizePicks hands you a row per offer. Count rows and you will think PrizePicks has four times the board. It has roughly the same board, sold four ways.
Top comments (0)