KhueApps
Home/Python/Track Steam Sales with Python: Store API Polling and Alerts

Track Steam Sales with Python: Store API Polling and Alerts

Last updated: October 07, 2025

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

  1. Install dependencies
  • pip install requests
  1. Pick app IDs
  • Add app IDs to the APPS dict. See FAQ for finding IDs.
  1. Set region and language
  • Edit CC to your country code and LANG to your language. These impact currency and some text fields.
  1. Run once interactively
  • python steam_sales_tracker.py
  • Confirm it prints pricing and writes last_seen_prices.json
  1. Schedule it
  • Linux/macOS: use cron, e.g., run hourly.
  • Windows: use Task Scheduler to run the script on a schedule.
  1. 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.

Series: Automate boring tasks with Python

Python