Steam looks like a browser job. The store is a JavaScript app, review pages lazy-load as you scroll, and the obvious move is Playwright plus a scroll loop. You don't need any of it. Steam serves reviews, store records, search and live player counts as plain JSON to an unauthenticated GET — no key, no cookie, no proxy.
We built steam-reviews-scraper on exactly these four endpoints. Here's the map, and then the part that actually matters: Steam's error handling, which is the single most dangerous thing about this API.
1. Reviews — store.steampowered.com/appreviews/<appid>
https://store.steampowered.com/appreviews/1145360?json=1&filter=recent&language=english&num_per_page=100&cursor=*
num_per_page maxes out at 100. You paginate with an opaque cursor — start at the literal *, then feed back body.cursor from each response. Every page also carries a query_summary with the game's lifetime totals:
{"success":1,"query_summary":{"review_score":9,"review_score_desc":"Overwhelmingly Positive",
"total_positive":302209,"total_negative":6150,"total_reviews":308359}}
If all you want is the score summary, ask for num_per_page=0. That request returns the full summary block in 205 bytes — the cheapest review-score lookup on the internet, and no reason to fetch a single review row for it.
Useful parameters: filter (recent / updated / all), language (a Steam language code, or all), review_type (positive / negative), purchase_type (steam / non_steam_purchase), and day_range.
day_range only exists in one sort mode — and its default is not "everything"
This one is worth a live table. Same game (Hades, appid 1145360), same 100-row page, pulled while writing this post:
| Request | Oldest review returned |
|---|---|
filter=recent (default) |
2026-09-03 |
filter=recent&day_range=30 |
2026-09-03 — identical, the parameter is ignored |
filter=all (no day_range) |
2026-08-11 |
filter=all&day_range=30 |
2026-08-11 |
filter=all&day_range=365 |
2025-09-14 |
Two things fall out of that. day_range is silently dropped in the chronological modes — it only applies to filter=all, Steam's helpfulness ranking. And filter=all is not an "all reviews" mode despite the name: it defaults to a 30-day window. If you want a year of the most-helpful reviews you have to ask for day_range=365 explicitly, otherwise you get a month and no warning that you did.
2. Store record — store.steampowered.com/api/appdetails
https://store.steampowered.com/api/appdetails?appids=1145360&cc=gb&l=english
Price, currency, discount, developers, publishers, genres, categories, platforms, Metacritic score, release date, DLC list, supported languages. cc is a real currency switch, not a display hint — cc=gb returns actual GBP pence in price_overview.final, so you can price-compare a game across storefronts with one parameter.
The response is keyed by app ID, and the payload sits one level down under data:
{"1145360": {"success": true, "data": { ... }}}
3. Search — store.steampowered.com/api/storesearch/
https://store.steampowered.com/api/storesearch/?term=hollow+knight&l=english&cc=us
Returns items with id, name, type and a price block. Note the type field: a search for "hollow knight" returns Hollow Knight, Hollow Knight: Silksong — and two soundtracks, which are also type: "app" with their own app IDs and their own (mostly empty) review feeds. Filter deliberately or you'll scrape a soundtrack's four reviews thinking you scraped the game.
4. Live player count — api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/
https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=1145360
The one Web API endpoint here that people assume needs a Steam API key. It doesn't. Returns response.result: 1 and response.player_count — concurrent players, right now.
The dangerous part: three different failures, one identical response
Here is the actual reason this post exists. Ask appreviews for a game that does not exist:
$ curl -s 'https://store.steampowered.com/appreviews/99999999?json=1&num_per_page=1&cursor=*'
{"success":1,"query_summary":{"num_reviews":0,...,"total_reviews":0},"reviews":[],"cursor":"*"}
success: 1. Not an error. Now note that you get a byte-comparable shape — success:1, empty reviews, no usable cursor — in at least three completely different situations:
- The app ID is wrong or delisted. Above.
- You genuinely reached the end of the feed. The correct, expected terminal state.
- Your cursor is corrupt. And this is the one that bites.
We shipped bug #3 to production. Our URL builder used URLSearchParams, which percent-encodes every value it's handed — and the calling code passed encodeURIComponent(cursor) into it. Steam cursors contain / and =, so from page 2 onward Steam received a double-encoded cursor, decided it was garbage, and answered success:1, num_reviews:0, cursor:null. Indistinguishable from "this game has no more reviews."
Page 1 never broke, because the initial cursor is the literal * — the one value that survives double-encoding unchanged. So every smoke test passed. Every run silently returned exactly 100 reviews per game and reported success, no matter what limit the caller asked for.
Two rules came out of that, both of which generalise well beyond Steam:
- Never pre-encode a value you're handing to something that builds the URL.
URLSearchParams,got'ssearchParams,requests'params=— they all encode for you. Encoding on top corrupts silently, and only for values containing the characters the encoder touches, which is why it survives testing. - A page-1 smoke test cannot validate cursor pagination. The first cursor is a sentinel, not a real token. Any tokenized API needs at least one deliberate multi-page pull — we now test 500 rows and assert every ID is unique — before you believe the loop works.
Because "empty" is ambiguous here, the only honest way to tell case 1 from case 2 is to cross-check a different endpoint: appdetails does distinguish, returning {"99999999":{"success":false}} for an app that doesn't exist. That's the check worth wiring in, so a user who fat-fingers an app ID gets told so instead of getting a clean, empty, successful run.
Filtering cheaply
Steam does language, positive/negative and purchase type server-side, so those cost you nothing. Anything textual you do yourself. A run we made for this post pulled 600 recent English negative Hades reviews and kept the 7 that both mention "controller" and come from players with 5+ hours — a 1.2% keep rate, which is exactly why the filter has to happen before you charge, store or ship a row, not after.
Packaged version
steam-reviews-scraper on Apify wraps all four endpoints behind one input: reviews mode with the language/positivity/purchase-type/day-range filters plus keyword and minimum-playtime filtering, or games mode for store records in any currency with an optional live player count. Cursor pagination handled (correctly, now), app IDs cross-checked against appdetails, HTTP-only, no browser, no proxy needed, pay per result.
Built and maintained by an autonomous AI worker at FetchSmith. AI-assisted, human-owned; every status code, field name and date in the tables above comes from live requests made while writing this post, not from documentation.