Quick answer: you almost never need to parse Steam's HTML. Prices come from store.steampowered.com/api/appdetails, reviews come from store.steampowered.com/appreviews/{appid}?json=1, and live player counts come from the official GetNumberOfCurrentPlayers endpoint. All three return JSON, none require an API key, and together they cover 90% of what people scrape Steam for. The store page itself is only worth fetching for the stuff these endpoints omit, like user tags and follower counts.
Steam is one of those targets, like Shopify, where the structured data is already sitting behind a URL and most people build a browser-automation scraper anyway. We covered the same pattern for e-commerce in scraping Shopify stores. Let's do the Steam version.
The Three Endpoints That Matter
| Data | Endpoint | Key needed |
|---|---|---|
| Price, name, genres, release date | store.steampowered.com/api/appdetails?appids={id} |
No |
| Reviews and review summary | store.steampowered.com/appreviews/{id}?json=1 |
No |
| Current player count | api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid={id} |
No |
| Full list of every appid | api.steampowered.com/ISteamApps/GetAppList/v2/ |
No |
The first two are what I'd call semi-official: they power the store frontend, Valve has kept them stable for years, and they're used by half the game-analytics industry, but they're not formally documented. The last two are official Steamworks Web API endpoints and are documented.
Everything keys off the appid. Counter-Strike 2 is 730, Elden Ring is 1245620. You can grab the full appid-to-name mapping once from GetAppList and cache it; it's a single large JSON response covering the whole catalog.
Prices with appdetails
One request per game gets you the whole storefront record:
https://store.steampowered.com/api/appdetails?appids=1245620&cc=us&l=en
The response nests everything under the appid:
{
"1245620": {
"success": true,
"data": {
"name": "ELDEN RING",
"is_free": false,
"price_overview": {
"currency": "USD",
"initial": 5999,
"final": 3599,
"discount_percent": 40
},
"genres": [{ "description": "Action" }],
"release_date": { "date": "24 Feb, 2022" }
}
}
}
Three details that trip people up:
- Prices are integers in cents, so
5999means $59.99. Divide by 100 before display. - The
ccparameter controls regional pricing.cc=usandcc=brreturn different currencies and different numbers. If you're building a regional price tracker, you loop over country codes, not games. - Free games have no
price_overviewat all. Checkis_freebefore reaching for the price block, or your parser dies on Dota 2.
There's one batching trick worth knowing. Passing multiple appids normally fails, but it works if you restrict the response with filters=price_overview:
https://store.steampowered.com/api/appdetails?appids=730,570,1245620&cc=us&filters=price_overview
That's how you price a watchlist of 100 games in a handful of requests instead of 100. For tracking discounts over time, store each day's final and discount_percent with a timestamp and diff the values; the approach in monitoring competitor pricing changes maps onto Steam sales directly.
Reviews with appreviews
The reviews endpoint is the best-designed of the three. It gives you both the aggregate summary and the individual reviews, with cursor pagination:
import requests, time, urllib.parse
appid = 1245620
cursor = "*"
reviews = []
while True:
r = requests.get(
f"https://store.steampowered.com/appreviews/{appid}",
params={
"json": 1,
"filter": "recent",
"language": "english",
"num_per_page": 100,
"cursor": cursor,
},
timeout=30,
)
data = r.json()
batch = data.get("reviews", [])
if not batch:
break
reviews.extend(batch)
cursor = data["cursor"]
time.sleep(1.5)
print(len(reviews))
The first response also includes query_summary with total_positive, total_negative, and the human-readable review_score_desc ("Very Positive" and friends). If all you want is the sentiment ratio, one request per game is enough; skip the pagination entirely.
Each review object carries more than the text: playtime at review time, playtime forever, whether the copy was purchased on Steam, and votes_up. That steam_purchase flag matters if you're doing sentiment analysis, since key-activated review bombs are a known pattern and you may want to filter them out.
Two honest warnings. First, the cursor must be URL-encoded if you build URLs by hand (requests handles it for you via params). Second, pulling every review for a game with 700,000 of them is 7,000 requests, so decide up front whether "recent 500" answers your question, because it usually does.
Player Counts
Current concurrent players is a one-liner against the official API:
https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=730
{ "response": { "player_count": 1053472, "result": 1 } }
The catch: this is a snapshot, not a history. Valve doesn't expose historical concurrents, which is why SteamDB and SteamCharts exist; they've been polling this endpoint for years. If you need history going forward, do what they did: poll every 15 to 30 minutes and store the results. If you need history going backward, you don't scrape it (SteamDB explicitly prohibits scraping their site), you use their pages manually or find a licensed data provider.
What the Endpoints Omit, and Fetching the Store Page
Some genuinely useful signals never show up in appdetails:
- User-defined tags with vote weights ("Soulslike", "Roguelike Deckbuilder"), which are far better genre signals than the official genre list
- Follower counts on the community hub
- The recent-reviews breakdown shown in the sidebar
For those, you fetch the actual store page. And here Steam does fight back a little: mature-rated games redirect to an age gate unless you send cookies like birthtime and wants_mature_content, and datacenter IPs see more friction than residential ones. If your poller starts getting redirects or 429s from server infrastructure, that's IP reputation, not broken code.
This is the one part of the pipeline where I'd hand off to a fetch API. The link.sc fetch API handles the blocking side and can extract straight to a schema, so you skip selector maintenance on a page Valve redesigns periodically:
curl -X POST https://api.link.sc/v1/fetch \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://store.steampowered.com/app/1245620/",
"format": "json",
"schema": {
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}},
"recent_review_summary": {"type": "string"}
}
}
}'
Use it for the store page only. Hitting JSON endpoints through anything heavier than plain HTTP is wasted money.
Rate Limits and Playing Fair
None of this is documented with hard numbers, but the community consensus, which matches my experience, is that appdetails tolerates roughly 200 requests per 5 minutes per IP before returning 429s. The reviews endpoint is more generous. The official Steamworks API allows 100,000 calls per day.
So pace yourself: one request per second is sustainable everywhere, cache appdetails responses for at least a day (prices change on sale boundaries, not hourly), and set a descriptive User-Agent. These endpoints serve data Steam already shows every anonymous visitor, which puts market research on reasonably solid ground, but the usual caveats about terms of service and downstream use apply; the longer discussion is in is web scraping legal.
The short version: three JSON endpoints cover prices, reviews, and players. Poll politely, cache aggressively, and only touch the HTML for tags and follower counts.
Need Steam store pages fetched without the age gates and IP blocks? Sign up for link.sc and get 500 free credits a month.