Overview
This guide shows how to use Python to track Steam game discounts by polling the Steam Store API for app pricing and discount fields. You will:
- Query price and discount_percent for selected app IDs
- Persist last-seen prices to detect new sales
- Print alerts (extendable to email/Slack)
- Schedule the script on a timer or with cron
Fits: Category: Python. Collection: Automate boring tasks with Python.
Quickstart
- Python 3.9+
- pip install requests
- Choose a few app IDs to monitor (see FAQ if you need IDs)
- Run the script; it will alert when a discount appears or price drops
Minimal working example
# file: steam_sales_tracker.py
import json
import time
from pathlib import Path
from typing import Dict, Optional
import requests
STORE_URL = "https://store.steampowered.com/api/appdetails"
STATE_FILE = Path("last_seen_prices.json")
# Map the app IDs you care about
APPS = {
620: "Portal 2",
892970: "Valheim",
281990: "Stellaris",
}
# Country code and language affect currency and formatting
CC = "us" # set to your region, e.g., "gb", "eu", "br"
LANG = "en"
session = requests.Session()
session.headers.update({
"User-Agent": "steam-sales-tracker/1.0 (+python-requests)"
})
def load_state() -> Dict[str, dict]:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text())
return {}
def save_state(state: Dict[str, dict]) -> None:
STATE_FILE.write_text(json.dumps(state, indent=2, sort_keys=True))
def cents_to_amount(cents: Optional[int]) -> Optional[float]:
if cents is None:
return None
return round(cents / 100.0, 2)
def fetch_app(appid: int) -> Optional[dict]:
"""Fetch app details for one appid. Returns dict with key fields or None."""
try:
r = session.get(
STORE_URL,
params={"appids": appid, "cc": CC, "l": LANG, "filters": "price_overview,name,is_free"},
timeout=10,
)
r.raise_for_status()
payload = r.json()
entry = payload.get(str(appid), {})
if not entry.get("success"):
return None
data = entry.get("data", {})
pov = data.get("price_overview")
info = {
"appid": appid,
"name": data.get("name") or APPS.get(appid, str(appid)),
"currency": (pov or {}).get("currency"),
"initial": (pov or {}).get("initial"), # cents
"final": (pov or {}).get("final"), # cents
"discount_percent": (pov or {}).get("discount_percent", 0),
"is_free": data.get("is_free", False),
}
return info
except requests.RequestException:
return None
def diff_and_alert(prev: dict, curr: dict) -> None:
# Print alerts for new discount or price drop
was_final = prev.get("final")
was_disc = prev.get("discount_percent", 0)
now_final = curr.get("final")
now_disc = curr.get("discount_percent", 0)
# Only alert when we have a price to compare
if now_final is None and not curr.get("is_free"):
return
# New or increased discount
if (now_disc or 0) > (was_disc or 0):
amount = cents_to_amount(now_final)
print(f"SALE: {curr['name']} -{now_disc}% now {curr.get('currency', '')} {amount}")
return
# Price dropped without discount field (rare but possible)
if was_final is not None and now_final is not None and now_final < was_final:
print(
f"PRICE DROP: {curr['name']} from {curr.get('currency', '')} {cents_to_amount(was_final)} "
f"to {curr.get('currency', '')} {cents_to_amount(now_final)}"
)
def poll_once() -> None:
state = load_state()
new_state = dict(state) # copy
for appid, label in APPS.items():
info = fetch_app(appid)
if not info:
print(f"WARN: failed to fetch appid={appid}")
continue
key = str(appid)
prev = state.get(key, {})
diff_and_alert(prev, info)
# Update stored state with minimal fields
new_state[key] = {
"name": info["name"],
"currency": info["currency"],
"final": info["final"],
"discount_percent": info["discount_percent"],
"ts": int(time.time()),
}
time.sleep(0.4) # be polite to the API
save_state(new_state)
if __name__ == "__main__":
# Simple loop every hour; replace with cron/Task Scheduler in production
while True:
print("Polling Steam prices...")
poll_once()
print("Done. Sleeping 3600s")
time.sleep(3600)
What it does:
- Fetches pricing for your app list in one region
- Writes last_seen_prices.json
- Alerts when a discount appears or the price declines
Step-by-step
- Install dependencies
- pip install requests
- Pick app IDs
- Add app IDs to the APPS dict. See FAQ for finding IDs.
- Set region and language
- Edit CC to your country code and LANG to your language. These impact currency and some text fields.
- Run once interactively
- python steam_sales_tracker.py
- Confirm it prints pricing and writes last_seen_prices.json
- Schedule it
- Linux/macOS: use cron, e.g., run hourly.
- Windows: use Task Scheduler to run the script on a schedule.
- Extend alerts (optional)
- Replace print(...) with your notifier: email, Slack, or desktop notifications.
Customization tips
- Track more fields: Add filters to the request or parse additional data fields (e.g., package/bundle types may differ).
- Monitor a larger list: Keep APPS in a JSON file and load it at startup.
- Multi-currency check: Loop over several CC values to compare regions.
Understanding key fields
- price_overview.initial: Original price (in minor units, e.g., cents)
- price_overview.final: Current price after discount (in minor units)
- price_overview.discount_percent: Percentage off (0 when no sale)
- currency: Currency code (e.g., USD)
- is_free: True for free-to-play titles
Pitfalls and how to handle them
- Missing price_overview: Free apps, demos, and some DLC may not have this field. The code tolerates None values.
- Regional pricing: Prices and discounts vary by country. Use the CC parameter and track per-region state if needed.
- Transient errors: Network and HTTP errors happen. Use timeouts, retries with backoff if necessary.
- Inconsistent discounts: Some discounts are package/bundle-only and may not surface via the app endpoint you query.
- Currency rounding: We divide cents by 100. Some currencies have different minor units; use the currency code and avoid assuming decimals if you need full accuracy.
- Politeness: Add small delays between requests and avoid aggressive polling. Hourly is plenty for sales.
Performance notes
- Reuse connections: A requests.Session cuts TLS setup overhead.
- Batch cadence: For dozens of apps polled hourly, simple sequential requests with a short sleep are fine.
- Concurrency: For hundreds of apps, use ThreadPoolExecutor with a small pool (e.g., 5–10) plus a global rate limit.
- Caching: Persist last-seen values and skip alerts unless fields change. Consider hashing responses to avoid disk writes when unchanged.
- Backoff: If you get errors or timeouts, exponentially back off and try later.
Example using simple concurrency:
from concurrent.futures import ThreadPoolExecutor, as_completed
appids = list(APPS.keys())
with ThreadPoolExecutor(max_workers=8) as ex:
futures = {ex.submit(fetch_app, appid): appid for appid in appids}
for fut in as_completed(futures):
info = fut.result()
# handle info as in poll_once()
Testing your tracker
- Start with 2–3 known titles; verify at least one has a discount during a sale period.
- Temporarily force a change by editing last_seen_prices.json to simulate a previous price; ensure the script prints an alert.
- Switch CC to a different region and confirm currency/values change accordingly.
Automating notifications (idea starter)
- Email: smtplib with an app password; send when diff_and_alert triggers.
- Chat: Use a webhook client library to post a one-line message per alert.
- Desktop: On macOS use osascript, on Windows use Toast notifications.
Keep messages short and include game name, percent off, and final price.
Maintenance checklist
- Review the APPS list periodically
- Keep Python and requests updated
- Monitor logs for repeated failures
- Adjust polling frequency during major sale events
FAQ
Q: How do I find a Steam app ID? A: Visit a game’s store page; the numeric segment in the URL path is the app ID. Add that integer to APPS.
Q: Can I track my whole wishlist? A: Export your wishlisted app IDs and feed them into APPS (or load from a file). The example focuses on polling known IDs.
Q: Why do prices differ from my local storefront? A: The CC parameter controls region/currency. Use your correct country code and keep it consistent across runs.
Q: How often should I poll? A: Hourly is typically enough. Steam sales don’t change minute-by-minute; be considerate with request volume.