# WOK API client for JavaScript

Dependency-free, server-side ESM client for the
[WOK Steam API](https://woksteamapi.com/docs). It uses the `fetch` implementation
built into Node.js and includes TypeScript declarations.

## Requirements

- Node.js 18 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 on the server. Do not bundle it into browser code or expose it
to clients.

## Install

The examples below target WOK's downloadable **1.3.0** SDK. The package registry
can lag this source: `npm install @woksteamapi/client` selects the npm release,
not necessarily the version documented here. Use the versioned WOK-hosted archive
when you need these methods. It contains the ESM module and TypeScript declarations
and has no runtime dependencies.

Download the archive, 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/woksteamapi-client-1.3.0.tgz &&
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 &&
npm install --offline --ignore-scripts --no-audit --no-fund ./woksteamapi-client-1.3.0.tgz
```

`SHA256SUMS` also lists the Python wheel; `--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. The offline install uses the local
archive and disables package scripts; a pre-existing project with other uncached
dependencies can fail rather than fetch them. 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. The existing
[single ESM source](https://woksteamapi.com/sdk/javascript/wok-api.mjs) remains available.

## Usage

```js
import WokApi, { WokApiError } from "@woksteamapi/client";

const client = new WokApi({ apiKey: process.env.WOK_API_KEY });
const steamId = process.env.STEAM_ID;
if (!steamId) throw new Error("Set STEAM_ID in the environment");

// Read the purchased plan and pace a checker without spending quota.
const quota = await client.keyInfo();
console.log(quota.plan, quota.remaining);

try {
  const inventory = await client.inventory(steamId, {
    game: "cs2",
    top: 25,
  });
  console.log(inventory);
} catch (error) {
  if (error instanceof WokApiError) {
    console.error(error.status, error.message, error.retryAfter);
  } else {
    throw error;
  }
}
```

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:

```js
const quote = await client.price("rust", "Big Grin", { priceStat: "avg" });
console.log(quote.price, quote.price_statistics.source_count);
const skinport = await client.price("rust", "Big Grin", {
  priceSource: "skinport", priceStat: "median", strict: true,
});
console.log(skinport.price, skinport.source_statistics.skinport);
```

Without `priceSource`, `min`, `max`, `avg`, and `median` compare one recent
lowest ask per marketplace. With `priceSource`, they use that provider's
reported field. An unavailable metric is `null`; one ask does not establish
the maximum of every lot.

## FACEIT profiles

For FACEIT profiles, `faceitPlayer(steamId)` now returns typed optional public
creation/activation dates, images, membership labels, friends-list entry count,
Steam linkage and game level labels without additional upstream requests.
`faceitStats(playerId)` 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

```js
const page = await client.profileComments(steamId, { limit: 20, start: 0 });
for (const comment of page.comments) {
  console.log(comment.author.display_name, comment.text);
}
if (page.next_start !== null) {
  const nextPage = await client.profileComments(steamId, { start: page.next_start });
}

const security = await client.playerSecurityBatch([steamId, steamId]);
console.log(security.count, security.requested_count); // 1 unique ID, 2 inputs
console.log(security.players[0].bans.vac_banned);
```

Both methods require a user SteamID64 string and all-purpose or Players
access. `profileComments()` returns `ProfileCommentsResponse`, with typed
options, authors and comments. 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 === null`,
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).

`playerSecurityBatch()` accepts an iterable containing 1..100 SteamID64
strings and returns `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
`playerSecurity()`. 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

```js
const hours = await client.gamesBatch([steamId], { appids: [730, 252490] });
for (const player of hours.players) {
  if (player.status === 200) console.log(player.data.returned_game_count, player.data.games);
  else console.log(player.error.code);
}
```

`gamesBatch()` accepts 1..20 SteamID64 strings and an optional iterable of
1..50 positive integer `appids`. It returns a typed `SteamGamesBatch` with
`GamesBatchOptions` and `OwnedGamesBatchData` declarations.
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

```js
const recent = await client.recentGames(steamId);
const recentBatch = await client.recentGamesBatch([steamId], { appids: [730, 252490] });
const library = await client.games(steamId, { includeRecent: true });
const libraries = await client.gamesBatch([steamId], { appids: [730], includeRecent: 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.

`includeRecent` 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:

```js
const profile = await client.profile(steamId, { includeLevel: true });
const games = await client.games(steamId);
const friends = await client.friends(steamId);
const nameHistory = await client.nameHistory(steamId);
const stats = await client.rustStats(steamId);
const summary = await client.rustSummary(steamId);
const achievements = await client.rustAchievements(steamId);
const playtime = await client.rustPlaytime(steamId);
console.log(profile.timecreated, profile.steam_level, friends.friends.length);
console.log(nameHistory.aliases.map((entry) => entry.name));
console.log(games.games.find(game => game.appid === 252490)?.playtime_hours);
console.log(stats.playerstats.stats);
console.log(summary.kills.players, summary.kills.scientists, summary.resources.wood);
console.log(achievements.playerstats.achievements?.filter(row => row.achieved === 1));
console.log(playtime.response.games[0]?.playtime_forever); // minutes
console.log(playtime.response.games[0]?.playtime_hours); // decimal hours
```

Use `inventory(steamId, {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, `tryFirstSevenDaysBlockedItems: 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:

```js
const result = await client.inventory(steamId, {
  game: "cs2", search: "★ Butterfly Knife | Marble Fade",
  tryFirstSevenDaysBlockedItems: 1,
});
console.log(result.meta.coverage, result.meta.protected_items_fallback);
```

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

```js
const repriced = await client.inventories([steamId], {
  games: ["cs2"], refreshPrices: true, top: 0,
});
for (const result of repriced.results) {
  if (result.ok) console.log(result.total, result.meta.inventory_created_at);
  else console.log(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 `refreshPrices: true` with `noCache: true`.
The new option is available in the downloadable SDK; registry releases may lag.

Save a quantity baseline from the current complete inventory cache, then page
through observed name changes:

```js
await client.inventory(steamId, { game: "cs2" }); // Populate the cache if needed.
const saved = await client.captureInventorySnapshot(steamId, { game: "cs2" });
console.log(saved.snapshot.observed_at, saved.diff.status);

let page = await client.getInventorySnapshotDiff(saved.snapshot.id);
for (;;) {
  console.log(page.added, page.removed, page.unknown);
  if (!page.next_cursor) break;
  page = await client.getInventorySnapshotDiff(saved.snapshot.id, {
    cursor: page.next_cursor,
  });
}
const history = await client.listInventorySnapshots(steamId, { game: "cs2" });
// await client.deleteInventorySnapshots(steamId, { game: "cs2" });
```

The first capture is a baseline, not an arrival of every item. Repeating a
capture of the same cache returns `created: false`; a new diff needs a new
valid inventory observation. Snapshot calls do not fetch Steam or use proxies.
They track quantities by exact market name, not individual asset movement or
trade causes. History is per key and 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:

```js
const buffInventory = await client.refreshInventoryPrices(steamId, {
  game: "cs2", priceSource: "buff", strict: true,
});
const buffCatalog = await 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 `priceSource: "buff"`; they retain their normal scan/cache behavior.

Skins.com is a separate CS2 source from the same shared snapshot. Use
`priceSource: "skins"` with `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:

```js
const inventory = await client.inventory(steamId, { game: "cs2", top: 0 });
for (const item of inventory.items) {
  for (const estimate of item.resale_estimates ?? []) {
    if (estimate.status === "estimated") {
      console.log(estimate.assetid, estimate.price,
        estimate.low, estimate.high, estimate.sample_count,
        estimate.basis, estimate.sources);
    } else if (estimate.status === "insufficient_data") {
      console.log(estimate.assetid, estimate.reason);
    }
  }
}
console.log(inventory.estimated_total, inventory.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:

```js
const classid = process.env.CS2_CLASS_ID; // Decimal Steam class ID as a string.
if (classid) {
  const preview = await client.previewCs2ItemClass(classid);
  console.log(preview.scope, preview.item.markethashname, preview.item.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:

```js
const name = "AK-47 | Redline (Field-Tested)"; // Exact market_hash_name.
const page = await client.listCs2MarketListings(name, { priceStat: "median" });
console.log(page.selected_total_minor, page.selected_currency_id,
  page.listing_statistics); // Actual matching lots on this page only.
for (const listing of page.listings) {
  console.log(listing.listing_id, listing.float_value,
    listing.price_display, listing.currency);
}
if (page.has_more && page.next_start !== null) {
  const nextPage = await client.listCs2MarketListings(name, {
    start: page.next_start, bucketId: page.bucket_id,
  });
  console.log("next page listings:", nextPage.listings.length);
}
```

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:

```js
import { writeFile } from "node:fs/promises";

const png = await client.cs2Screenshot({
  certificate: process.env.CS2_INSPECT_CERTIFICATE,
  theme: "wok",
  itemName: "AK-47 | Redline (Field-Tested)",
});
await writeFile("wok-cs2-inspect.png", png);
```

`WokApiError` provides `status`, `payload`, `retryAfter`, `rateLimit`,
`rateRemaining`, and `rateReset`. Wait at least `retryAfter` seconds after HTTP
429. Network failures and timeouts use status `0`.

Requests use `redirect: "manual"` and are never retried automatically. HTTP 3xx
raises `WokApiError`; configure the final API `baseUrl` explicitly. This avoids
forwarding credentials or replaying a paid operation. A custom `fetchImpl` must
honor the manual redirect option. The timeout remains active while reading
the response body, including binary downloads.

The default timeout 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 `baseUrl`, `timeoutMs`, or a
custom `fetchImpl` to the constructor.

## Profile activity, groups and app data

```js
const profile = await client.profile(steamId, { includeActivity: true, includeGroups: true });
const groups = await client.profileGroups(steamId);
const badges = await client.badges(steamId);
const stats = await client.gameStats(steamId, 252490);
const app = await client.app(730, { sections: ["reviews", "current_players"] });
const schema = await client.appSchema(252490, { language: "english" });
const percentages = await client.globalAchievements(252490);
const news = await client.appNews(730, { count: 5, maxlength: 500 });
const catalog = await client.searchApps("counter", { limit: 20 });
const progress = await client.gameAchievements(steamId, 252490);
const batch = await client.gameAchievementsBatch(
  [{ steam_id: steamId, appid: 252490 }],
  { includeSchema: true, includeGlobal: 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, {includeActivity: 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.

`playerSecurity(..., {includeDetails: true})` and
`playerSecurityBatch(..., {includeDetails: 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. `searchApps()` 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 `includeSchema: 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 `null` completion
percentage or total is unknown, not zero.

See the [Steam data guide](https://woksteamapi.com/api/steam-player-extras.md)
for fields, source limits and cache windows. TypeScript definitions cover the
new groups, stats, badges, schemas, news, app search and achievement responses.

| Data | SDK method |
| --- | --- |
| Public friends, aliases, level | `friends()`, `aliases()` / `nameHistory()`, `steamLevel()` |
| Visible library and recent hours | `games()`, `gamesBatch()`, `recentGames()`, `recentGamesBatch()` |
| One game's achievement progress | `gameAchievements()`; `games(..., {achievementAppid: ...})` |
| Selected achievement pairs | `gameAchievementsBatch()` |
| Public groups, badges and XP | `profileGroups()`, `badges()` |
| Game counters | `gameStats()` |
| Store/reviews/player count | `app()` |
| Local app search, definitions, news | `searchApps()`, `appSchema()`, `appNews()` |
| Global achievement percentages | `globalAchievements()` |
| Rust data | `rustStats()`, `rustSummary()`, `rustAchievements()`, `rustPlaytime()` |
| CS2 cross-market quotes | `cs2MarketPrices()` |

## Inventory pages and exports

```js
const options = {
  game: "cs2", limit: 100, currency: "EUR",
  select: ["assetid", "pricemedian", "tradable"],
};
let page = await client.inventoryPage(steamId, options);
const cursor = page.headers["X-Wok-Next-Cursor"];
if (cursor) page = await client.inventoryPage(steamId, { ...options, cursor });

const csv = await client.exportInventory(steamId, { game: "cs2" }); // string
const bytes = await client.downloadInventory(steamId, {
  game: "cs2", format: "ndjson",
}); // Uint8Array
```

`inventory()` and `compatibilityInventory()` accept `currency`, `select`,
`limit`, `offset`, `cursor` and `format`. JSON remains the default; CSV/NDJSON
return text. `inventoryPage()` 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
`noCache` or `refreshPrices`. 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 `retryAfter` 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.

## Verify the package

```sh
npm run check
npm pack --dry-run
```

## CS2 viewer and screenshot renders

`cs2ViewerAccess()`, `configureCs2Viewer({origins, settings})` and
`openCs2Viewer({def_index, paint_index, float_value, paint_seed})` manage viewer
access through the separate Viewer subscription. Viewer Trial includes
10 opens per 30 days; other API plans do not include 3D access.

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