# Underdog Fantasy Player Props Scraper - Lines & Line Moves (`neverempty/underdog-player-props-scraper`) Actor

For DFS and betting models: every Underdog Higher/Lower prop as a row - player, team, stat, line, payout multiplier, price, implied probability, start time. 5,607 pre-game lines, 11 leagues, one request (2026-09-22). Monitoring returns only lines that moved, with the earlier values.

- **URL**: https://apify.com/neverempty/underdog-player-props-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Sports, Developer tools, Automation
- **Stats:** 1 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.11 / 1,000 line returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Underdog Fantasy Player Props Scraper - Lines & Line Moves

For DFS and betting models: every Underdog Fantasy Higher/Lower player prop as one row - player, team, opponent, stat, the line itself, the payout multiplier for each side, American and decimal prices, Underdog's own implied probability and the game start time. On 2026-09-22 a single request returned **5,607 pre-game lines across 11 leagues**, in 11.0 seconds. Turn monitoring on and later runs return only the lines that have actually changed, with the previous value, the first value ever seen and both differences in the same row - so you are never charged for the same unchanged line twice.

Export as JSON, CSV or Excel.

### Why monitoring matters here

Underdog's board barely moves between runs. Measured by taking the whole board twice, **9 minutes 5 seconds apart**, on 2026-09-22:

| | lines |
|---|---|
| on the board the second time | 5,542 |
| unchanged since the first read | 5,368 |
| changed (line, multiplier, price, odds or status) | 169 |
| newly opened | 5 |
| **charged in monitoring mode** | **174 (3.1%)** |
| lines that had closed and were gone | 70 |

Reading the board costs the same either way - the $0.01 start below. With monitoring on, that run charges for 174 rows instead of 5,542 - about **1/32 of the rows** - and each of those rows carries what the value was before.

Both snapshots are in this repository as `test/fixtures/board-a-fingerprints.json` and `board-b-fingerprints.json` (the key and the fingerprint of every line, produced by `src/underdog.js` itself), and `npm test` recounts every number above. The 45-line and 47-line samples in `test/fixtures/lines-a.json` and `lines-b.json` are cut from the same two reads.

How much moves depends entirely on the gap between runs, and one measurement is one measurement. Three more pairs of runs on the same day, all on the same 6,300-line board:

| gap between runs | lines changed |
|---|---|
| back to back (about a minute) | 2, then 1 |
| a few minutes | 256 |
| 25 minutes | 1,052 of 6,182 (17%) |

So the shorter the schedule, the smaller the bill per run - the opposite of paying full price for the whole board every time. Set the run's maximum total charge if you want a hard ceiling rather than trusting a rate.

### What you get

One row per line. Columns:

- **Identity** - `lineKey` (stable across runs; use it to join runs together), `lineId`, `overUnderId`, `propId` (player + stat, shared by a line and its alternates), `appearanceId`
- **Player** - `playerId`, `playerName`, `firstName`, `lastName`, `position`, `positionName`, `jerseyNumber`
- **Teams** - `teamId`, `teamName`, `opponentTeamId`, `opponentName`, `homeAway`
- **Game** - `matchType`, `gameId`, `gameTitle`, `gameShortTitle`, `gameStartsAt`, `gameStatus`, `gameVenue`, `competition`
- **The prop** - `league`, `leagueName`, `stat` (Underdog's code, e.g. `rush_rec_yds`), `statName` (`Rush + Rec Yards`), `propTitle`, `line`, `lineText`, `hasAlternateLines`, `sidesOffered`
- **Each side** (`higher...` and `lower...`) - `PayoutMultiplier`, `AmericanPrice`, `DecimalPrice`, `OddsAmerican`, `OddsDecimal`, `ImpliedProbabilityPercent`, `Status`, `PickText`
- **The line's own state** - `lineStatus`, `lineType`, `isLiveLine`, `providerId`, `lineUpdatedAt`
- **Monitoring only** - `isNewSinceLastRun`, `changeKinds` (`line` / `multiplier` / `price` / `odds` / `status` / `new`), `previousLine`, `lineMove`, `firstSeenLine`, `lineMoveSinceFirstSeen`, `previousHigherPayoutMultiplier`, `higherPayoutMultiplierMove`, `previousLowerPayoutMultiplier`, `lowerPayoutMultiplierMove`, `previousHigherOddsAmerican`, `previousLowerOddsAmerican`, `firstSeenAt`, `previouslySeenAt`

#### Three different numbers that all look like a price

Underdog publishes three sets, and they do not agree with each other. They are kept in separate columns and none of them is derived from another:

| column | what Underdog calls it | example |
|---|---|---|
| `higherPayoutMultiplier` | `payout_multiplier` - the multiplier shown on the pick | `1.11` |
| `higherAmericanPrice` / `higherDecimalPrice` | `american_price` / `decimal_price` | `+123` / `2.23` |
| `higherOddsAmerican` / `higherOddsDecimal` / `higherImpliedProbabilityPercent` | `odds.fantasy` | `+106` / `2.06` / `45` |

`odds.sportsbook` and `odds.prediction` were null on all 9,122 sides measured, so they are not columns.

### Leagues

`NFL`, `CFB` (College Football), `MLB`, `NHL`, `WNBA`, `TENNIS`, `MMA`, `CS` (Counter-Strike), `LOL`, `VAL` (Valorant), `F1SZN` (Formula 1, season-long). Counts on 2026-09-22: MLB 2,105 / NFL 1,432 / CFB 868 / CS 389 / WNBA 244 / Tennis 237 / MMA 102 / NHL 89 / Valorant 79 / LoL 40 / F1 22. Which leagues are on the board depends on the day; an empty board is Underdog's own answer and comes back as a free row, not as a failure.

That list is what Underdog's board carried on the day this Actor was built, not a promise about what Underdog offers. **Leave `leagues` empty and you get every league on the board, including any Underdog has added since.** Naming a league that is not in the list above stops the run before it asks Underdog anything, and that run is free.

### Input

Everything is optional. With an empty input the whole board comes back, up to `maxLines`.

| field | what it does |
|---|---|
| `leagues` | keep only these leagues. Empty = every league |
| `players` | keep a line only if the player's name contains one of these (partial, case-insensitive) |
| `stats` | keep a line only if the stat name or Underdog's stat code contains one of these |
| `teams` | keep a line only if the team, the opponent or the matchup title contains one of these |
| `keywords` / `keywordMatch` / `excludeKeywords` | keep or drop on any of the text columns; `keywordMatch` is `any` or `all` |
| `minPayoutMultiplier` / `maxPayoutMultiplier` | keep a line only if a side pays inside this range. 0 = no limit |
| `onlyBoostedLines` | only lines where a side pays something other than 1.0 |
| `onlyStandardLines` | only lines where every side offered pays 1.0 |
| `onlyBothSides` | drop lines that offer one side only |
| `startsWithinHours` | only games starting within this many hours. 0 = no limit |
| `maxLines` | how many rows this run may return (default 500) |
| `monitoringMode` | return only lines that changed since the last run with this same input |
| `resetMonitoringState` | forget what was already returned, so everything counts as new again |
| `onlyLineMoves` | monitoring only: drop changes where only the multiplier, price, odds or status moved |
| `minLineMove` | monitoring only: the line must have moved at least this far |
| `minPayoutMultiplierMove` | monitoring only: a payout multiplier must have moved at least this much |
| `useProxy` | use an Apify proxy and retry a refused request from another address (on by default) |

Filtering happens after the board is read, so it never costs an extra request. Filters keep rows whose value is unknown rather than dropping them, because unknown is not the same as small - except `teams`, where a row with no team and no matchup title (Underdog's season-long props) is dropped, since naming a team means you do not want those.

### Honest limits

- **The feed is one big request, and its speed varies a lot.** 16,026,326 bytes (1,421,724 over the wire with gzip). The same feed answered in 2.7s, 11.0s, 35.0s and 111.4s on 2026-09-22. Each attempt waits up to 180 seconds and the run tries up to 4 times, so a slow day costs time, not rows.
- **Pre-game only.** Underdog's live in-play lines need a logged-in account. This Actor never logs in, so it returns pre-game lines only: all 5,607 lines in the 2026-09-22 snapshot carried `live_event: false` and `line_type: balanced` (`npm test` recounts both from `test/fixtures/board-a-fingerprints.json`).
- **Team names are not in this feed.** Underdog publishes team ids and a matchup title. The title comes in two shapes: `Atlanta Falcons @ Green Bay Packers` and `Esport Academy Copenhagen vs 100 Thieves`. `A @ B` is away at home, so `teamName` and `opponentName` are filled in for those. For the `vs` shape it is not verified which side is home, so **those rows leave `teamName` and `opponentName` empty rather than guess** - `teamId`, `opponentTeamId`, `homeAway` and `gameTitle` are still there.
- **Some props have no game.** Underdog's season-long props (89 NHL lines on 2026-09-22) are attached to a season, not a match, so every game column is empty. Formula 1 season props have no opponent.
- **Some lines offer one side only.** 2,092 of 5,607 lines carried a single side - 2,076 Higher-only and 16 Lower-only - so the missing side's columns are empty, not zero.
- **A line Underdog gives no stable id, or repeats, is skipped rather than returned**, because there would be no safe way to tell it apart from another line on the next run. The run says how many were skipped; on 2026-09-22 it was 0 of 5,607.
- **Payout multipliers are Underdog's numbers, not a calculation.** They ranged from 0.60 to 19.82 on 2026-09-22, and 3,483 of 9,122 sides were the standard 1.0.
- **Monitoring memory is per input.** Two schedules with different filters keep separate memories. If two runs with the *same* input finish at the same instant, one can overwrite the other's memory - Apify key-value stores have no atomic update, so this cannot be fully prevented. The run says so in its log when it happens. Do not put the same input in two schedules that overlap.
- **A changed line that was not returned is not remembered.** If a run hits "Maximum lines to return" or the run's maximum total charge, or if a line changed but has not yet moved far enough for `minLineMove` / `minPayoutMultiplierMove` / `onlyLineMoves`, its earlier values are deliberately kept. So a line that moves 0.5 four times does reach a `minLineMove` of 1 - the movement adds up instead of being reset each run.
- **A changed line your other filters exclude *is* remembered, at its new value.** Leagues, players, stats, teams, keywords, multipliers and start time are about which lines you want at all, so widening one of them later does not dump the whole board on you as new.
- This is not advice about betting or investing. It returns what Underdog publishes, nothing more.

### Rows that are never charged

Every run that returns nothing says why, in one free row:

| `status` | meaning |
|---|---|
| `no-results` | Underdog answered correctly and its board is empty |
| `no-filter-match` | lines were read, the filters removed all of them |
| `no-line-move` | monitoring mode: every line read is unchanged |
| `limit-reached` | more lines matched than "Maximum lines to return" |
| `charge-limit-reached` | the run's maximum total charge was reached before all matching lines were returned |
| `blocked` | Underdog refused the request (403 / 429 / 503) from every address tried |
| `unreadable` | the feed could not be read, or its shape changed |
| `unsupported-input` | the input asks for something that cannot exist (an unknown league, two filters that contradict each other) - nothing is requested from Underdog |

`no-results`, `no-line-move` and `blocked` are deliberately three different things. A refused request is never reported as "there are no lines".

### Pricing

Two charges, and nothing else:

| event | price |
|---|---|
| `actor-start` - once per run in which Underdog answered with a board carrying at least one line | $0.01 |
| `line-scraped` - per line returned | $0.15 per 1,000 (less on paid Apify plans) |

Rows that explain why nothing came back are always free. A run is charged **nothing at all, not even the start**, when Underdog refused it, when it timed out, when the board came back empty, or when it was stopped before asking Underdog anything (an unsupported league, or two filters that contradict each other). Filters that simply match nothing do not make a run free - the board was read, so the $0.01 start applies.

In monitoring mode only the lines that changed are charged; there is no separate per-check fee. A run where nothing moved therefore costs $0.01 and returns one free row saying so. Following 20 lines every 5 minutes costs about $0.01 x 8,640 runs a month in start fees, plus the handful of rows that actually moved - decide the schedule with that in mind, and set the run's maximum total charge if you want a hard ceiling.

### How it reads Underdog

One request to Underdog's public pick'em feed, `https://api.underdogfantasy.com/v1/over_under_lines`. No login, no paywall, no bot check. `https://api.underdogfantasy.com/robots.txt` was fetched twice on 2026-09-22 and contains a single comment line with no rules at all.

The response is large (16,026,326 bytes; 1,421,724 over the wire with gzip) and its speed varies: 2.7s, 11.0s, 35.0s and 111.4s were all measured on 2026-09-22. Of 12 Apify datacentre addresses tested that day, 11 answered HTTP 200 and 1 answered 403, so a refused or timed-out request is retried up to four times - from a different address when a proxy is on - before the run gives up and writes a free `blocked` row.

### Unofficial. Public data only.

Not affiliated with, endorsed by or connected to Underdog Fantasy or any sportsbook. It reads only what Underdog publishes without a login, and returns no personal data: the people in the output are professional athletes, and the columns are their public playing statistics.

# Actor input Schema

## `leagues` (type: `array`):

Leave empty for every league on the board. On 2026-09-22 the board carried MLB 2105, NFL 1432, CFB 868, CS 389, WNBA 244, Tennis 237, MMA 102, NHL 89, Valorant 79, LoL 40 and F1 (season) 22 lines. Which leagues are live depends on the day.

## `players` (type: `array`):

Keep a line only if the player's name contains one of these (case-insensitive, partial). Example: Bijan Robinson, Mahomes.

## `stats` (type: `array`):

Keep a line only if the stat name or Underdog's stat code contains one of these (case-insensitive, partial). Examples: Receiving Yards, receiving\_yds, Home Runs, Kills on Maps 1+2. The board carried 108 distinct stats on 2026-09-22.

## `teams` (type: `array`):

Keep a line only if the player's team, the opponent, or the matchup title contains one of these (case-insensitive, partial). Examples: Packers, ATL @ GB, Fnatic.

## `maxLines` (type: `integer`):

How many rows this run may return. Reading the board is free; only the rows you receive are charged. When more lines match than this, a free row says how many were left, and the lines that were not returned are not remembered, so the next run offers them again.

## `monitoringMode` (type: `boolean`):

Off = return the board as it stands now. On = remember the exact line, payout multipliers, prices, odds and status already returned for this input and, on later runs, return a line again only when one of them has actually changed - with the previous value, the first value ever seen and the difference in the same row. Measured on 2026-09-22: 9 minutes apart, only 174 of 5,542 lines (3.1%) had changed; back-to-back runs on the same board changed 2 lines, then 1.

## `resetMonitoringState` (type: `boolean`):

Clear the remembered values for this exact input before the run, so every line counts as new again.

## `onlyLineMoves` (type: `boolean`):

Monitoring mode only. Drop changes where only the payout multiplier, price, odds or status moved, and keep the ones where the line itself moved (for example 24.5 to 25.5). Lines seen for the first time are kept. A change held back this way is not recorded as delivered, so the line comes back when its number does move.

## `minLineMove` (type: `number`):

Monitoring mode only. Keep a line only if it has moved at least this far from the value returned last time, in either direction - Underdog lines usually move in steps of 0.5 or 1. 0 = no limit. Lines with no previous value are kept, because unknown is not the same as unmoved. A line held back because it has not moved far enough is not recorded as delivered, so its movement keeps adding up until it passes this number.

## `minPayoutMultiplierMove` (type: `number`):

Monitoring mode only. Keep a line only if the Higher or Lower payout multiplier has moved at least this much since last time, in either direction. Multipliers move in small steps, so 0.05 is already a real move. 0 = no limit. A line held back because it has not moved far enough is not recorded as delivered, so its movement keeps adding up until it passes this number.

## `minPayoutMultiplier` (type: `number`):

Keep a line only if Higher or Lower pays at least this multiplier. 0 = no limit. On 2026-09-22 multipliers ran from 0.60 to 19.82, and 3,483 of 9,122 sides were the standard 1.0.

## `maxPayoutMultiplier` (type: `number`):

Keep a line only if Higher or Lower pays at most this multiplier. 0 = no limit.

## `onlyBoostedLines` (type: `boolean`):

Keep only lines where at least one side pays a multiplier other than the standard 1.0 - Underdog's alternate and boosted picks.

## `onlyStandardLines` (type: `boolean`):

Keep only lines where every side offered pays the standard 1.0 multiplier.

## `onlyBothSides` (type: `boolean`):

Drop lines that offer one side only. On 2026-09-22, 2,092 of 5,607 lines offered a single side - 2,076 Higher only and 16 Lower only.

## `startsWithinHours` (type: `integer`):

Keep a line only if its game starts within this many hours from now. 0 = no limit. Lines with no start time (Underdog's season-long props, for example NHL regular-season totals) are kept.

## `keywords` (type: `array`):

Keep a line only if one (Any) or all (All) of these appear in the player, team, opponent, matchup, stat name, prop title, league, competition or position.

## `keywordMatch` (type: `string`):

Any = at least one keyword. All = every keyword.

## `excludeKeywords` (type: `array`):

Drop a line if any of these appear in the same fields.

## `useProxy` (type: `boolean`):

On by default. Underdog's public feed answered HTTP 200 from 11 of 12 Apify datacentre addresses tested on 2026-09-22 and 403 from one, so a refused request is retried from a different address. Turning this off makes the run use Apify's shared address directly, which is refused more often.

## Actor input object example

```json
{
  "leagues": [],
  "players": [],
  "stats": [],
  "teams": [],
  "maxLines": 500,
  "monitoringMode": false,
  "resetMonitoringState": false,
  "onlyLineMoves": false,
  "minLineMove": 0,
  "minPayoutMultiplierMove": 0,
  "minPayoutMultiplier": 0,
  "maxPayoutMultiplier": 0,
  "onlyBoostedLines": false,
  "onlyStandardLines": false,
  "onlyBothSides": false,
  "startsWithinHours": 0,
  "keywords": [],
  "keywordMatch": "any",
  "excludeKeywords": [],
  "useProxy": true
}
```

# Actor output Schema

## `results` (type: `string`):

One row per Underdog Fantasy Higher/Lower pre-game line: the league, the player with Underdog's id, position and jersey number, the team and opponent ids, the matchup with its start time, status and venue, the stat and Underdog's stat code, the line itself, and for each side offered the payout multiplier, the American and decimal price, Underdog's own American and decimal odds and implied probability, the side's status and the pick text as Underdog writes it. In monitoring mode each row also carries the value returned last time, the first value ever seen for that line, both differences, and which of line, multiplier, price, odds or status changed. Lines that could not be read, an empty board, filters that matched nothing, a run with no change and a run that hit a limit come back as their own rows and are not charged. Live in-play lines need a logged-in account and are not included.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/underdog-player-props-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/underdog-player-props-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call neverempty/underdog-player-props-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/underdog-player-props-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/LzLrlp13d3GVKdMBa/builds/FyonhHezvjPCtoOUG/openapi.json
