# WOK API client for Python

Dependency-free synchronous client for the
[WOK Steam API](https://woksteamapi.com/docs). It is distributed as the single
`wok_api` module and uses only the Python standard library at runtime.

## Requirements

- Python 3.10 or newer
- A WOK API key

For development, the Free Developer plan provides 1,000 requests per 30-day
quota window, a 100-request daily guard and 30 requests per minute. Create a key at
<https://woksteamapi.com/pricing>.
Free access requires current membership in the WOK API Discord server through
the Discord account linked to your WOK account.

Keep the API key in a server-side environment variable or secret store. Do not
commit it to source control or send it to browser clients.

## Install

The examples below target WOK's downloadable **1.3.0** SDK. The package registry
can lag this source: `python -m pip install wok-api` selects the PyPI release,
not necessarily the version documented here. Use the versioned WOK-hosted wheel
when you need these methods. No extra runtime packages are required.

Download the wheel, its build manifest and checksum list over HTTPS, then verify
the files you downloaded before installing:

```sh
curl --fail --remote-name https://woksteamapi.com/sdk/releases/1.3.0/wok_api-1.3.0-py3-none-any.whl &&
curl --fail --remote-name https://woksteamapi.com/sdk/releases/1.3.0/manifest.json &&
curl --fail --remote-name https://woksteamapi.com/sdk/releases/1.3.0/SHA256SUMS &&
sha256sum --check --ignore-missing SHA256SUMS &&
python -m pip install --no-index --no-deps ./wok_api-1.3.0-py3-none-any.whl
```

`SHA256SUMS` also lists the JavaScript archive; `--ignore-missing` skips that
file if you did not download it. The manifest records source hashes, exact
archive members, build tools and the fixed build epoch. Checksums detect changed
downloads; they are not a separate publisher signature. Download and installation
need no WOK key and make no Steam/provider request. API calls made after setup
still need normal endpoint access and quota. You can also keep using the
[single source module](https://woksteamapi.com/sdk/python/wok_api.py).

## Usage

```python
import os

from wok_api import WokApi, WokApiError

client = WokApi(os.environ["WOK_API_KEY"])
steam_id = os.environ["STEAM_ID"]

# Read the purchased plan and pace a checker without spending quota.
quota = client.key_info()
print(quota["plan"], quota["remaining"])

try:
    inventory = client.inventory(
        steam_id,
        game="cs2",
        top=25,
    )
    print(inventory)
except WokApiError as error:
    print(error.status, error, error.retry_after)
```

For a client without the SDK, use the [standalone setup and failure rules](https://woksteamapi.com/api/examples/README.md),
[Steam + FACEIT Python example](https://woksteamapi.com/api/examples/steam-faceit.py),
or [Node.js inventory stream consumer](https://woksteamapi.com/api/examples/inventory-stream.mjs).
These built-in-library CLI examples use the normal endpoint scopes and quota.

Choose a saved price statistic for any supported game:

```python
quote = client.price("rust", "Big Grin", price_stat="avg")
print(quote["price"], quote["price_statistics"]["source_count"])
skinport = client.price("rust", "Big Grin", price_source="skinport",
                        price_stat="median", strict=True)
print(skinport["price"], skinport["source_statistics"].get("skinport"))
```

Without `price_source`, `min`, `max`, `avg`, and `median` compare one recent
lowest ask per marketplace. With `price_source`, they use that provider's
reported field. Unsupported provider metrics return `null` in JSON (`None` in
Python); a single ask is never presented as the maximum of every lot.

## FACEIT profiles

For FACEIT profiles, `faceit_player(steam_id)` now returns optional public
creation/activation dates, images, membership labels, friends-list entry count,
Steam linkage and game level labels without additional upstream requests.
`faceit_stats(player_id)` also returns lifetime `Wins` when available. Existing
six-hour cached profiles may lack extras until natural refresh. See the
[FACEIT integration workflow](https://woksteamapi.com/faceit#faceit-workflow)
for server-side requests, missing-result handling and cache limits.

## Profile comments and batch VAC checks

```python
page = client.profile_comments(steam_id, limit=20, start=0)
for comment in page["comments"]:
    print(comment["author"]["display_name"], comment["text"])
if page["next_start"] is not None:
    next_page = client.profile_comments(steam_id, start=page["next_start"])

security = client.player_security_batch([steam_id, steam_id])
print(security["count"], security["requested_count"])  # 1 unique ID, 2 inputs
print(security["players"][0]["bans"]["vac_banned"])
```

Both methods require a user SteamID64 string and all-purpose or Players
access. `profile_comments()` returns a typed `ProfileCommentsResponse` with
`ProfileComment` and `ProfileCommentAuthor` entries. It reads one newest-first
page visible to an anonymous Steam Community visitor, with `limit=1..100` and
`start=0..1_000_000`. Nonempty pages cache for 60 seconds; empty pages for 300.
Offsets can shift when comments change. Stop at `next_start is None`, including
at the offset bound. Hidden/deleted comments cannot be recovered, and an
empty page does not prove profile existence or public visibility.
`text_raw` is nullable original Steam text and may contain BBCode; treat it,
`text` and display names as untrusted content. Each call costs one quota unit.
See the [comments guide](https://woksteamapi.com/api/steam-profile-comments.md).

`player_security_batch()` accepts an iterable containing 1..100 SteamID64
strings and returns a typed `PlayerSecurityBatch`. The server validates and
deduplicates in first occurrence order, charging one unit per unique ID,
including cached entries. A five-minute per-ID cache is shared with
`player_security()`. Only uncached IDs are sent together in one official
`GetPlayerBans` request. `cached_count` excludes cold requests joining an
existing flight. Null ban signals remain unknown; public bans cannot prove
complete trade eligibility. See the
[batch VAC guide](https://woksteamapi.com/api/steam-player-security-batch.md).
Downloadable SDKs include these methods; package registry releases may lag.

## Batch game hours

```python
hours = client.games_batch([steam_id], appids=[730, 252490])
for player in hours["players"]:
    if player["status"] == 200:
        print(player["data"]["returned_game_count"], player["data"]["games"])
    else:
        print(player["error"]["code"])
```

`games_batch()` accepts 1..20 SteamID64 strings and an optional iterable of
1..50 positive integer `appids`. It returns a typed `SteamGamesBatch`.
The server deduplicates IDs in first occurrence order and charges one quota
unit per unique account, including cached results. AppIDs filter the shared
five-minute owned-games cache locally; `game_count` and library totals remain
unfiltered, while `returned_game_count` describes the returned games.
Unknown hours remain null. Steam requires a separate lookup for each cold
account; four global workers and a 30-second deadline bound the batch.
HTTP 200 can contain player errors 502/503/504. `pending_count` is a snapshot
of running deadline jobs; `upstream_attempts` counts only worker results
included in the response. Deadline rows may finish later. See the
[batch game hours guide](https://woksteamapi.com/api/steam-games-batch.md).
Downloadable SDKs include this method; package registry releases may lag.
Keep the default 35-second client timeout. Retain successful player rows and
retry only failed IDs after backoff of at least five seconds; honor a longer
`Retry-After` on a top-level rate-limit response.

## Other profile and game data

### Hours over the last two weeks

```python
recent = client.recent_games(steam_id)
recent_batch = client.recent_games_batch([steam_id], appids=[730, 252490])
library = client.games(steam_id, include_recent=True)
libraries = client.games_batch([steam_id], appids=[730], include_recent=True)
```

The recent methods return typed `RecentResult` / `SteamRecentGamesBatch`
data from Steam's `GetRecentlyPlayedGames`, using `count=0` for the full
exposed recent list. `complete` requires a nonempty valid source list,
matching reported count and explicit recent minutes for every row. Unknown,
empty or hidden results retain null totals. Never fill a missing game with
zero hours. Local AppID filters keep original source count/totals/complete;
`returned_game_count` identifies the filtered games length.

`include_recent` defaults to `False`; enabling it adds `recent_playtime` and
fills known recent minutes only for matching owned games. A recent failure
preserves the library and appears in the nested status/error, so inspect
that result even on success. Enriched batch `cached_count` requires both
owned and recent sources to be cache/shared hits. Each source caches for five
minutes; recent reads share their cache with enriched requests. Cold recent
reads need one Steam lookup, enriched owned reads up to two, with one quota
unit per account unchanged. A single route also requesting achievement
progress can need three cold lookups. See the
[recent playtime guide](https://woksteamapi.com/api/steam-recent-playtime.md).
The new methods are in the downloadable SDK; registry releases may lag.

One WOK key covers every supported game and the Rust player endpoints. For a
17-digit SteamID64, request Steam's Rust data through WOK:

```python
profile = client.profile(steam_id, include_level=True)
games = client.games(steam_id)
friends = client.friends(steam_id)
name_history = client.name_history(steam_id)
stats = client.rust_stats(steam_id)
summary = client.rust_summary(steam_id)
achievements = client.rust_achievements(steam_id)
playtime = client.rust_playtime(steam_id)
print(profile["timecreated"], profile["steam_level"], len(friends["friends"]))
print([entry["name"] for entry in name_history["aliases"]])
print(next((game["playtime_hours"] for game in games["games"]
            if game["appid"] == 252490), None))
print(stats["playerstats"].get("stats"))
print(summary["kills"]["players"], summary["kills"]["scientists"],
      summary["resources"]["wood"])
print([row for row in achievements["playerstats"].get("achievements", [])
       if row["achieved"] == 1])
print(playtime["response"]["games"][0]["playtime_forever"]  # minutes
      if playtime["response"]["games"] else None)
print(playtime["response"]["games"][0]["playtime_hours"]  # decimal hours
      if playtime["response"]["games"] else None)
```

Use `inventory(steam_id, game="rust")` for Rust items, or change `game` to
`cs2`, `dota2`, `tf2`, `pubg`, `sandbox`, `unturned`, or `payday2`. See the
[integration guide](https://woksteamapi.com/api/integration-guide.md) for exact
routes, privacy behavior, and response fields. The downloadable SDK on the
site has these methods; registry releases may lag.

For CS2, `try_first_seven_days_blocked_items=1` currently returns the public
context-2 inventory with `meta.protected_items_fallback: "public_only"`.
It does not expose hidden protected assets. Strict mode `2` returns HTTP 501
until a verified protected-item source is available. The option is invalid for
other games. `search` filters returned rows by full market name without
changing totals:

```python
result = client.inventory(
    steam_id, game="cs2", search="★ Butterfly Knife | Marble Fade",
    try_first_seven_days_blocked_items=1,
)
print(result["meta"]["coverage"], result["meta"]["protected_items_fallback"])
```

Revalue a batch after a normal inventory request without downloading it from
Steam again:

```python
repriced = client.inventories(
    [steam_id], games=["cs2"], refresh_prices=True, top=0,
)
for result in repriced["results"]:
    if result["ok"]:
        print(result["total"], result["meta"]["inventory_created_at"])
    else:
        print(result["error"]["code"])
```

This updates prices from WOK's current local catalog, not the inventory's
contents or age. Missing, expired or invalidated snapshots return a per-pair
`404 inventory_cache_miss`; choose a separate normal scan only if needed.
No Steam or proxy requests are made. Each unique player-game pair still
consumes one quota unit. Do not combine `refresh_prices=True` with `no_cache=True`.
The new option is available in the downloadable SDK; registry releases may lag.

Save an inventory quantity baseline only when a complete inventory is already
cached, then read later observed changes by exact market name:

```python
client.inventory(steam_id, game="cs2")  # Populate the regular cache if needed.
saved = client.capture_inventory_snapshot(steam_id, game="cs2")
print(saved["snapshot"]["observed_at"], saved["diff"]["status"])

page = client.inventory_snapshot_diff(saved["snapshot"]["id"])
while True:
    print(page["added"], page["removed"], page["unknown"])
    if not page["next_cursor"]:
        break
    page = client.inventory_snapshot_diff(
        saved["snapshot"]["id"], cursor=page["next_cursor"])

history = client.list_inventory_snapshots(steam_id, game="cs2")
# client.delete_inventory_snapshots(steam_id, game="cs2")
```

The first capture is a baseline with no asserted additions. A new diff needs
a *new* valid inventory observation; repeating a capture of the same cache
returns `created: false`. Snapshot calls do not fetch Steam or use proxies.
They record name quantities only, not individual asset movement or trade
causes. History belongs to this key and is retained for at most 30 days.
See the [snapshot integration guide](https://woksteamapi.com/api/integration-guide.md#opt-in-inventory-change-snapshots).

Choose BUFF163 prices for CS2:

```python
buff_inventory = client.refresh_inventory_prices(
    steam_id, game="cs2", price_source="buff", strict=True,
)
buff_catalog = client.items(game="cs2", source="buff", limit=50)
```

The refresh method requires an active cached inventory. These are saved lowest
listing prices in USD, not a fresh BUFF scan or completed-sale prices. Catalog
`updated_at` values retain the provider's observation time. With `strict=True`,
items missing a BUFF price remain unpriced; without strict mode, the actual
source field identifies any fallback. BUFF is CS2-only. `auto` excludes it,
while `mix` can choose an observation up to 72 hours old after price checks.
Normal inventory and batch methods
also accept `price_source="buff"`; they retain their normal scan/cache behavior.

Skins.com is a separate CS2 source from the same shared snapshot. Use
`price_source="skins", strict=True` for a Skins.com-only inventory value, or
`source="skins"` with `client.items()` for catalog rows. It is also
excluded from `auto`; `mix` can choose a fresh observation after price checks.

CS2 inventory rows include applied `stickers` and `charms`, with an `assetid`
on each attachment. Read `resale_estimates` for an assessment of each complete
weapon, including its attachments:

```python
inventory = client.inventory(steam_id, game="cs2", top=0)
for item in inventory["items"]:
    for estimate in item.get("resale_estimates", []):
        if estimate["status"] == "estimated":
            print(estimate["assetid"], estimate["price"],
                  estimate["low"], estimate["high"], estimate["sample_count"],
                  estimate["basis"], estimate["sources"])
        elif estimate["status"] == "insufficient_data":
            print(estimate["assetid"], estimate["reason"])
print(inventory.get("estimated_total"), inventory.get("unestimated_count"))
```

These estimates use comparable completed sales when enough matching evidence
exists, or comparable asking prices otherwise, in USD before seller fees.
Read `basis`, `sources` and `evidence` to distinguish the two. Sales and listings
are never combined to reach the minimum sample count. `estimated_total` is
null if any sellable CS2 asset lacks enough evidence; a cold comparison cache
can return `insufficient_data`. Source availability varies; a BUFF base catalog
quote does not imply that the optional BUFF Enterprise craft feed is connected.
`pricemedian` and `total` keep their base catalog meaning. Attachment `price`
and `attachment_value` are standalone reference values: do not add them to a
weapon's price. An ungrouped compatibility response uses `resale_estimate`.

Resolve the public Steam description for a known CS2 class ID:

```python
classid = os.environ.get("CS2_CLASS_ID")  # Decimal Steam class ID as a string.
if classid:
    preview = client.preview_cs2_item_class(classid)
    print(preview["scope"], preview["item"].get("markethashname"),
          preview["item"].get("image_url"))
```

This uses one normal WOK quota unit and needs no Inspect add-on. The result
has `scope: "class"`: it describes the item definition, not an owned asset.
It cannot supply an individual's float, paint seed, owner or trade-protection
state. Steam may not expose a newly introduced class immediately. The
[class preview guide](https://woksteamapi.com/api/cs2-item-preview.md) explains
missing fields and errors.

Read individual CS2 Steam Market listings with the Inspect add-on:

```python
name = "AK-47 | Redline (Field-Tested)"  # Exact market_hash_name.
page = client.list_cs2_market_listings(name, price_stat="median")
print(page["selected_total_minor"], page["selected_currency_id"],
      page["listing_statistics"])  # Matching lots on this page only.
for listing in page["listings"]:
    print(listing["listing_id"], listing["float_value"],
          listing["price_display"], listing["currency"])
if page["has_more"] and page["next_start"] is not None:
    next_page = client.list_cs2_market_listings(
        name, start=page["next_start"], bucket_id=page["bucket_id"])
    print("next page listings:", len(next_page["listings"]))
```

Each call reads one bounded page. `listings` can be empty while `has_more` is
true; `total_count` counts Steam's filtered query, not necessarily the exact
item name. `float_value` can be null. Prices are in Steam's returned currency
(`currency`, `price_display`, and minor-unit fields), not automatically USD.

Render a modern CS2 inspect certificate as PNG bytes:

```python
png = client.cs2_screenshot(
    certificate=os.environ["CS2_INSPECT_CERTIFICATE"],
    theme="wok",
    item_name="AK-47 | Redline (Field-Tested)",
)
with open("wok-cs2-inspect.png", "wb") as output:
    output.write(png)
```

`WokApiError` provides `status`, `payload`, `retry_after`, `rate_limit`,
`rate_remaining`, and `rate_reset`. Wait at least `retry_after` seconds after
HTTP 429. Network failures and timeouts before a readable success body use
status `0`. If an HTTP error body cannot be read, the known HTTP status and
retry/rate headers are preserved, with `payload=None`. Error responses are
closed even when reading their bodies fails.

The default transport does not follow redirects or retry requests. HTTP 3xx
raises `WokApiError`; configure the final API `base_url` explicitly. This avoids
forwarding Bearer credentials or replaying a paid operation. A custom
`transport` is responsible for enforcing the same redirect policy.

The timeout must be finite and positive. The default is 35 seconds so the 25-second synchronous inventory batch
can finish. Do not configure `/v1/inventories` with a timeout below 30 seconds.

For testing or a compatible deployment, pass `base_url`, `timeout`, or a custom
`transport` to the constructor.

## Profile activity, groups and app data

```python
profile = client.profile(steam_id, include_activity=True, include_groups=True)
groups = client.profile_groups(steam_id)
badges = client.badges(steam_id)
stats = client.game_stats(steam_id, 252490)
app = client.app(730, sections=["reviews", "current_players"])
schema = client.app_schema(252490, language="english")
percentages = client.global_achievements(252490)
news = client.app_news(730, count=5, maxlength=500)
catalog = client.search_apps("counter", limit=20)
progress = client.game_achievements(steam_id, 252490)
batch = client.game_achievements_batch(
    [{"steam_id": steam_id, "appid": 252490}],
    include_schema=True, include_global=True, language="english",
)
```

Core profile fields keep their existing cache window. Activity is optional,
cached separately for 60 seconds, and includes nullable `personastate`,
`lastlogoff`, `gameid` and `gameextrainfo`, with `activity_status` and
`activity_observed_at`. The default returns activity as `null` with
`activity_status: not_requested`. `profiles(ids, include_activity=True)`
shares single-profile caches and fetches only misses together. It accepts
1–64 accounts; it does not fetch groups for every account.
Unavailable profiles are omitted from the returned map; compare its keys with
the requested IDs rather than treating an absent profile as empty data.

Groups reflect only the anonymous public XML source. Missing/hidden data
returns `groups: null`, `count: null` and `status: unknown`;
`complete` remains false. An optional group failure in `profile()` preserves
core profile data and reports the nested error.

`player_security(..., include_details=True)` and
`player_security_batch(..., include_details=True)` add nullable `ban_details`
with Steam's published ban fields. This does not reveal individual game-ban
records or establish complete trade eligibility.

Choose `app(..., sections=[...])` from `store`, `reviews` and
`current_players` to read only the requested sources. Omitting sections
preserves the default three-source response. `search_apps()` reads the local
catalog, adds no upstream request and exposes catalog completeness/freshness.

Achievement batches accept 1–20 explicit account/AppID pairs, remove duplicate
pairs in first occurrence order, and charge one unit per unique pair. Each
response row has `status`, `data` and `error`; optional schema/global failures
preserve available player progress. `language` requires `include_schema=True`.
They use the SDK's 35-second timeout with the server's 30-second batch deadline.
Retry failed pairs after backoff; retain successful rows. A completion
percentage or total of `None` is unknown, not zero.

See the [Steam data guide](https://woksteamapi.com/api/steam-player-extras.md)
for fields, source limits and cache windows.

| Data | SDK method |
| --- | --- |
| Public friends, aliases, level | `friends()`, `aliases()` / `name_history()`, `steam_level()` |
| Visible library and recent hours | `games()`, `games_batch()`, `recent_games()`, `recent_games_batch()` |
| One game's achievement progress | `game_achievements()`; `games(..., achievement_appid=...)` |
| Selected achievement pairs | `game_achievements_batch()` |
| Public groups, badges and XP | `profile_groups()`, `badges()` |
| Game counters | `game_stats()` |
| Store/reviews/player count | `app()` |
| Local app search, definitions, news | `search_apps()`, `app_schema()`, `app_news()` |
| Global achievement percentages | `global_achievements()` |
| Rust data | `rust_stats()`, `rust_summary()`, `rust_achievements()`, `rust_playtime()` |
| CS2 cross-market quotes | `cs2_market_prices()` |

## Inventory pages and exports

```python
options = {
    "game": "cs2", "limit": 100, "currency": "EUR",
    "select": ["assetid", "pricemedian", "tradable"],
}
page = client.inventory_page(steam_id, **options)
cursor = page["headers"].get("X-Wok-Next-Cursor")
if cursor:
    page = client.inventory_page(steam_id, cursor=cursor, **options)

csv_text = client.export_inventory(steam_id, game="cs2")  # str
download = client.download_inventory(steam_id, game="cs2", format="ndjson")  # bytes
```

`inventory()` and `compatibility_inventory()` accept `currency`, `select`,
`limit`, `offset`, `cursor` and `format`. JSON remains the default; CSV/NDJSON
return text. `inventory_page()` retains response headers as `{data, headers}`;
set `compatible=True` for the compatibility array route. Currency uses the
saved FX snapshot, with no per-request FX fetch and a 24-hour maximum age.

`limit` is 1–1,000 and overrides `top`. A cursor requires the same SteamID64,
request options and valid cached snapshot, and cannot be combined with
`no_cache` or `refresh_prices`. HTTP 409 means restart pagination. Local
prices may change between pages. CSV/NDJSON without `limit` exports all
matched rows. HTTP failures still raise `WokApiError` with the JSON error
payload, including `retry_after` for 429/503; helpers do not retry automatically.
See the [inventory output guide](https://woksteamapi.com/api/steam-inventory-output.md).

Source version 1.3.0 includes these helpers. A package registry release may
lag the downloadable SDK.

## Build the package

```sh
python -m build
```

The command creates both an sdist and a wheel under `dist/`.

## CS2 viewer and screenshot renders

`cs2_viewer_access()`, `configure_cs2_viewer(settings)` and
`open_cs2_viewer(item)` manage the separate Viewer subscription. Viewer Trial
includes 10 opens per 30 days; other API plans do not include 3D access.

`create_cs2_screenshot(options)` returns a pending job or a ready base64 PNG object.
Poll `cs2_screenshot_job(job_id)` after the retry delay until `status == "ready"`.
The existing `cs2_screenshot()` method keeps returning inspect **data cards**.
See [render options and limitations](https://woksteamapi.com/api/cs2-screenshot-api.md).
