Four undocumented Steam Store API behaviors every game directory builder should know

typescript dev.to

If you're pulling game data from Steam to populate a directory, the public Steam Web API will surprise you in ways that aren't in the developer documentation. I ran into four of them building Find Games Like, the indie game recommender in my three-site programmatic experiment. None caused permanent damage, but each cost debugging time I wouldn't have spent with a more complete write-up somewhere.

Every response wraps in a success envelope — and HTTP 200 doesn't mean success

The standard game metadata endpoint:

https://store.steampowered.com/api/appdetails?appids=578080
Enter fullscreen mode Exit fullscreen mode

Returns HTTP 200 regardless of whether the appid is valid, the game exists, or the title has been removed. The actual result is nested under an appid key:

{"578080":{"success":true,"data":{"name":"PLAYERUNKNOWN'S BATTLEGROUNDS",...}}}
Enter fullscreen mode Exit fullscreen mode

When the appid is invalid or the game has been delisted:

{"578080":{"success":false}}
Enter fullscreen mode Exit fullscreen mode

The correct check is entry?.success ? entry.data : null. If you check entry?.data directly, you get undefined on failure, which looks like a missing field in your ETL rather than a failed lookup. My TypeScript client:

const data = (await res.json()) as Record<string, { success: boolean; data: SteamAppDetail }>;
const entry = data[String(appid)];
return entry?.success ? entry.data : null;
Enter fullscreen mode Exit fullscreen mode

HTTP error handling alone is not enough here. You need the explicit success check because 200 + success: false is a valid failure state.

The cc=us&l=en parameters control pricing and text consistency

Without explicit country and language parameters:

/api/appdetails?appids=578080
Enter fullscreen mode Exit fullscreen mode

Steam returns pricing in the currency of your server's IP geolocation. GitHub Actions runners are in US regions, so production works fine. But local development from Japan returns JPY pricing. Development from Germany returns EUR. The price_overview.final_formatted string you're storing in Turso ends up different depending on where the job ran.

Adding cc=us&l=en:

/api/appdetails?appids=578080&cc=us&l=en
Enter fullscreen mode Exit fullscreen mode

Fixes this: you consistently get USD prices formatted as "$11.99" and English strings in all description fields. Without it, a game refreshed in one country and later refreshed in another produces a spurious "price changed" update in the Turso upsert, even though the price hasn't actually changed.

l=en also matters for short_description and about_the_game — games with non-English primary locales return their native language by default. For a directory serving an English-speaking audience, you want English descriptions even when the game is French or Japanese.

The review summary endpoint is at a completely different path

The appdetails endpoint returns metadata but no review data — no ratings, no review counts, no "Very Positive" or "Mixed" label. For that, you need a separate call:

https://store.steampowered.com/appreviews/{appid}?json=1&filter=summary&purchase_type=all&num_per_page=0&language=all
Enter fullscreen mode Exit fullscreen mode

This is at store.steampowered.com/appreviews/, not store.steampowered.com/api/ and not api.steampowered.com. The path structure is inconsistent with the rest of the Store API, so if you're reading URL patterns to figure out where other endpoints might live, this one breaks the pattern.

Parameters that matter:

  • json=1 — returns JSON instead of an HTML page
  • filter=summary — returns aggregate stats, not individual review text
  • num_per_page=0 — returns no individual reviews (summary only)
  • purchase_type=all — includes all buyers, not just verified-purchase reviewers
  • language=all — aggregates across all review languages

The response has a different success field too. appdetails uses success: boolean. This endpoint uses success: 1 — an integer, not a boolean:

const data = (await res.json()) as {
  success: 1 | number;
  query_summary?: { total_reviews?: number; review_score_desc?: string; ... };
};
if (data.success !== 1 || !data.query_summary) return null;
Enter fullscreen mode Exit fullscreen mode

I treat review fetch failures as non-fatal in the ETL. The game row is written from appdetails first; review stats are added separately. If the review call fails with a 429 or 5xx, the ETL logs it and continues. The game still has a page; it shows no review data until the next run.

DLC and soundtrack appids pollute the full app list

Steam's GetAppList endpoint returns every entry in the catalog:

https://api.steampowered.com/ISteamApps/GetAppList/v2
Enter fullscreen mode Exit fullscreen mode

That's roughly 200,000+ entries — games, DLC packs, official soundtracks, game demos, tools, dedicated server packages. All of them share the same numeric appid namespace.

If you send appdetails requests for these indiscriminately, you get back valid-looking responses. A DLC appid returns a success: true object with a data block, but data.type will be "dlc" and the name will be something like "Fallout 4 - Wasteland Workshop." A soundtrack appid returns type: "music". A dedicated server returns type: "tool".

The Find Games Like ETL avoids this by using a curated seed-appids.json — a hand-maintained list of game appids — rather than pulling from GetAppList. If you're building from the full app list instead, filter on data.type === "game" before inserting. Without that filter, your directory ends up with DLC entries that have no "similar games" context, no meaningful genre data, and no cover art in the expected dimensions.


The TypeScript interface design for these clients — specifically how optional fields are modeled and where null checks live — comes directly from encountering all four of these in production ETL runs.

Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Source: dev.to

arrow_back Back to Tutorials