statshawk
Guides

Building a prop-analysis bot

paid

Call /analysis/player-prop with a person id, read the card, act on it.

The analysis endpoint returns a person-keyed prop card: the resolved person, the requested measure, the per-game values from their in-scope appearances, windowed averages, and hit rates against your line. You supply the person id, stat, line, and optional competition/season scope; the API does the math.

If you only have a name, resolve it first with /v1/persons?q=Jayson%20Tatum and pass the returned per_… id into the analysis call.

The call

call.sh
curl -H "X-API-Key: $STATSHAWK_KEY" \
  "https://api.statshawk.ai/v1/analysis/player-prop\
?person_id=per_3f9kq0v2&stat=pts&line=27.5&competition=nba"
Run /analysis/player-prop in the playground →

Scope and stat selection:

  • stat: the measure to analyse. The canonical form is phase-qualified (pitching.so, passing.yards) to disambiguate a measure that exists in more than one phase; a bare key (pts) works when it's unambiguous, and friendly aliases are accepted. An unknown stat returns 400 UNKNOWN_STAT listing the valid keys.
  • competition: slug (nba) or minted comp_… id. When you pass competition and omit season/edition/from/to, the window defaults to that competition's latest edition with at least one final contest, so in-season you get current-season numbers without hardcoding a year.
  • season, edition, from, to: supply any of these to opt out of the default edition window.
  • stage: by default the card only includes regular-season (plus unknown-stage) games, the same window season stats use. Pass stage=all to include every stage, playoffs and preseason included.
  • line: omit it if you only need the game log and averages; hit_rates is only built when a line is supplied.

The response

The data object is a PlayerPropCard:

PlayerPropCard
{
  "person": {
    "id": "per_3f9kq0v2",
    "bio": {
      "full_name": "Jayson Tatum",
      "display_name": "Jayson Tatum",
      "position": "SF"
    }
  },
  "stat": "pts",
  "game_log_detail": [
    {
      "value": 31,
      "contest_id": "cst_8m2vq4x1",
      "kickoff": "2026-01-14T00:30:00Z",
      "opponent": { "team_id": "team_9x1mq2v4", "name": "Miami Heat" },
      "home": true
    },
    {
      "value": 24,
      "contest_id": "cst_7k1nq0v2",
      "kickoff": "2026-01-16T02:00:00Z",
      "opponent": { "team_id": "team_2b8kq0v7", "name": "Denver Nuggets" },
      "home": false
    }
  ],
  "games": 72,
  "averages": {
    "season": 28.1,
    "last_5": 31.2,
    "last_10": 29.4,
    "home": 29.0,
    "away": 27.2,
    "counts": { "season": 72, "last_5": 5, "last_10": 10, "home": 36, "away": 36 }
  },
  "hit_rates": {
    "season": { "line": 27.5, "hits": 44, "games": 72, "over_rate": 0.611 },
    "last_5": { "line": 27.5, "hits": 4, "games": 5, "over_rate": 0.8 },
    "last_10": { "line": 27.5, "hits": 7, "games": 10, "over_rate": 0.7 },
    "home": { "line": 27.5, "hits": 24, "games": 36, "over_rate": 0.667 },
    "away": { "line": 27.5, "hits": 20, "games": 36, "over_rate": 0.556 }
  },
  "context": {
    "sport": "basketball",
    "rest": {
      "back_to_back": { "games": 12, "average": 26.1 },
      "rested": { "games": 60, "average": 28.5 }
    }
  }
}

Key fields:

FieldWhat it tells you
hit_rates.season.over_rateSeason hit rate over the supplied line (hits / games; a hit is a game that strictly cleared the line)
hit_rates.last_5 / .last_10The same hit rate over the trailing 5 / 10 games: where the recency signal lives
hit_rates.home / .awayThe venue split of the same hit rate, each with its own games denominator
averagesWindowed means: season, last_5, last_10, and the home/away split
averages.countsThe achieved game count behind each windowed average, e.g. early in a season last_5 may be backed by fewer than 5 games, so check the count before trusting the average
gamesNumber of games included in the denominator
game_log_detailThe chronological per-game log (last_5/last_10 are its tail): each entry carries the value plus the contest it came from: contest_id, kickoff, the named opponent, and the home side
person.bioThe canonical person profile resolved from the supplied person_id
contextSport-tagged analysis context, when a registered provider produced it (see below); absent from the JSON otherwise, never null

hit_rates is null when no line was passed.

Sport context

When the analyzed stat's sport and phase have a registered context provider, the card carries a context object tagged with a sport discriminator:

  • Baseball, batting stats: platoon, the batter's per-game splits by the opposing starter's throwing hand (vs_left / vs_right, each { games, average }), assembled from the matchup data behind the card's games. Served only when both sides have at least one game.
  • Basketball and hockey stats: rest, back-to-back vs rested splits (back_to_back / rested, each { games, average }). A back-to-back is a game whose kickoff is more than 12 and at most 36 elapsed hours after the previous one; longer gaps are rested, and gaps of 12 hours or less (plus the first in-scope game and games with no known kickoff) carry no rest signal and land in neither bucket. Served only when both buckets have at least one game.

A card whose sport/phase has no provider (football, soccer, baseball pitching, …) simply omits the field: branch on its presence and the sport tag.

A 20-line Python script

analyze.py
import os, requests

KEY = os.environ["STATSHAWK_KEY"]
BASE = "https://api.statshawk.ai/v1"

def analyze(competition: str, player: str, stat: str, line: float) -> dict:
    person = requests.get(
        f"{BASE}/persons",
        headers={"X-API-Key": KEY},
        params={"q": player, "limit": 1},
        timeout=10,
    ).json()["data"]["items"][0]

    r = requests.get(
        f"{BASE}/analysis/player-prop",
        headers={"X-API-Key": KEY},
        params={
            "person_id": person["id"],
            "stat": stat,
            "line": line,
            "competition": competition,
        },
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["data"]

card = analyze("nba", "Jayson Tatum", "pts", 27.5)

season = card["hit_rates"]["season"]
recent = card["hit_rates"]["last_10"]
verdict = "LEAN OVER" if recent["over_rate"] >= 0.6 and card["averages"]["last_5"] > season["line"] else "PASS"

print(f"{card['person']['bio']['full_name']} | line {season['line']} pts")
print(f"Season: {season['over_rate']:.0%}  |  L10: {recent['over_rate']:.0%}  |  L5 avg: {card['averages']['last_5']:.1f}")
print(f"Verdict: {verdict}")

Scanning a whole slate

If you're evaluating one line, player-prop is the tool. If you're scanning tonight's slate for the best candidates, don't loop it over every player, call /v1/analysis/stat-board once instead:

stat_board.sh
curl -H "X-API-Key: $STATSHAWK_KEY" \
  "https://api.statshawk.ai/v1/analysis/stat-board\
?competition=mlb&date=2026-07-07&stat=batting.h"

The board ranks the day's slate by over-rate over a recent-game window (window, default 20 games) and returns two lists: recommended (entries with at least min_games games in the window, default 10) and full_slate (everyone, up to limit). date is interpreted in the competition's local scoreboard timezone: US Eastern for MLB. Same 10× weight and paid tier as player-prop, but one call replaces dozens.

On this page