@assetpay/steam-mobile 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1188 @@
1
+ # Steam Mobile
2
+
3
+ A headless, fully-typed **ESM TypeScript** client for the **Steam mobile app**, built for trading. It
4
+ unifies the *web* logic of [`steamcommunity`](https://github.com/DoctorMcKay/node-steamcommunity) and
5
+ [`steam-tradeoffer-manager`](https://github.com/DoctorMcKay/node-steam-tradeoffer-manager) into one
6
+ cohesive, Promise-native package — with **no `steam-user` and no Steam CM binary protocol**.
7
+
8
+ Everything the mobile app needs is in-package: credential login, TOTP, mobile confirmations
9
+ (`mobileconf`), and the `IAuthenticationService` protobufs. The only runtime dependencies are
10
+ [`got`](https://github.com/sindresorhus/got), [`proxy-agent`](https://github.com/TooTallNate/proxy-agents),
11
+ [`tough-cookie`](https://github.com/salesforce/tough-cookie), [`steamid`](https://github.com/DoctorMcKay/node-steamid),
12
+ and [`@bufbuild/protobuf`](https://github.com/bufbuild/protobuf-es).
13
+
14
+ The client impersonates the Steam mobile app on the wire (a MobileApp-platform access token, per-host
15
+ user agents, mobile cookies, and `mobileconf` confirmations). MobileApp is the only platform whose
16
+ access token can self-renew over plain HTTP, which is what lets this library run headless without a
17
+ persistent CM connection.
18
+
19
+ ## Table of contents
20
+
21
+ - [Installation](#installation)
22
+ - [The basics](#the-basics)
23
+ - [Logging in](#logging-in)
24
+ - [SteamMobile](#steammobile) — the root client
25
+ - [bot.trade](#bottrade) — sending, reading, and polling trade offers
26
+ - [TradeOffer](#tradeoffer) — a single offer
27
+ - [bot.community](#botcommunity) — inventories, profiles, trade URLs, API keys
28
+ - [bot.session](#botsession) — token lifecycle and sessions
29
+ - [bot.confirmations](#botconfirmations) — low-level mobile confirmations
30
+ - [decodePreviewToken](#decodepreviewtoken) — decode CS2 item inspect/certificate data
31
+ - [Data types](#data-types)
32
+ - [Enums](#enums)
33
+ - [Errors](#errors)
34
+ - [Mobile-app impersonation](#mobile-app-impersonation)
35
+ - [Notes & limitations](#notes--limitations)
36
+ - [Development](#development)
37
+
38
+ ---
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pnpm add @assetpay/steam-mobile # Node 22+, ESM only
44
+ ```
45
+
46
+ This package is ESM-only and targets Node 22+. Import it with `import { SteamMobile } from "@assetpay/steam-mobile"`.
47
+
48
+ ## The basics
49
+
50
+ ```ts
51
+ import { SteamMobile } from "@assetpay/steam-mobile";
52
+
53
+ const bot = await new SteamMobile({
54
+ refreshToken: process.env.STEAM_REFRESH_TOKEN!,
55
+ identitySecret: process.env.STEAM_IDENTITY_SECRET, // only needed to confirm trades
56
+ }).login();
57
+
58
+ console.log("Logged in as", bot.steamID.getSteamID64());
59
+
60
+ const { sent, received } = await bot.trade.getTradeOffers();
61
+ console.log(`${sent.length} sent, ${received.length} received`);
62
+ ```
63
+
64
+ Construction is **synchronous** and does no network I/O — your `steamID` is known immediately because
65
+ it is encoded in the refresh-token JWT. The single network step is `await bot.login()`, which mints the
66
+ access token and applies the login cookie. Every method awaits the token lazily, so you *can* call
67
+ methods before `login()`, but calling `login()` up front gives you fail-fast behavior and lets you
68
+ auto-start polling.
69
+
70
+ ---
71
+
72
+ ## Logging in
73
+
74
+ There are two ways to get a session. In production you almost always want the **refresh-token** path:
75
+ log in once with credentials, store the long-lived refresh token, and reuse it.
76
+
77
+ ### From a refresh token (the normal path)
78
+
79
+ ```ts
80
+ const bot = await new SteamMobile({ refreshToken }).login();
81
+ ```
82
+
83
+ A MobileApp refresh token lives ~211 days. The library renews the ~24-hour access token lazily and
84
+ rotates the refresh token automatically as it nears end-of-life — emitting a [`refreshToken`](#event-refreshtoken)
85
+ event each time so you can persist the new one.
86
+
87
+ ### From credentials
88
+
89
+ When you don't yet have a refresh token, use `loginWithCredentials`. This drives the full
90
+ `IAuthenticationService` credential flow (RSA password encryption → begin session → answer Steam Guard
91
+ → poll) entirely in-package — no `steam-session` needed.
92
+
93
+ #### loginWithCredentials(options)
94
+
95
+ - `options`
96
+ - `username` — Your Steam account name (login name, not persona).
97
+ - `password` — Your account password.
98
+ - `sharedSecret` — Optional. Your TOTP `shared_secret`. If present, device (TOTP) Steam Guard codes are answered automatically. If the account has **no mobile authenticator** (email Steam Guard only), this rejects up-front with a `NoMobileAuthenticatorError` rather than silently falling through.
99
+ - `steamGuardCode` — Optional. A Steam Guard code you supply yourself (email or device).
100
+ - `proxy` — Optional. A proxy URL; routes the entire login flow.
101
+ - `mobileProfile` — Optional. `"ios"` (default), `"android"`, or an object overriding individual [profile fields](#mobile-app-impersonation).
102
+ - `signal` — Optional. An `AbortSignal`; aborting stops polling and rejects with a `LoginError`.
103
+ - `onSteamGuardRequired` — Optional. `async ({ type, message }) => code`. Called when a code is required and none was supplied (e.g. an email code). Resolve it with the code to continue.
104
+
105
+ Returns a `Promise` that resolves to a **`LoginResult`**:
106
+
107
+ - `refreshToken` — The MobileApp refresh token. **Store this** and feed it to `new SteamMobile({ refreshToken })`.
108
+ - `accessToken` — The freshly-minted access token, or `undefined` if Steam didn't return one.
109
+ - `steamId` — The account's SteamID64, as a string.
110
+ - `username` — The account name Steam echoed back.
111
+
112
+ ```ts
113
+ import { loginWithCredentials, SteamMobile } from "@assetpay/steam-mobile";
114
+
115
+ const { refreshToken } = await loginWithCredentials({
116
+ username: "myaccount",
117
+ password: "…",
118
+ sharedSecret: "…", // answers the device-code 2FA automatically
119
+ // onSteamGuardRequired: async ({ message }) => await promptUser(message),
120
+ });
121
+
122
+ const bot = await new SteamMobile({ refreshToken }).login();
123
+ ```
124
+
125
+ Credential login is rate-limited by Steam, so do it once per device and reuse the refresh token. For
126
+ finer-grained control over the state machine (events for each step, manual code submission), use the
127
+ [`CredentialSession`](#credentialsession-low-level) class directly.
128
+
129
+ #### CredentialSession (low-level)
130
+
131
+ `loginWithCredentials` is a thin one-shot wrapper around this `EventEmitter`. Use it directly when you
132
+ need to react to each step.
133
+
134
+ - `new CredentialSession(http, profile[, pollTimeoutMs])` — `http` is an `HttpClient`, `profile` a resolved [`MobileProfile`](#mobile-app-impersonation), `pollTimeoutMs` defaults to 180000.
135
+ - `start(options)` — Begins the flow. `options` is the credentials (`username`, `password`, `sharedSecret?`, `steamGuardCode?`). Throws `NoMobileAuthenticatorError` synchronously inside `start()` if `sharedSecret` was supplied but the account isn't TOTP-protected. Returns `Promise<void>`.
136
+ - `submitSteamGuardCode(code)` — Supplies a code in response to a `steamGuardRequired` event. Returns `Promise<void>`.
137
+ - `stop()` — Aborts the flow and clears the poll timer.
138
+ - Properties (populated as the flow progresses): `steamID?`, `username`, `accessToken?`, `refreshToken?`.
139
+ - Events:
140
+ - `authenticated` — A refresh token is available; read it off the instance.
141
+ - `steamGuardRequired` — `{ type, message }`. No code could be supplied automatically; call `submitSteamGuardCode()`.
142
+ - `remoteInteraction` — Steam is waiting for the user to approve on their phone (device/email confirmation).
143
+ - `timeout` — The confirmation window elapsed.
144
+ - `error` — `error`. The flow failed.
145
+ - `debug` — `message`. Verbose step logging.
146
+
147
+ ---
148
+
149
+ ## SteamMobile
150
+
151
+ The root client. It owns the HTTP layer, the session, and the namespaces, and it re-emits every
152
+ [trade event](#trade-events) so you can listen on `bot` directly instead of `bot.trade`.
153
+
154
+ ### Constructor: new SteamMobile(options)
155
+
156
+ - `options`
157
+ - `refreshToken` — **Required.** A MobileApp refresh token (from `loginWithCredentials` or a prior session).
158
+ - `identitySecret` — Optional. Your `identity_secret`, required to **confirm** trades and other mobile confirmations. Read-only operations and sending don't need it.
159
+ - `proxy` — Optional. A proxy URL (`http://user:pass@host:port`, `socks5://…`, etc.). All traffic — API, community, and confirmations — routes through it.
160
+ - `mobileProfile` — Optional. `"ios"` (default), `"android"`, or a `Partial<MobileProfile>` to override individual fields. See [Mobile-app impersonation](#mobile-app-impersonation).
161
+ - `polling` — Optional. `true` to start polling with default cadence after `login()`, or a [`PollOptions`](#polloptions) object to tune it / resume from saved `pollData`.
162
+
163
+ Throws `SteamSessionExpiredError` synchronously if the refresh token is malformed (no SteamID in its
164
+ payload). Does **not** make any network request.
165
+
166
+ ### Properties
167
+
168
+ - `steamID` — A [`SteamID`](https://github.com/DoctorMcKay/node-steamid) object for the logged-in account. Available immediately after construction.
169
+ - `accessToken` — The current access token (`string`), or `undefined` before the first mint.
170
+ - `refreshToken` — The current refresh token (`string`). This changes when Steam rotates it; listen for the [`refreshToken`](#event-refreshtoken) event to persist updates.
171
+ - `identitySecret` — The `identity_secret` you passed, or `undefined`.
172
+ - `trade` — The [TradeNamespace](#bottrade).
173
+ - `community` — The [CommunityNamespace](#botcommunity).
174
+ - `session` — The [SessionManager](#botsession).
175
+ - `confirmations` — The [ConfirmationManager](#botconfirmations).
176
+ - `http`, `api` — The low-level `HttpClient` and `WebApiClient`. You normally won't need these.
177
+
178
+ ### login()
179
+
180
+ Mints the access token (failing fast if the refresh token is dead), applies the `steamLoginSecure`
181
+ login cookie, and — if you passed `polling` — starts the poll loop. Returns a `Promise` that resolves
182
+ to the `SteamMobile` instance itself, so you can write `const bot = await new SteamMobile(opts).login()`.
183
+
184
+ ### reauthenticate(credentials)
185
+
186
+ - `credentials` — A `ReauthenticateOptions`: the same fields as [`loginWithCredentials`](#loginwithcredentialsoptions) **except** `proxy` / `mobileProfile`, which are reused from this instance.
187
+
188
+ Recovers a **dead or revoked** refresh token — one the library can no longer renew (past its ~211-day
189
+ life, after a password change, or a Steam revocation). Runs a fresh credential login, swaps the new
190
+ refresh token in place, re-mints the access token, and re-emits [`refreshToken`](#event-refreshtoken) so
191
+ you can persist it. **Rejects if the credentials are for a different account** than this instance.
192
+ Returns `Promise<void>`. Pair it with the [`sessionExpired`](#event-sessionexpired) event.
193
+
194
+ ### ensureApiKey(\[domain])
195
+
196
+ - `domain` — Optional. The domain to register the key under (default `"assetpay.gg"`). Steam's API-key `domain` is a hostname label, not a URL.
197
+
198
+ Returns the account's existing Web API key, or registers a new one (auto-accepting the mobile
199
+ confirmation prompt if `identitySecret` is set), as a `Promise<string | null>`. Resolves to `null` when
200
+ the account is **ineligible** for a key (unverified email, no mobile authenticator, or a
201
+ [limited account](#notes--limitations)). This is a convenience passthrough to
202
+ [`bot.community.ensureApiKey`](#communityensureapikeydomain).
203
+
204
+ ### request(method, url\[, opts]) · get(url\[, opts]) · post(url\[, opts])
205
+
206
+ Escape hatch for endpoints the typed namespaces don't cover (like `node-steamcommunity`'s
207
+ `httpRequest`). Each ensures the session is live — minting/renewing the access token and applying the
208
+ `steamLoginSecure` cookie — *before* sending, so the request carries your logged-in session, then
209
+ delegates to [`bot.http`](#properties). Throws `SteamSessionExpiredError` if the session can't be
210
+ renewed.
211
+
212
+ - `method` — `"GET"` or `"POST"`.
213
+ - `url` — Absolute URL.
214
+ - `opts` — A `RequestOptions`: `searchParams`, `headers`, `referer`, `responseType` (`"text"` default, `"json"`, or `"buffer"`), `signal` (an `AbortSignal` to cancel), `timeoutMs` (per-request timeout override; default 50000), and **exactly one** request body of `form`, `multipart`, `json`, or `body` (raw `string`/`Buffer`).
215
+
216
+ Returns `Promise<HttpResponse<T>>` (`{ statusCode, headers, body }`). Pass a type parameter and
217
+ `responseType: "json"` to get a parsed body.
218
+
219
+ ```ts
220
+ const { body } = await bot.get<{ success: boolean }>(
221
+ "https://steamcommunity.com/market/priceoverview/",
222
+ { searchParams: { appid: 730, market_hash_name: "Mag-7 | Heat (Field-Tested)", currency: 1 }, responseType: "json" },
223
+ );
224
+
225
+ await bot.post("https://steamcommunity.com/some/endpoint", {
226
+ json: { sessionid: await bot.http.getSessionId(), foo: "bar" },
227
+ });
228
+ ```
229
+
230
+ For requests where you *don't* want the session ensured first, use `bot.http` directly.
231
+
232
+ ### shutdown()
233
+
234
+ Stops the poll loop and removes all event listeners (on both `bot` and `bot.trade`). Call this when
235
+ you're done with the client so it doesn't keep timers alive. Returns a `Promise<void>`.
236
+
237
+ ### Events
238
+
239
+ `SteamMobile` re-emits all [trade events](#trade-events) (`newOffer`, `sentOfferChanged`,
240
+ `receivedOfferChanged`, `unknownOfferSent`, `pollData`, `pollSuccess`, `pollFailure`, `debug`) plus two
241
+ of its own:
242
+
243
+ #### Event: refreshToken
244
+
245
+ - `token` — The new refresh token (`string`).
246
+
247
+ Emitted when Steam rotates your refresh token (which happens automatically near end-of-life). **Persist
248
+ the new value** — the old one stops working.
249
+
250
+ #### Event: sessionExpired
251
+
252
+ - `error` — A `SteamSessionExpiredError`.
253
+
254
+ Emitted when the refresh token is rejected or expired and the session can no longer be renewed. It fires
255
+ only on a **confirmed-terminal** auth failure (not a transient blip), so it's safe to trigger re-auth
256
+ on: call [`bot.reauthenticate(credentials)`](#reauthenticatecredentials).
257
+
258
+ ---
259
+
260
+ ## bot.trade
261
+
262
+ The trade namespace. It's an `EventEmitter` (see [Trade events](#trade-events)) and holds every method for
263
+ reading, sending, and watching trade offers.
264
+
265
+ ### createOffer(target)
266
+
267
+ - `target` — An [`OfferTarget`](#offertarget): either `{ tradeUrl }` or `{ steamId, token? }`.
268
+
269
+ Creates a new, empty [TradeOffer](#tradeoffer) addressed to the partner. **Local only** — nothing is
270
+ sent to Steam until you call [`offer.send()`](#send). Chain `.give()` / `.receive()` / `.setMessage()`
271
+ to build it.
272
+
273
+ ### getTradeOffer(id)
274
+
275
+ - `id` — The trade offer's numeric id, as a string.
276
+
277
+ Fetches a single offer and returns it as a [TradeOffer](#tradeoffer). Item descriptions are inlined
278
+ (`get_descriptions=1`), so the offer's items are full [`EconItem`](#econitem)s. Rejects with a
279
+ `SteamError` if the offer doesn't exist.
280
+
281
+ ### getTradeOffers(\[filter]\[, historicalCutoff])
282
+
283
+ - `filter` — Optional. A value from [`EOfferFilter`](#eofferfilter) (default `ActiveOnly`).
284
+ - `historicalCutoff` — Optional. A `Date`. When `filter` includes historical offers, only offers updated at or after this time are returned. Defaults to one year in the future (i.e. "active only").
285
+
286
+ Returns `Promise<{ sent: TradeOffer[]; received: TradeOffer[] }>`. This cursor-paginates **all** pages
287
+ internally and inlines descriptions into every offer's items. If Steam returns the wholly-malformed
288
+ "data temporarily unavailable" glitch (all offers missing partner/items), this rejects with a
289
+ `SteamError` rather than handing back garbage.
290
+
291
+ ### getTradeStatus({tradeId})
292
+
293
+ - `tradeId` — The **trade id** (not the offer id). This is set on a [TradeOffer](#tradeoffer) once it's accepted (`offer.tradeID`).
294
+
295
+ Returns settlement details for a completed or escrowed trade as a `Promise<`[`ExchangeDetails`](#exchangedetails)`>`,
296
+ including where each item *landed* after the trade (`new_assetid` / `new_contextid`, when Steam provides
297
+ them). See [`offer.getTradeStatus()`](#gettradestatus) for the offer-level shortcut.
298
+
299
+ ### getUserDetails(target)
300
+
301
+ - `target` — An [`OfferTarget`](#offertarget) (typically `{ tradeUrl }`).
302
+
303
+ Persona, contexts, escrow days, avatars, and partner probation for both sides — same scrape as
304
+ [`offer.getUserDetails()`](#getuserdetails) but addressed by target, so you don't need an offer in
305
+ hand. Returns `Promise<`[`UserDetails`](#userdetails)`>`. **Steam applies
306
+ `max(me.escrowDays, them.escrowDays)`** as the actual hold — compute that yourself if you need a
307
+ single number.
308
+
309
+ ### getEscrow(target)
310
+
311
+ - `target` — An [`OfferTarget`](#offertarget) (typically `{ tradeUrl }`).
312
+
313
+ Escrow hold (in **seconds**) via `IEconService/GetTradeHoldDurations`. Returns
314
+ `Promise<`[`EscrowHold`](#escrowhold)`>` shaped `{ me, them, both }` (top-level renamed from Steam's
315
+ `my_escrow` / `their_escrow` / `both_escrow`; inner shape preserved 1:1). Lightweight — just the
316
+ WebAPI, no scrape, no other user data. For everything together (escrow + persona + avatars +
317
+ contexts), use [`getUserDetails`](#getuserdetailstarget) above (or [`offer.getUserDetails()`](#getuserdetails))
318
+ instead.
319
+
320
+ ### getTradeHistory(\[options])
321
+
322
+ - `options` — Optional [`TradeHistoryOptions`](#tradehistoryoptions):
323
+ - `maxTrades` — Max trades to return (default 100).
324
+ - `startAfterTime` / `startAfterTradeId` — Cursor: the time and id of the last trade from the previous page. Pass both to fetch the next page.
325
+ - `navigatingBack` — Page backwards (toward newer trades) instead of forward.
326
+ - `includeFailed` — Include failed/rolled-back trades.
327
+ - `includeTotal` — Ask Steam for the total trade count (populates `totalTrades`).
328
+
329
+ Returns past trades, newest first, as a `Promise<`[`TradeHistory`](#tradehistory)`>`. Each entry is a
330
+ fully-parsed [`ExchangeDetails`](#exchangedetails) plus its `tradeId` and `partnerSteamId`. To page,
331
+ take the last entry's `tradeInitTime`/`tradeId` and pass them as `startAfterTime`/`startAfterTradeId`
332
+ while `more` is `true`.
333
+
334
+ ### getTradeOffersSummary()
335
+
336
+ Returns counts of pending/new/historical sent & received offers (and escrow counts) as a
337
+ `Promise<`[`TradeOffersSummary`](#tradeofferssummary)`>`. Cheap; useful as a "do I have anything to
338
+ look at?" check before a full `getOffers`.
339
+
340
+ ### reconcile(ids)
341
+
342
+ - `ids` — An array of trade offer id strings.
343
+
344
+ Re-reads each id in parallel and returns a `Promise<Map<string, TradeOffer>>` keyed by id. **Missing or
345
+ errored ids are skipped**, so one bad id can't fail the whole batch. This is the authoritative,
346
+ poll-free way to track offers you've sent: persist the id from `send()`, then reconcile it on demand.
347
+
348
+ ### getOffersContainingItems(items\[]\[, includeInactive])
349
+
350
+ - `items` — An array of `{ appid, contextid, assetid }` item references.
351
+ - `includeInactive` — Optional. If `true`, searches all offers; otherwise active only (default `false`).
352
+
353
+ Returns the offers that contain **all** of the given items, as a `Promise<TradeOffer[]>`.
354
+
355
+ ### getInventory(target, appid\[, contextid]\[, options])
356
+
357
+ - `target` — An [`OfferTarget`](#offertarget) identifying whose inventory to load (typically a partner via `{ tradeUrl }`).
358
+ - `appid` — The app id (e.g. `730` for CS2, `252490` for Rust).
359
+ - `contextid` — Optional. The context id (default `"2"`).
360
+ - `options`
361
+ - `tradableOnly` — Optional. If `true`, only tradable items are returned (default `false`).
362
+
363
+ Loads a **partner's** inventory through the trade-offer page (`/tradeoffer/new/partnerinventory/`),
364
+ which is the reliable way to see a partner's items — including trade-protected ones that the public
365
+ `/inventory/` endpoint hides. Returns `Promise<`[`EconItem`](#econitem)`[]>`, paginated automatically.
366
+ For your *own* inventory, prefer [`bot.community.getInventory`](#getinventoryappid-contextid-options).
367
+
368
+ On failure, the partner's trade page is lazily scraped for Steam's `<div id="error_msg">` and the
369
+ message is classified into the right typed [error](#errors) (`PrivateInventoryError`,
370
+ `TradeBanError`, `TargetCannotTradeError`, `ItemServerUnavailableError`, …).
371
+
372
+ ### startPolling(\[options])
373
+
374
+ - `options` — Optional [`PollOptions`](#polloptions):
375
+ - `pollInterval` — Active-poll cadence in ms (default `10000`).
376
+ - `pollFullUpdateInterval` — Full-sweep cadence in ms (default `300000`).
377
+ - `pollData` — A saved [`PollData`](#polldata) snapshot to resume from (so a restart doesn't re-emit known offers).
378
+ - `store` — A [`PollDataStore`](#polldatastore) loaded on start and saved after each changed tick (e.g. Redis-backed). Default: in-memory only.
379
+ - `maxAgeMs` — Retention window in ms (default `2592000000`, 30d). Terminal offers older than this are pruned from the snapshot, and the full sweep is bounded to this window.
380
+ - `cancelTime` — If set, auto-cancels a sent offer still `Active` this many ms after its last update, emitting [`sentOfferCanceled`](#event-sentoffercanceled). Off by default.
381
+
382
+ Starts the poll loop. The library does an **active poll** (recently-changed offers only) on the short
383
+ interval and a periodic **full sweep** (offers updated within the retention window, to catch backdated
384
+ state changes) on the long interval. State changes are diffed against the last snapshot and surfaced as
385
+ [events](#trade-events). The loop never dies on a rate limit — it backs off and resumes. Calling this
386
+ again replaces the running loop.
387
+
388
+ ### stopPolling()
389
+
390
+ Stops the poll loop.
391
+
392
+ ### pollOnce(\[options])
393
+
394
+ - `options` — Optional. A [`PollOptions`](#polloptions) (used to configure the lazily-created poller on first call) plus `forceFull?` to force a full sweep this cycle.
395
+
396
+ Runs a **single, timer-less** poll cycle, for driving cadence from an external scheduler (e.g. a BullMQ
397
+ cron) instead of the built-in timer. Returns `Promise<{ changes:`[`PollChange`](#pollchange)`[]; pollData: PollData }>`.
398
+ With a [`store`](#polldatastore) it loads the snapshot, diffs, emits the usual [events](#trade-events),
399
+ saves, and returns the diff. `lastFullUpdate` is persisted inside `pollData`, so a fresh instance per
400
+ job still keeps the full-sweep cadence (it won't degrade to a full sweep every call). The poller is
401
+ created lazily and reused, so pass config once. Unlike the timer loop, this **throws** on a fetch
402
+ failure so your scheduler can handle it.
403
+
404
+ ### pollData (getter)
405
+
406
+ A getter returning the current [`PollData`](#polldata) snapshot, or `undefined` if polling isn't
407
+ running. Usually you persist this via the [`pollData` event](#event-polldata) rather than reading it
408
+ directly.
409
+
410
+ ### Trade events
411
+
412
+ `bot.trade` (and `bot`, which re-emits them) emits:
413
+
414
+ #### Event: newOffer
415
+
416
+ - `offer` — A [TradeOffer](#tradeoffer) for the newly-received offer.
417
+
418
+ Emitted when someone sends you a new trade offer.
419
+
420
+ #### Event: receivedOfferChanged
421
+
422
+ - `offer` — The [TradeOffer](#tradeoffer) in its new state.
423
+ - `oldState` — The previous [`ETradeOfferState`](#etradeofferstate).
424
+
425
+ Emitted when an offer you received changes state (e.g. you accepted it elsewhere, or it expired).
426
+
427
+ #### Event: sentOfferChanged
428
+
429
+ - `offer` — The [TradeOffer](#tradeoffer) in its new state.
430
+ - `oldState` — The previous [`ETradeOfferState`](#etradeofferstate).
431
+
432
+ Emitted when an offer you sent changes state (e.g. the partner accepted or declined it).
433
+
434
+ #### Event: unknownOfferSent
435
+
436
+ - `offer` — The [TradeOffer](#tradeoffer).
437
+
438
+ Emitted when polling sees a sent offer it has no record of — either sent out-of-band (not via this
439
+ client) or seen on the first cold poll.
440
+
441
+ #### Event: sentOfferCanceled
442
+
443
+ - `offer` — The [TradeOffer](#tradeoffer) that was canceled.
444
+ - `reason` — Why it was canceled; currently always `"cancelTime"`.
445
+
446
+ Emitted when the poll loop auto-cancels a sent offer because it stayed `Active` past
447
+ [`cancelTime`](#startpollingoptions). The resulting `Active → Canceled` transition also surfaces as a
448
+ normal [`sentOfferChanged`](#event-sentofferchanged) on a later poll.
449
+
450
+ #### Event: offerUpdate
451
+
452
+ - `update` — A [`TradeOfferUpdate`](#tradeofferupdate) `{ offer, previousState? }`.
453
+
454
+ Fires once for **every** offer change, alongside the specific named event above. `previousState` is
455
+ `undefined` the first time an offer is observed; `offer.isOurOffer` distinguishes sent vs received. Use
456
+ this when you want a single handler for all changes (e.g. forwarding to a queue); use the named events
457
+ when you want to react to one kind.
458
+
459
+ #### Event: pollData
460
+
461
+ - `data` — The new [`PollData`](#polldata) snapshot.
462
+
463
+ Emitted whenever the poll snapshot changes. **Persist this** and pass it back as `pollData` on the next
464
+ `startPolling` to resume without re-emitting offers you've already handled.
465
+
466
+ #### Event: pollSuccess
467
+
468
+ Emitted after each successful poll cycle.
469
+
470
+ #### Event: pollFailure
471
+
472
+ - `error` — The `Error` that caused the failed poll.
473
+
474
+ Emitted when a poll cycle fails. The loop keeps running; this is informational.
475
+
476
+ #### Event: debug
477
+
478
+ - `message` — A human-readable debug string.
479
+
480
+ Verbose internal logging (cursor pages, mint/renewal, backoff). Listen on this while developing.
481
+
482
+ ---
483
+
484
+ ## TradeOffer
485
+
486
+ Represents a single trade offer — either one you're building to send, or one fetched from Steam. Build
487
+ offers with [`bot.trade.createOffer`](#createoffertarget); fetch them with
488
+ [`bot.trade.getTradeOffer`](#gettradeofferid) / `getOffers` / `getTradeOffers`.
489
+
490
+ ### Offer properties
491
+
492
+ - `id` — The offer id (`string`), or `undefined` before it's been sent.
493
+ - `partner` — A [`SteamID`](https://github.com/DoctorMcKay/node-steamid) for the other party.
494
+ - `token` — The partner's trade-URL token (`string`), if known.
495
+ - `message` — The offer message (≤128 chars).
496
+ - `state` — The current [`ETradeOfferState`](#etradeofferstate).
497
+ - `itemsToGive` — Array of [`TradeItem`](#tradeitem) (full [`EconItem`](#econitem)s on fetched offers) you'd give up.
498
+ - `itemsToReceive` — Array of items you'd receive.
499
+ - `isOurOffer` — `true` if we sent it, `false` if we received it.
500
+ - `tradeID` — The trade id once accepted (`string`), or `undefined`. Needed for [`getTradeStatus()`](#gettradestatus).
501
+ - `confirmationMethod` — An [`EConfirmationMethod`](#econfirmationmethod) (`MobileApp`, `Email`, or `None`).
502
+ - `escrowEnds` — A `Date` when the Steam Guard escrow hold ends, or `undefined`.
503
+ - `settlementDate` — A `Date` when the 2025 trade-protection hold ends and items become final, or `undefined`. Equals the trade's `time_settlement` (~7–8 days after accept); set only once `Accepted`. Separate from `escrowEnds`.
504
+ - `delaySettlement` — `true` when the trade's items are subject to the trade-protection settlement delay.
505
+ - `created` / `updated` / `expires` — `Date`s, or `undefined`.
506
+ - `fromRealTimeTrade` — `true` if this came from a real-time trade session.
507
+ - `glitched` — `true` when the offer is missing item names (descriptions not ready) or has no items. Polling won't advance its cutoff past a glitched offer, so it gets re-polled until complete.
508
+
509
+ ### give(items)
510
+
511
+ - `items` — An array of [`TradeItem`](#tradeitem) (`{ appid, contextid, assetid, amount? }`).
512
+
513
+ Adds items **you** will give. Chainable (returns the offer). Array-only.
514
+
515
+ ### receive(items)
516
+
517
+ - `items` — An array of [`TradeItem`](#tradeitem).
518
+
519
+ Adds items you'll receive from the partner. Chainable.
520
+
521
+ ### setMessage(message)
522
+
523
+ - `message` — The offer message (truncated to 128 characters).
524
+
525
+ Sets the message. Chainable. Throws if the offer has already been sent.
526
+
527
+ ### send()
528
+
529
+ Sends the offer. Before sending it best-effort acknowledges the 2025 trade-protection notice (which
530
+ Steam otherwise blocks new offers on). Returns a `Promise` resolving to a **`SendResult`**:
531
+
532
+ - `"sent"` — The offer is live; `offer.id` and `offer.state` (`Active`) are now set.
533
+ - `"needs_confirmation"` — The offer was created but needs a mobile/email confirmation. Call [`confirm()`](#confirm).
534
+
535
+ Throws a typed [error](#errors) on failure (e.g. `OfferLimitError`, `TradeBanError`,
536
+ `SteamSessionExpiredError`). Throws if the offer is empty or already sent.
537
+
538
+ ### accept()
539
+
540
+ Accepts an offer **you received**. Returns a `Promise` resolving to an **`AcceptResult`**:
541
+
542
+ - `"accepted"` — Done; items have transferred.
543
+ - `"escrow"` — Accepted but held in escrow (`offer.escrowEnds` tells you until when).
544
+ - `"needs_confirmation"` — Needs a mobile/email confirmation; call [`confirm()`](#confirm).
545
+
546
+ Because the accept response doesn't itself flag escrow, this re-reads the offer afterward to tell a
547
+ held trade from a settled one (best-effort; falls back to `"accepted"`). Throws if the offer isn't
548
+ active, is our own, or on a Steam error.
549
+
550
+ ### cancel()
551
+
552
+ Cancels an offer you sent (or, for a received offer, declines it) via the community endpoint — the Web
553
+ API rejects the mobile token for this. Returns `Promise<void>`. Throws if the offer isn't active /
554
+ pending-confirmation.
555
+
556
+ ### decline()
557
+
558
+ Alias for [`cancel()`](#cancel); use it for readability on received offers.
559
+
560
+ ### confirm()
561
+
562
+ Accepts the pending mobile confirmation for this offer (after `send()`/`accept()` returned
563
+ `"needs_confirmation"`). Returns `Promise<void>`. Throws a `ConfirmationError` immediately if the
564
+ offer is unsent, if `identitySecret` wasn't supplied to the `SteamMobile` constructor (named-fix
565
+ message: *"identitySecret is required to confirm trade offers — construct SteamMobile with
566
+ { identitySecret }"*), or if no matching confirmation is found.
567
+
568
+ ### counter()
569
+
570
+ Returns a **new** unsent [TradeOffer](#tradeoffer) to the same partner, pre-filled with this offer's
571
+ items and message (deep-copied, so editing the counter won't mutate the original). Edit it and call
572
+ `send()`; sending marks the original as countered. Throws if the original isn't active.
573
+
574
+ ### getTradeStatus()
575
+
576
+ Shortcut for [`bot.trade.getTradeStatus({ tradeId: this.tradeID })`](#gettradestatustradeid). Returns
577
+ `Promise<`[`ExchangeDetails`](#exchangedetails)`>`. Throws if the offer has no `tradeID` (i.e. it hasn't
578
+ been accepted).
579
+
580
+ ### getPartnerInventory(appid\[, contextid]\[, tradableOnly])
581
+
582
+ - `appid` — App id.
583
+ - `contextid` — Optional context id (default `"2"`).
584
+ - `tradableOnly` — Optional. Tradable items only.
585
+
586
+ Loads the **partner's** inventory, reusing this offer's partner and token. Returns
587
+ `Promise<`[`EconItem`](#econitem)`[]>`.
588
+
589
+ ### getUserDetails()
590
+
591
+ Persona, contexts, escrow days, avatars, and partner probation for both sides — McKay-parity. Scrapes
592
+ the trade page once. Picks the URL based on the offer's state: `/tradeoffer/<id>/` for offers that
593
+ have already been sent, otherwise `/tradeoffer/new/?partner=…[&token=…]` for an unsent draft. Returns
594
+ `Promise<`[`UserDetails`](#userdetails)`>`. **Steam applies `max(me.escrowDays, them.escrowDays)`** as
595
+ the actual hold — compute that yourself if you need a single number.
596
+
597
+ Use this when you already have a `TradeOffer` in hand. If you only have a tradeUrl/SteamID, prefer
598
+ the top-level [`bot.trade.getUserDetails(target)`](#getuserdetailstarget) — same scrape, no offer
599
+ construction needed.
600
+
601
+ Throws when called on an offer where Steam doesn't render trade info:
602
+
603
+ - An offer **we sent** (`isOurOffer === true` with `this.id` set) — Steam shows our own offers from a
604
+ different view. Use [`bot.trade.getUserDetails(target)`](#getuserdetailstarget) instead.
605
+ - An offer **received** but no longer `Active` (already accepted, declined, expired, etc.).
606
+
607
+ ### containsItem(item)
608
+
609
+ - `item` — `{ appid, contextid, assetid }`.
610
+
611
+ Returns `true` if either side of the offer contains that exact asset.
612
+
613
+ ---
614
+
615
+ ## bot.community
616
+
617
+ Account- and profile-level helpers backed by `steamcommunity.com` and a couple of Web API calls.
618
+
619
+ ### getInventory(appid\[, contextid]\[, options])
620
+
621
+ - `appid` — App id.
622
+ - `contextid` — Optional context id (default `"2"`).
623
+ - `options`
624
+ - `steamId` — Optional. Whose inventory to load (default: yourself).
625
+ - `tradableOnly` — Optional. If `true`, only tradable items are returned (default `false`). For your
626
+ own inventory this is applied server-side via the legacy endpoint's `trading=1` flag; for other
627
+ users it's applied in the parser.
628
+
629
+ Loads your own inventory — or any **public** inventory — paginated automatically. Returns
630
+ `Promise<`[`EconItem`](#econitem)`[]>`. Throws `PrivateInventoryError` on a private inventory (the
631
+ lazy trade-page scrape also classifies trade-ban / target-cannot-trade / item-server-unavailable
632
+ errors into their typed counterparts).
633
+
634
+ Routes by target:
635
+
636
+ - **Self** → the legacy `/profiles/<id>/inventory/json/<appid>/<contextid>` endpoint (cookie path,
637
+ near-nonexistent rate limits, surfaces trade-protected items). The modern
638
+ `IEconService/GetInventoryItemsWithDescriptions` is avoided here — Steam silently returns
639
+ `{response:{}}` on ≥2 calls/sec, making it unfit for repeated use.
640
+ - **Other user** → the public `/inventory/` endpoint (cookie path).
641
+
642
+ For a trade **partner's** inventory, use
643
+ [`bot.trade.getInventory`](#getinventorytarget-appid-contextid-options) instead — `/partnerinventory/`
644
+ is the only path that reveals trade-protected items.
645
+
646
+ ### getTradeURL()
647
+
648
+ Returns your current trade URL and token as `Promise<{ url: string; token: string }>`.
649
+
650
+ ### changeTradeURL()
651
+
652
+ Regenerates (and thereby **invalidates**) your trade URL/token. Returns the new
653
+ `Promise<{ url: string; token: string }>`.
654
+
655
+ ### getProfile(\[steamId])
656
+
657
+ - `steamId` — Optional. Whose profile to read (default: yourself).
658
+
659
+ Reads a profile summary from the community XML in a **single request**. Returns a
660
+ `Promise<`[`SteamProfile`](#steamprofile)`>` with persona, avatar, account-creation date, trade-ban
661
+ state, VAC status, privacy state, and the `isLimited` flag. (Steam level isn't in the XML — use
662
+ `getSteamLevel`.)
663
+
664
+ ### getSteamLevel(\[steamId])
665
+
666
+ - `steamId` — Optional (default: yourself).
667
+
668
+ Returns the account's Steam level as a `Promise<number>` via `IPlayerService/GetSteamLevel` (which
669
+ accepts the access token — no API key required).
670
+
671
+ ### community.ensureApiKey(\[domain])
672
+
673
+ - `domain` — Optional hostname for the key (default `"assetpay.gg"`).
674
+
675
+ Returns the existing Web API key, or registers a new one (auto-accepting the mobile confirmation if
676
+ `identitySecret` is set), as `Promise<string | null>`. Resolves to `null` when the account is
677
+ ineligible (unverified email, no authenticator, or limited). The registration flow handles Steam's
678
+ Pending → confirm → retry handshake for you.
679
+
680
+ ### acknowledgeTradeProtection()
681
+
682
+ Acknowledges the 2025 trade-protection notice. Called automatically by [`send()`](#send); exposed here
683
+ for completeness. Returns `Promise<void>`.
684
+
685
+ ---
686
+
687
+ ## bot.session
688
+
689
+ The `SessionManager` owns the token lifecycle. You rarely call it directly — the namespaces refresh the
690
+ access token for you — but it's where session control lives.
691
+
692
+ ### getAccessToken()
693
+
694
+ Returns a valid access token, minting or renewing it if needed, as `Promise<string>`. Concurrent calls
695
+ share one in-flight mint. Throws `SteamSessionExpiredError` if the session has been revoked or the
696
+ refresh token is dead.
697
+
698
+ ### listSessions()
699
+
700
+ Returns the client ids of in-progress auth sessions (login-approval flows) as `Promise<bigint[]>`.
701
+ Note: this is **not** a list of logged-in devices — it reflects pending `IAuthenticationService`
702
+ sessions.
703
+
704
+ ### logout(\[action])
705
+
706
+ - `action` — Optional [`EAuthTokenRevokeAction`](#eauthtokenrevokeaction) (default `Logout`).
707
+
708
+ Revokes this refresh token server-side. Afterward the session is dead and any further call throws
709
+ `SteamSessionExpiredError`. Returns `Promise<void>`.
710
+
711
+ ### setRefreshToken(refreshToken)
712
+
713
+ - `refreshToken` — A fresh MobileApp refresh token for the **same** account.
714
+
715
+ Swaps in a new refresh token and re-mints in place — the mechanism behind
716
+ [`bot.reauthenticate`](#reauthenticatecredentials). Re-emits [`refreshToken`](#event-refreshtoken).
717
+ Throws if the token is malformed or belongs to a different account. Returns `Promise<void>`.
718
+
719
+ ### Properties & events
720
+
721
+ - `refreshToken` / `accessToken` / `steamID` — Same values surfaced on [`SteamMobile`](#properties).
722
+ - Events: `refreshToken`, `sessionExpired`, `debug` — re-emitted on the root client.
723
+
724
+ ---
725
+
726
+ ## bot.confirmations
727
+
728
+ The `ConfirmationManager` for mobile confirmations. **Prefer [`offer.confirm()`](#confirm)** for trades;
729
+ these cover other cases (market listings, API-key requests, manual flows). All require `identitySecret`.
730
+
731
+ The high-level helpers below derive the TOTP time and HMAC key for you — you normally only need these.
732
+ The lower-level `getConfirmations`/`respondToConfirmation` remain for when you want to compute keys yourself.
733
+
734
+ ### getPending()
735
+
736
+ Returns all outstanding confirmations as `Promise<`[`Confirmation`](#confirmation)`[]>`, deriving the
737
+ list HMAC automatically.
738
+
739
+ ### acceptConfirmation(id, nonce) · rejectConfirmation(id, nonce)
740
+
741
+ Accept or reject (cancel) a single confirmation by its `id` and `nonce` (the `key` field from
742
+ [`getPending`](#getpending)). Each returns `Promise<void>` and handles the time-offset + per-request
743
+ HMAC timestamp for you.
744
+
745
+ ### acceptAll()
746
+
747
+ Accepts every pending confirmation and resolves to the [`Confirmation`](#confirmation)`[]` it acted on.
748
+ Fails fast on the first error.
749
+
750
+ ```ts
751
+ for (const c of await bot.confirmations.getPending()) console.log(c.title);
752
+ await bot.confirmations.acceptAll();
753
+ ```
754
+
755
+ ### getConfirmations(time, key)
756
+
757
+ - `time` — A Unix timestamp (use the manager's server-time-corrected clock).
758
+ - `key` — A confirmation key, or `{ tag, key }`.
759
+
760
+ Returns the outstanding confirmations as `Promise<`[`Confirmation`](#confirmation)`[]>`.
761
+
762
+ ### respondToConfirmation(confID, confKey, time, key, accept)
763
+
764
+ - `confID` — The confirmation id.
765
+ - `confKey` — The confirmation's nonce.
766
+ - `time` — A Unix timestamp.
767
+ - `key` — A confirmation key, or `{ tag, key }`.
768
+ - `accept` — `true` to allow, `false` to cancel.
769
+
770
+ Accepts or rejects a single confirmation. Returns `Promise<void>`.
771
+
772
+ ### acceptConfirmationForObject(objectID)
773
+
774
+ - `objectID` — The id of the object the confirmation is for (e.g. a trade offer id, or an API-key request id).
775
+
776
+ Finds and accepts the confirmation tied to that object, handling the time-offset and per-request HMAC
777
+ timestamps for you. Returns `Promise<void>`. Throws a `ConfirmationError` if no matching confirmation
778
+ exists.
779
+
780
+ ---
781
+
782
+ ## decodePreviewToken
783
+
784
+ A standalone helper (not on the bot) that decodes a **CS2 masked preview token** — the inspect-data
785
+ blob Steam ships in an item's `asset_properties` under `propertyid: 6` (the "certificate") — into a
786
+ plain JSON object. The same masked format is used by `csgo_econ_action_preview` market inspect tokens.
787
+ Decoding is fully offline (XOR-unmask → protobuf), so no game-coordinator call is needed.
788
+
789
+ ```ts
790
+ import { decodePreviewToken } from "@assetpay/steam-mobile";
791
+
792
+ const cert = item.asset_properties.find((p) => p.propertyid === 6)?.string_value;
793
+ const data = decodePreviewToken(cert);
794
+ // {
795
+ // itemid: "51663785755", defindex: 4, paintindex: 230, rarity: 4, quality: 9,
796
+ // paintwear: 0.18685653805732727, paintseed: 34,
797
+ // stickers: [{ slot: 2, stickerId: 6618 }, …],
798
+ // inventory: 3221225475, origin: 8,
799
+ // keychains: [{ slot: 0, stickerId: 54, offsetX: -1.55…, pattern: 34792 }],
800
+ // }
801
+ ```
802
+
803
+ - `hex` — The raw certificate hex (or any `[xorKey][protobuf][crc32]` masked blob). Pass it as-is; no
804
+ trimming or tag-stripping needed.
805
+ - Returns the decoded [`CEconItemPreviewDataBlock`](https://github.com/SteamDatabase/Protobufs/blob/master/csgo/cstrike15_gcmessages.proto#L917)
806
+ as a plain object (`Record<string, unknown>`), or `null` for non-hex / undecodable input.
807
+
808
+ Output notes: `itemid` (uint64) is a **string**, fields are **camelCase** (`sticker_id` → `stickerId`,
809
+ `offset_x` → `offsetX`), only fields actually present on the wire are included, and `paintwear` is
810
+ returned as the **float wear** (`0..1`) rather than its raw uint32 bits.
811
+
812
+ > S/A/D inspect links (the ones with a `D…` parameter) are **not** decodable this way — those carry no
813
+ > embedded data and require a live game-coordinator lookup.
814
+
815
+ ---
816
+
817
+ ## Data types
818
+
819
+ ### OfferTarget
820
+
821
+ Identifies a trade partner. It's a discriminated union — supply **one** of the two shapes:
822
+
823
+ ```ts
824
+ type OfferTarget =
825
+ | { tradeUrl: string } // a full trade URL (partner + token parsed out)
826
+ | { steamId: string; token?: string }; // a SteamID64, optionally with a trade token
827
+ ```
828
+
829
+ ### TradeItem
830
+
831
+ The minimal shape needed to put an item into an offer:
832
+
833
+ ```ts
834
+ interface TradeItem {
835
+ appid: number;
836
+ contextid: string;
837
+ assetid: string;
838
+ amount?: number; // default 1
839
+ }
840
+ ```
841
+
842
+ ### EconItem
843
+
844
+ The fully-typed item, with Steam's fields preserved 1:1 (snake_case, lossless). Returned by every
845
+ inventory and offer read. Key fields:
846
+
847
+ - `appid` (number), `contextid`, `assetid`, `classid`, `instanceid`, `amount`, `currencyid?`
848
+ - `name`, `market_name`, `market_hash_name`, `type`, `name_color?`, `background_color?`
849
+ - `icon_url`, `icon_url_large?`
850
+ - `tradable`, `marketable`, `commodity` (booleans), `market_tradable_restriction`, `market_marketable_restriction` (numbers)
851
+ - `descriptions`, `owner_descriptions` ([`SteamDescriptionLine`](#data-types)[]), `actions`, `market_actions` (`SteamAction[]`), `fraudwarnings` (string[]), `tags` (`SteamTag[]`)
852
+ - `asset_properties` ([`AssetProperty`](#data-types)[]) — CS2 float/seed/sticker data when present; decode the `propertyid: 6` certificate with [`decodePreviewToken`](#decodepreviewtoken)
853
+ - Any other field Steam returns is preserved (`[key: string]: unknown`).
854
+
855
+ ### ExchangeItem
856
+
857
+ An [`EconItem`](#econitem) plus where it landed after the trade settled:
858
+
859
+ ```ts
860
+ interface ExchangeItem extends EconItem {
861
+ new_assetid?: string; // Rust populates these; CS2 usually omits them
862
+ new_contextid?: string;
863
+ rollback_new_assetid?: string;
864
+ rollback_new_contextid?: string;
865
+ }
866
+ ```
867
+
868
+ ### ExchangeDetails
869
+
870
+ Returned by [`getTradeStatus`](#gettradestatustradeid):
871
+
872
+ ```ts
873
+ interface ExchangeDetails {
874
+ status: ETradeStatus;
875
+ tradeInitTime: Date;
876
+ settlementTime: Date | null; // when items were actually delivered
877
+ receivedItems: ExchangeItem[];
878
+ sentItems: ExchangeItem[];
879
+ usedInventoryFallback: boolean; // always false — surfacing new_assetid is enough; reconciling by inventory diff is the server's job
880
+ }
881
+ ```
882
+
883
+ ### TradeHistory
884
+
885
+ Returned by [`getTradeHistory`](#gettradehistoryoptions):
886
+
887
+ ```ts
888
+ interface TradeHistory {
889
+ trades: TradeHistoryEntry[]; // newest first
890
+ more: boolean; // are there more pages?
891
+ totalTrades: number | undefined; // only when includeTotal was set
892
+ }
893
+
894
+ interface TradeHistoryEntry extends ExchangeDetails {
895
+ tradeId: string;
896
+ partnerSteamId: string | undefined;
897
+ }
898
+ ```
899
+
900
+ ### TradeHistoryOptions
901
+
902
+ See [`getTradeHistory`](#gettradehistoryoptions) for field meanings: `maxTrades?`, `startAfterTime?`,
903
+ `startAfterTradeId?`, `navigatingBack?`, `includeFailed?`, `includeTotal?`.
904
+
905
+ ### TradeOffersSummary
906
+
907
+ Returned by [`getTradeOffersSummary`](#gettradeofferssummary) — all `number`:
908
+
909
+ `pending_received_count`, `new_received_count`, `updated_received_count`, `historical_received_count`,
910
+ `pending_sent_count`, `newly_accepted_sent_count`, `updated_sent_count`, `historical_sent_count`,
911
+ `escrow_received_count`, `escrow_sent_count`.
912
+
913
+ ### SteamProfile
914
+
915
+ Returned by [`getProfile`](#getprofilesteamid):
916
+
917
+ ```ts
918
+ interface SteamProfile {
919
+ steamId: string;
920
+ personaName: string;
921
+ avatar: string;
922
+ accountCreated: Date | null;
923
+ tradeBanState: string; // e.g. "None"
924
+ isLimited: boolean;
925
+ vacBanned: boolean;
926
+ privacyState: string; // e.g. "public"
927
+ }
928
+ ```
929
+
930
+ ### UserDetails
931
+
932
+ Returned by [`bot.trade.getUserDetails(target)`](#getuserdetailstarget) and
933
+ [`offer.getUserDetails()`](#getuserdetails) — mirrors steamcommunity's `TradeOffer#getUserDetails`:
934
+
935
+ ```ts
936
+ interface UserDetails {
937
+ me: UserSideDetails;
938
+ them: UserPartnerDetails;
939
+ }
940
+
941
+ interface UserSideDetails {
942
+ personaName: string;
943
+ contexts: Record<string, unknown> | null;
944
+ escrowDays: number; // your side; Steam holds for max(me, them) days
945
+ avatarIcon: string | undefined; // .jpg
946
+ avatarMedium: string | undefined; // _medium.jpg
947
+ avatarFull: string | undefined; // _full.jpg
948
+ }
949
+
950
+ interface UserPartnerDetails extends UserSideDetails {
951
+ probation: boolean; // partner is on Steam-trade probation (see Steam's warning banner)
952
+ }
953
+ ```
954
+
955
+ ### EscrowHold
956
+
957
+ Returned by [`bot.trade.getEscrow`](#getescrowtarget) — escrow-only, in **seconds**. Top-level keys
958
+ renamed from Steam's `my_escrow`/`their_escrow`/`both_escrow`; inner shape preserved 1:1:
959
+
960
+ ```ts
961
+ interface EscrowHold {
962
+ me: EscrowSide;
963
+ them: EscrowSide;
964
+ both: EscrowSide;
965
+ }
966
+
967
+ interface EscrowSide {
968
+ escrow_end_duration_seconds: number;
969
+ }
970
+ ```
971
+
972
+ ### PollData
973
+
974
+ The poll snapshot — persist it via the [`pollData` event](#event-polldata) and pass it back to
975
+ [`startPolling`](#startpollingoptions):
976
+
977
+ ```ts
978
+ interface PollData {
979
+ offersSince: number; // Unix seconds of the newest processed update
980
+ sent: Record<string, ETradeOfferState>; // offer id -> last seen state
981
+ received: Record<string, ETradeOfferState>;
982
+ timestamps: Record<string, number>;
983
+ lastFullUpdate?: number; // ms epoch of the last full sweep; persisted so the cadence survives stateless workers
984
+ }
985
+ ```
986
+
987
+ ### PollOptions
988
+
989
+ See [`startPolling`](#startpollingoptions): `pollInterval?` (default 10000 ms), `pollFullUpdateInterval?`
990
+ (default 300000 ms), `pollData?`, `store?` (a [`PollDataStore`](#polldatastore)), `maxAgeMs?` (default
991
+ 30d), `cancelTime?` (off by default).
992
+
993
+ ### PollDataStore
994
+
995
+ Pluggable persistence for the poll snapshot — implement it to keep `pollData` in Redis (or anywhere) so
996
+ any worker can resume a bot's poll. You own one-tick-at-a-time mutual exclusion; the library owns the
997
+ diff. Use it with [`startPolling`](#startpollingoptions) (timer) or [`pollOnce`](#pollonceoptions) (cron).
998
+
999
+ ```ts
1000
+ interface PollDataStore {
1001
+ load(): Promise<PollData | undefined>;
1002
+ save(pollData: PollData): Promise<void>;
1003
+ }
1004
+ ```
1005
+
1006
+ ### PollChange
1007
+
1008
+ One offer transition returned by [`pollOnce`](#pollonceoptions) (the same set is also emitted as
1009
+ [trade events](#trade-events)):
1010
+
1011
+ ```ts
1012
+ type PollChange =
1013
+ | { type: "newOffer"; offer: TradeOffer }
1014
+ | { type: "sentOfferChanged"; offer: TradeOffer; oldState: ETradeOfferState }
1015
+ | { type: "receivedOfferChanged"; offer: TradeOffer; oldState: ETradeOfferState }
1016
+ | { type: "unknownOfferSent"; offer: TradeOffer };
1017
+ ```
1018
+
1019
+ ### TradeOfferUpdate
1020
+
1021
+ The payload of the [`offerUpdate`](#event-offerupdate) event — a unified view of any single offer
1022
+ change. `previousState` is `undefined` the first time an offer is seen; direction is `offer.isOurOffer`:
1023
+
1024
+ ```ts
1025
+ interface TradeOfferUpdate {
1026
+ offer: TradeOffer;
1027
+ previousState?: ETradeOfferState;
1028
+ }
1029
+ ```
1030
+
1031
+ ### Confirmation
1032
+
1033
+ Returned by [`getConfirmations`](#getconfirmationstime-key): `id`, `type`, `creator`, `key` (nonce),
1034
+ `title`, `receiving`, `sending`, `time` (ISO string), `timestamp` (`Date`), `icon`.
1035
+
1036
+ ---
1037
+
1038
+ ## Enums
1039
+
1040
+ All are exported and have the standard Steam numeric values.
1041
+
1042
+ ### ETradeOfferState
1043
+
1044
+ `Invalid (1)`, `Active (2)`, `Accepted (3)`, `Countered (4)`, `Expired (5)`, `Canceled (6)`,
1045
+ `Declined (7)`, `InvalidItems (8)`, `CreatedNeedsConfirmation (9)`, `CanceledBySecondFactor (10)`,
1046
+ `InEscrow (11)`, `Reverted (12)`.
1047
+
1048
+ ### ETradeStatus
1049
+
1050
+ `Init (0)`, `PreCommitted (1)`, `Committed (2)`, `Complete (3)`, `Failed (4)`,
1051
+ `PartialSupportRollback (5)`, `FullSupportRollback (6)`, `SupportRollback_Selective (7)`,
1052
+ `RollbackFailed (8)`, `RollbackAbandoned (9)`, `InEscrow (10)`, `EscrowRollback (11)`, `Reverted (12)`.
1053
+
1054
+ ### EConfirmationMethod
1055
+
1056
+ `None (0)`, `Email (1)`, `MobileApp (2)`.
1057
+
1058
+ ### EConfirmationType
1059
+
1060
+ `Invalid (0)`, `Generic (1)`, `Trade (2)`, `MarketListing (3)`, `FeatureOptOut (4)`,
1061
+ `PhoneNumberChange (5)`, `AccountRecovery (6)`.
1062
+
1063
+ ### EOfferFilter
1064
+
1065
+ `ActiveOnly (1)`, `HistoricalOnly (2)`, `All (3)`.
1066
+
1067
+ ### EAuthSessionGuardType
1068
+
1069
+ `Unknown (0)`, `None (1)`, `EmailCode (2)`, `DeviceCode (3)`, `DeviceConfirmation (4)`,
1070
+ `EmailConfirmation (5)`, `MachineToken (6)`, `LegacyMachineAuth (7)`.
1071
+
1072
+ ### EAuthTokenRevokeAction
1073
+
1074
+ `Logout (0)`, `Permanent (1)`, `Replaced (2)`, `Support (3)`, `Consume (4)`, `NonRememberedLogout (5)`,
1075
+ `NonRememberedPermanent (6)`, `Automatic (7)`. Used by [`bot.session.logout`](#logoutaction).
1076
+
1077
+ ### Others
1078
+
1079
+ `EAuthTokenPlatformType`, `ESessionPersistence`, `ETokenRenewalType`, and the full `EResult` table are
1080
+ also exported.
1081
+
1082
+ ---
1083
+
1084
+ ## Errors
1085
+
1086
+ Every error extends **`SteamError`**, which carries an optional `eresult` (a Steam `EResult`) and `body`
1087
+ (the raw response). The library **classifies** errors but does not implement a cooldown policy — retry
1088
+ logic stays in your code.
1089
+
1090
+ Steam-supplied messages (the `strError` field on send/accept/cancel responses **and** the
1091
+ `<div id="error_msg">` text scraped from the trade page when an inventory load fails) are run through
1092
+ one shared classifier so the same `instanceof` check works everywhere.
1093
+
1094
+ | Class | Extra fields | Meaning |
1095
+ | ----- | ------------ | ------- |
1096
+ | `SteamError` | `eresult?`, `body?` | Base class for everything. |
1097
+ | `HttpStatusError` | `statusCode` | A non-2xx HTTP response. |
1098
+ | `SteamSessionExpiredError` | — | The session/token is no longer valid; re-authenticate. |
1099
+ | `RateLimitError` | `statusCode?`, `retryAfterMs`, `unlockAt` | Rate limited (HTTP 429 or eresult 84). `unlockAt` is a millisecond epoch when you may retry — **always populated** (a conservative default when Steam gives no hint). |
1100
+ | `ProxyError` | `cause?` | A request through a configured proxy failed at the transport layer (unreachable / refused / timeout / auth). Only thrown when a `proxy` is set. |
1101
+ | `EscrowError` | `escrowDays` | The trade would be (or is) held in escrow. |
1102
+ | `TradeBanError` | — | The account is trade-banned. |
1103
+ | `OfferLimitError` | `eresult = 25` | Sent too many offers. |
1104
+ | `TargetCannotTradeError` | `eresult?` | Partner is not available to trade (limited, escrow-only, etc.). |
1105
+ | `NewDeviceError` | `eresult?` | Steam blocks the action because we logged in from a new device recently. |
1106
+ | `ItemServerUnavailableError` | `eresult = 102` | The game's item server (its Game Coordinator) is unreachable — transient, per-app, retry shortly. |
1107
+ | `PrivateInventoryError` | — | Partner's inventory is private (or friends-only and we're not friends). A trade URL/token does **not** bypass inventory privacy. |
1108
+ | `ConfirmationError` | — | A mobile-confirmation step failed (incl. missing `identitySecret` when calling [`offer.confirm()`](#confirm)). |
1109
+ | `FamilyViewError` | — | Family View is restricting the account. |
1110
+ | `LoginError` | `extendedErrorMessage?`, `isTransient` | Credential-login failure; `isTransient` is `true` for a retryable blip (timeout / service unavailable) rather than bad credentials. |
1111
+ | `NoMobileAuthenticatorError` | (extends `LoginError`) | `sharedSecret` was supplied but the account has no mobile authenticator attached, so TOTP can't answer the guard challenge. Use `steamGuardCode` / `onSteamGuardRequired` with an email code instead. |
1112
+
1113
+ `unlockAt` / `retryAfterMs` on `RateLimitError` are never `undefined`, so a cooldown is just
1114
+ `cooldownUntil = err.unlockAt`:
1115
+
1116
+ ```ts
1117
+ import { RateLimitError } from "@assetpay/steam-mobile";
1118
+
1119
+ try {
1120
+ await bot.trade.getTradeOffers();
1121
+ } catch (err) {
1122
+ if (err instanceof RateLimitError) {
1123
+ await sleepUntil(err.unlockAt);
1124
+ } else throw err;
1125
+ }
1126
+ ```
1127
+
1128
+ ---
1129
+
1130
+ ## Mobile-app impersonation
1131
+
1132
+ The client presents as the Steam mobile app on the wire: per-host user agents, the `mobileClient`
1133
+ cookie, an `origin=SteamMobile` header on writes, and `mobileconf` confirmations. Pick a preset with
1134
+ `mobileProfile: "ios"` (default) or `"android"`, or override individual fields with an object.
1135
+
1136
+ A **`MobileProfile`** has: `mobileClient` (`"ios"` | `"android"`), `mobileClientVersion`,
1137
+ `apiUserAgent`, `webUserAgent`, `deviceFriendlyName` (iOS sends the model id, e.g. `"iPhone18,3"`),
1138
+ `osType` (signed EOSType; iOS `-600`, Android `-500`), `gamingDeviceType`, and `appType?`. The
1139
+ `IOS_PROFILE` and `ANDROID_PROFILE` constants (captured from real apps) and `resolveMobileProfile` are
1140
+ exported if you want to build on them.
1141
+
1142
+ ```ts
1143
+ const bot = new SteamMobile({
1144
+ refreshToken,
1145
+ mobileProfile: { mobileClient: "ios", deviceFriendlyName: "iPhone17,2" },
1146
+ });
1147
+ ```
1148
+
1149
+ ---
1150
+
1151
+ ## Notes & limitations
1152
+
1153
+ - **MobileApp-only.** No real-time CM push — offer detection is polling. No Game Coordinator (item data
1154
+ comes from the web `asset_properties`). No chat/presence.
1155
+ - **Limited accounts** (haven't spent the $5 that lifts Steam's anti-spam limit) **can still trade** —
1156
+ subject to the usual Steam Guard / escrow holds — but can't use the Community Market or generate a
1157
+ Web API key. This library doesn't use a Web API key (it authenticates with the mobile access token),
1158
+ so limited accounts can send and receive trades through it. `getProfile().isLimited` surfaces the
1159
+ state; `ensureApiKey()` returns `null`.
1160
+ - **English only** (`l=english`) by design — no localization dependency.
1161
+ - **Settlement is read-only.** `getTradeStatus` surfaces Steam's `new_assetid`/`new_contextid`;
1162
+ reconciling items by inventory diff (the race-prone part) is left to your server, which has the
1163
+ concurrency context. `usedInventoryFallback` is therefore always `false`.
1164
+ - **No auto-retry.** Rate limits are thrown as `RateLimitError` — always with a concrete `unlockAt`, so
1165
+ the caller's cooldown needs no guesswork (the poll loop is the one exception: it backs off and resumes).
1166
+
1167
+ ---
1168
+
1169
+ ## Development
1170
+
1171
+ ```bash
1172
+ pnpm install
1173
+ pnpm typecheck # tsc --noEmit (strict NodeNext)
1174
+ pnpm lint # biome
1175
+ pnpm test # vitest
1176
+ pnpm build # tsdown → dist (ESM + d.ts)
1177
+ pnpm proto # regenerate src/protobufs from protobufs/*.proto (buf + protoc-gen-es)
1178
+ ```
1179
+
1180
+ Live debug scripts live in `debug/` and read a `.env` for credentials:
1181
+
1182
+ ```bash
1183
+ pnpm bootstrap # credential login → save ./bot.refreshtoken (run once)
1184
+ pnpm smoke # read-only health check across the whole API
1185
+ pnpm watch # live trade-event watcher — send the bot a trade and watch it fire
1186
+ pnpm trade # send → confirm → cancel lifecycle (gated: SEND=1 PARTNER_TRADE_URL=…; needs a non-limited bot)
1187
+ pnpm partner-inventory # load a partner's inventory via /partnerinventory/ (PARTNER_TRADE_URL=…; surfaces PrivateInventoryError etc.)
1188
+ ```