Endpoints
Inventory
List every item across every account, then sell, send, store, use, or trade up a selection.
Everything the Inventory page does, over HTTP: walk your items across every account, and run any of the page's selection actions against them. CS2 storage units and what is inside them live here too.
Two scopes cover the domain. inventory.read for the listings and lookups, inventory.write for the five bulk writes and the two storage unit writes.
Selecting items to act on
Five writes (sell, send, store, use, trade up) share one selection contract, in one of two shapes.
An explicit list of asset row ids, capped at 1,000:
{ "asset_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5", "019fb42e-9a7e-728d-b960-8b4c2162898c"] }Or a description of the selection, using the same filter keys GET /api/v1/inventory accepts:
{ "filters": { "game": ["cs2"], "marketable": "sellable", "search": "case" } }Send exactly one of them. Both, or neither, is a 422: an empty body would otherwise mean "every item I own", which is the most expensive mistake this API could offer. Over the id cap you get 422:
{ "message": "Too many asset_ids in one request. Send at most 1000, or describe the selection with filters.", "code": "bulk_limit_exceeded", "max": 1000 }Ids in asset_ids are SteamLabs row ids (the id field on a listing row), not Steam's asset_id. Ids you do not own are dropped from the selection rather than refused, so they are never confirmed to exist.
Select-all caps
| Selection | Cap |
|---|---|
asset_ids |
1,000 ids per request |
filters |
The first 1,000 matching assets, ordered by id |
| Dashboard Select all in grouped mode | 500 stacks |
The 500 stack cap is the dashboard's own, on its grouped tab. Over the API you always act on assets, so 1,000 is the number that matters.
Both shapes queue worker tasks and both answer 202 Accepted. See Bulk operations for the wider contract.
Every write needs an Idempotency-Key
List inventory
One endpoint, three modes over the same filtered set.
/api/v1/inventoryYour items across every account, paginated and filterable.
Requires inventory.read.
Value summary
Use the summary endpoint to compare the filtered inventory at each market's latest reference prices. It accepts the same game, accounts, tags, venue, tradable, marketable, location, rarity, search, price_min, and price_max filters as the listing.
/api/v1/inventory/summaryTotal item counts and inventory value by market price source.
Requires inventory.read.
{
"data": {
"total_items": 3542,
"unique_items": 483,
"values": {
"steam_market": { "value_cents": 50255, "currency": "USD" },
"csfloat": { "value_cents": 31944, "currency": "USD" },
"marketcsgo": { "value_cents": 32518, "currency": "USD" }
}
}
}An item without a price from one market contributes zero to that market's total. The other market totals still include it when their price exists.
Modes
mode |
One row is | Sorts (first is the default) |
|---|---|---|
flat (default) |
One Steam asset | recent, value, name, float |
grouped |
One catalog item, stacked across every account | recent, value, name, quantity |
units |
One CS2 storage unit | fill, name |
A sort that is not in the list for the mode you asked for is a 422, so a sort meant for one mode cannot silently fall back on another.
Parameters
| Field | Type | Description |
|---|---|---|
mode |
string | flat, grouped, or units. Anything else falls back to flat |
sort |
string | One of the mode's sorts above |
game |
array | cs2, tf2, steam. Any of them matches |
accounts |
array | Steam account ids you own, at most 1,000 |
tags |
array | Account tag ids you own. Items on accounts carrying any of them |
groups |
array | Account group ids you own. Items on accounts in any selected group |
include_ungrouped |
boolean | Include items held by accounts with no group |
venue |
array | steam, csfloat, marketcsgo, skinland, dmarket, skinport, assetpay. Items sitting on accounts that can sell there right now |
tradable |
boolean | true or false |
marketable |
string | 1 marketable, 0 not marketable, sellable for marketable items on an account that could really list them (authenticator on file, market access confirmed) |
location |
string | main for the plain inventory, storage for items inside a storage unit, protected for items Steam is holding under CS2 trade protection |
rarity |
array | CS2 grades by number, 1 to 7 (see below). Any of them matches |
search |
string | Matches the item's market hash name. Split on spaces, every word must appear, in any order |
price_min |
number | Lowest Steam Community Market price to include, in your preferred display currency. Unpriced items count as 0 |
price_max |
number | Highest Steam Community Market price to include, in your preferred display currency. Must be at least 0 |
page |
integer | 1-indexed, default 1 |
per_page |
integer | Default 50, clamped to 200 |
The array filters are real arrays: send game[]=cs2&game[]=tf2. A bare game=cs2 is a 422.
An account id or tag id you do not own is also a 422, not an empty page. "That is not yours" and "that account holds nothing" are different answers and you should not have to guess which you got.
venue is a property of the account, not of the item: it narrows to items held by accounts that are ready to sell on that venue.
Storage units never appear among the items in flat or grouped mode. A unit is a container, not an item. What is inside one does appear, and location=storage isolates it.
location is a three-way split, so main means "free to act on right now". An item inside a storage unit is storage until you withdraw it, and an item Steam is holding under CS2 trade protection is protected until the hold ends. Both are yours, both count in total_items and in the summary's values, and neither is part of main.
rarity takes the grade numbers the Game Coordinator uses, which are also what each row returns in its rarity field. The same number covers the matching grade in every item family, so 6 returns Covert weapons and knives, Extraordinary gloves and stickers, and Master agents alike.
rarity |
Weapons and knives | Agents | Everything else |
|---|---|---|---|
1 |
Consumer Grade | Base Grade | |
2 |
Industrial Grade | Industrial Grade | |
3 |
Mil-Spec Grade | Distinguished | High Grade |
4 |
Restricted | Exceptional | Remarkable |
5 |
Classified | Superior | Exotic |
6 |
Covert | Master | Extraordinary |
7 |
Contraband | Contraband |
A row's grade is the Game Coordinator's rarity when an inventory refresh has read it, otherwise the grade named in the item's Steam type ("Classified Rifle", "Base Grade Container"). Selecting any grade drops items that have neither: other games, and CS2 items whose type carries no grade.
curl "https://dashboard.steamlabs.dev/api/v1/inventory?mode=flat&game[]=cs2&marketable=sellable&sort=value&per_page=2" \
-H "Authorization: Bearer $STEAMLABS_API_KEY"items = requests.get(
"https://dashboard.steamlabs.dev/api/v1/inventory",
headers={"Authorization": f"Bearer {api_key}"},
params={
"mode": "flat",
"game[]": "cs2",
"marketable": "sellable",
"sort": "value",
"per_page": 200,
},
).json()const query = new URLSearchParams({
mode: 'flat',
'game[]': 'cs2',
marketable: 'sellable',
sort: 'value',
per_page: '200',
});
const response = await fetch(`https://dashboard.steamlabs.dev/api/v1/inventory?${query}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const { data, meta } = await response.json();A flat row:
{
"data": [
{
"id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
"asset_id": "38295610447",
"game": "cs2",
"context_id": 2,
"account": {
"id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
"username": "farm_017",
"persona_name": "Ada"
},
"item": {
"id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44",
"market_hash_name": "AK-47 | Redline (Field-Tested)",
"name": "AK-47 | Redline",
"image_url": "https://community.fastly.steamstatic.com/economy/image/…",
"type": "Classified Rifle"
},
"price_cents": 1842,
"tradable": true,
"tradable_after": null,
"marketable": true,
"location": "main",
"casket_id": null,
"reserved_marketplace": null,
"reserved_sold": false,
"custom_name": null,
"stattrak": false,
"kill_eater_value": null,
"float": 0.2317884,
"wear": "field_tested",
"paint_seed": 411,
"paint_index": 282,
"def_index": 7,
"origin": 8,
"quality": 4,
"rarity": 5,
"stickers": [
{ "stickerId": 5032, "wear": 0.12 }
],
"keychains": [],
"acquired_at": "2026-07-12T08:41:03+00:00"
}
],
"meta": { "page": 1, "per_page": 2, "total": 4182, "last_page": 2091 }
}Trade protection is Steam's seven-day hold on a CS2 item that arrived in a trade. An item you have listed on the Steam Community Market sits in the same place on Steam's side, but it is not an inventory row here: a listing is a listing, and it would otherwise be counted twice. A held item stays in the account and keeps its value, but it cannot be traded, listed, used or moved into storage, so it comes back tradable: false, marketable: false, location: "protected" and context_id: 16. Sell, send, store and trade-up calls skip held items the way they skip stored ones. The end of the hold is on tradable_after, and the same instant is settlement_at on the trade that brought the item in (see Trading).
tradable_after is Steam's own end of the trade hold, for every game (CS2 items take it from the game coordinator, everything else from the web inventory). It is null when the item is unrestricted or when Steam gave no date, which for a lock that survives a refresh means it is permanent. Once that instant passes, tradable flips to true within a minute on its own; you do not need to refresh the inventory to see it.
A grouped row carries no asset id and no account, because a stack spans every account you own:
{
"data": [
{
"item": {
"id": "019fb430-2d71-71bc-8a0f-4c1e0f9b7a12",
"market_hash_name": "Fever Case",
"name": "Fever Case",
"image_url": "https://community.fastly.steamstatic.com/economy/image/…",
"type": "Base Grade Container",
"game": "cs2"
},
"quantity": 1284,
"tradable_quantity": 1180,
"marketable_quantity": 1284,
"listed_quantity": 12,
"sold_quantity": 3,
"unit_price_cents": 61,
"stack_value_cents": 78324,
"last_acquired_at": "2026-07-30T09:12:44+00:00"
}
],
"meta": { "page": 1, "per_page": 50, "total": 137, "last_page": 3 }
}price_cents and unit_price_cents are the Steam Community Market price, whatever venue you end up selling on. It is the one number every inventory surface in the product values items in, and null for an item nobody has priced yet.
reserved_marketplace is non-null while another venue holds the asset (steam, csfloat, marketcsgo, skinland, dmarket, skinport, assetpay) or while it is staged as a trade-up input (trade_up). Sell, send and store all skip reserved assets.
reserved_sold splits that reservation in two. On CSFloat and market.csgo an item you sell stays in your Steam inventory until the buyer accepts the delivery trade, so a sold asset is still listed by this endpoint: reserved_sold: false means a live listing, reserved_sold: true means the sale already happened and delivery is in flight. Treat both as spoken for. It is always false when reserved_marketplace is null or trade_up.
On a grouped row the same split is counted across the stack: listed_quantity is how many of its assets sit on a live marketplace listing and sold_quantity how many are sold and awaiting delivery. Trade-up staging counts toward neither.
List storage units
/api/v1/inventory/storage-unitsYour CS2 storage units, fullest first.
Requires inventory.read.
| Field | Type | Description |
|---|---|---|
sort |
string | fill (default, fullest first) or name |
accounts |
array | Steam account ids you own |
tags |
array | Account tag ids you own |
search |
string | Matches the unit's own name or its catalog name |
page |
integer | 1-indexed, default 1 |
per_page |
integer | Default 50, clamped to 200 |
The item-level filters (game, tradable, marketable, location, venue, rarity, price_min, price_max) mean nothing for a container and are ignored here, exactly as the dashboard hides those controls on its units tab.
curl "https://dashboard.steamlabs.dev/api/v1/inventory/storage-units?sort=fill&per_page=1" \
-H "Authorization: Bearer $STEAMLABS_API_KEY"{
"data": [
{
"id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
"asset_id": "40118273905",
"name": "Cases 04",
"custom_name": "Cases 04",
"account": {
"id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
"username": "farm_017",
"persona_name": "Ada"
},
"item_count": 940,
"capacity": 1000,
"space_left": 60,
"known_contents": 940,
"known_value_cents": 61420,
"ready_for_deposits": true
}
],
"meta": { "page": 1, "per_page": 1, "total": 12, "last_page": 12 }
}item_count is the count the CS2 Game Coordinator reports for the unit itself. known_contents is how many of those rows we have actually synced, so the two differ until a sync has read inside the unit.
One storage unit
/api/v1/inventory/storage-units/{id}One storage unit, with its known contents summary.
Requires inventory.read.
{id} is the unit's row id, the id field above. Returns the same object, bare, with no envelope. A unit belonging to someone else returns 404.
Storage unit contents
/api/v1/inventory/storage-units/{id}/contentsThe items inside one storage unit.
Requires inventory.read.
| Field | Type | Description |
|---|---|---|
sort |
string | value (default, priciest first), name, or float (lowest first, items without a float last) |
search |
string | Matches the item's market hash name, word by word |
page |
integer | 1-indexed, default 1 |
per_page |
integer | Default 50, clamped to 200 |
Rows are the same shape as a flat inventory row, with location set to storage and casket_id set to the unit's asset_id.
A unit holds at most 1,000 items, so this is the one listing in the domain whose whole result set is bounded by the game. It is still paginated.
Rename a storage unit
/api/v1/inventory/storage-units/{id}/renameQueue a rename for one storage unit.
Requires inventory.write and an Idempotency-Key header.
| Field | Type | Description |
|---|---|---|
name |
string | The new name, 1 to 20 characters. Twenty is the Game Coordinator's own limit, so a longer name is refused here rather than truncated by the game |
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/storage-units/019fb431-2c88-71ea-b0a3-8ce2f5d19b07/rename" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Cases 04"}'$response = Http::withToken($apiKey)
->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
->post('https://dashboard.steamlabs.dev/api/v1/inventory/storage-units/019fb431-2c88-71ea-b0a3-8ce2f5d19b07/rename', [
'name' => 'Cases 04',
])
->json();{
"task_id": "019fb44a-51d0-7238-9c1e-77a1f0c2b9d4",
"accounts_affected": 1
}The name lives in the Game Coordinator, not in our database, so this is a worker task rather than an edit. Poll GET /api/v1/tasks/{id} to follow it.
Withdraw from a storage unit
/api/v1/inventory/storage-units/{id}/withdrawPull items back out of one storage unit.
Requires inventory.write and an Idempotency-Key header.
This is the one write on the page that does not take the shared selection contract. A withdraw is scoped to a single container that holds at most 1,000 rows, so there is no fleet-sized selection to describe.
| Field | Type | Description |
|---|---|---|
asset_ids |
array | Row ids inside this unit. Omit it to withdraw everything matching search |
search |
string | Narrows to items whose market hash name matches, word by word |
An empty body means "empty this unit", which is a reasonable thing to ask a container to do and is bounded by its capacity.
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/storage-units/019fb431-2c88-71ea-b0a3-8ce2f5d19b07/withdraw" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"search": "Fever Case"}'{
"task_id": "019fb44b-8ac3-70f5-b2d1-0a6e3f5c81aa",
"accounts_affected": 1,
"items_queued": 240
}No plan can refuse a withdraw. Getting your own items back out of a container is not a paywall.
Sell items
/api/v1/inventory/sellList a selection on one selling venue.
Requires inventory.write and an Idempotency-Key header.
Takes the selection contract plus a venue and its pricing. Only marketable assets in the main inventory that are not already reserved by another venue's listing are eligible.
| Field | Type | Description |
|---|---|---|
venue |
string | steam, csfloat, marketcsgo, skinland, dmarket, skinport, or assetpay |
mode |
string | Pricing mode. Defaults to undercut. Validated per venue, see below. Omit it on skinland: that venue quotes its own price. On assetpay it decides whether the items are sold now or listed |
percent |
number | Percentage of the reference price, 1 to 500. Required when mode is reference_percent. Above 100 is allowed: listing above the reference is a real strategy, just not the common one. On assetpay with instant_markup it is the percentage of AssetPay's instant price to ask, clamped to a minimum of 100 |
reference_source |
string | assetpay with market_percent only. Which market's price percent is taken of: csfloat (default), steam_market or marketcsgo |
instant_sell_after_days |
integer | assetpay listing modes only. Days after which a listing that has not sold comes down and sells to AssetPay at the instant price, 1 to 365. Omit to keep the listing up until it sells. Only acts while the venue's instant sell unsold rule is on |
floor_percent |
number | Never undercut below this share of the reference price, 1 to 100. Dropped when mode is manual, which has nothing to clamp |
manual_price_cents |
integer | The exact price, in cents, at least 1. Enough for a single catalog item when mode is manual |
manual_prices_cents |
object | Exact prices keyed by catalog item id. Required for a mixed selection when mode is manual: one cents value per distinct item. A single shared manual_price_cents cannot cover two different items |
manual_currency |
string | Steam only. The wallet currency the exact prices are quoted in, for example PLN |
manual_convert |
boolean | Steam only. Re-price the exact prices into each account's own wallet currency instead of skipping the accounts that hold another one. Default false |
undercut_cents |
number | Steps below the current lowest ask, in cents. Read by undercut mode on CSFloat, market.csgo, and DMarket. Ignored on Steam. Market.CSGO prices in tenths of a cent and accepts one decimal place, minimum 0.1; the other venues take whole cents, minimum 1 |
auto_reprice |
boolean | Enroll the listings this call creates into automatic repricing. CSFloat, market.csgo, and DMarket, undercut mode only. Default false |
auto_reprice_interval_minutes |
integer | How often those listings are checked: 15, 30, 60, or 240. Defaults to the interval saved for that venue |
Steam wallets each carry their own currency, so an exact price on its own is a bare number: 707 lists as 7.07 zł on a PLN wallet and $7.07 on a USD one. Send manual_currency to say which you meant, and any account whose live Steam wallet is a different currency is skipped with reason wallet_currency_mismatch rather than listed at the wrong number. Omitting it keeps the older behaviour of listing the number as-is against whatever currency the wallet holds.
A mixed selection (two or more distinct catalog items) needs manual_prices_cents. One shared manual_price_cents is refused, because that would list different items at the same number. Every distinct item in the selection needs its own cents value. Copies of the same item share that item's price.
manual_convert decides what happens to the accounts that hold a different currency. Left false, every listing shows exactly the number you sent and the rest are skipped. Set true, nothing is skipped: the number is treated as a value to match and re-priced into each wallet, so the listed figures are conversions rather than the number you typed.
AssetPay is the one venue that sells two ways from this endpoint. instant sells the items to AssetPay outright at the price it quotes at that moment, the way skinland does, and the response is a queued accept task per account. The others list the items on AssetPay's store instead: instant_markup asks percent of what AssetPay would pay for each item right now, market_percent asks percent of another market's price for the item (reference_source: csfloat by default, steam_market or marketcsgo; 1 to 500, lifted to the instant price if it lands below it, and items without a price from that market are skipped), and manual takes an exact price per catalog item. Listing is a local write, so those two answer with the listings created rather than with task ids, and the items appear on AssetPay within seconds. That answer is still a 202: task_id is null, task_ids is empty, and listings_created carries the count, beside the usual accounts_affected, items_queued and skipped. There is no undercut mode: SteamLabs is the only supplier of its own AssetPay pool, so there is no competing listing to undercut. A painted skin whose exact finish cannot be determined (a Doppler knife whose phase is unknown, for instance) is skipped and counted under skipped.no_paint; a fresh inventory refresh of the account fills it in.
To put one exact price on many accounts by rule instead of by hand-picked assets, POST /api/v1/tasks with type: "sell_items" and mode: "manual" takes the same fields. See sell_items. On assetpay the same rule-based task sells now by default (mode: "instant") or lists the matching items on AssetPay's store with instant_markup or manual.
auto_reprice keeps the listings this call creates competitive after they go up. It supports CSFloat, market.csgo and DMarket and requires mode: "undercut"; any other pairing is a 422 on auto_reprice. Each listing captures its own absolute USD floor from floor_percent, so a listing whose venue reference price is missing is still created but is not enrolled. Send auto_reprice on its own and undercut_cents, floor_percent and auto_reprice_interval_minutes all fall back to the repricing defaults saved for that venue, the same ones the dashboard's repricing settings write. For the rule those listings end up with, and for changing it afterwards, see Manage automatic repricing.
| Venue | Modes |
|---|---|
steam |
undercut, reference_percent, buy_order, manual |
csfloat |
undercut, reference_percent, manual |
marketcsgo |
undercut, reference_percent, top_bid, manual |
dmarket |
undercut, reference_percent, top_bid, manual |
skinport |
undercut, reference_percent, manual |
skinland |
none. Omit mode. Sending one is a 422 |
assetpay |
instant (default), instant_markup, market_percent, manual |
A mode the venue cannot honour is a 422. Only Steam prices against a buy order. top_bid on market.csgo and DMarket is instant sell into a sitting buy target. On DMarket, Steam-side items are deposited first, then Instant sell uses DMarket's instant-sale call at that price. An item with no instant price after deposit is skipped. It never falls back to a sitting listing.
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/sell" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"filters": { "game": ["cs2"], "marketable": "sellable", "search": "Fever Case" },
"venue": "steam",
"mode": "reference_percent",
"percent": 98,
"floor_percent": 80
}'import uuid
response = requests.post(
"https://dashboard.steamlabs.dev/api/v1/inventory/sell",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"asset_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5"],
"venue": "csfloat",
"mode": "undercut",
"floor_percent": 85,
},
).json()const response = await fetch('https://dashboard.steamlabs.dev/api/v1/inventory/sell', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
asset_ids: assetIds,
venue: 'marketcsgo',
mode: 'top_bid',
}),
});One task per account, so 202 carries a list:
{
"task_id": "019fb44c-1f92-7461-8d3b-9c0a2e771b55",
"accounts_affected": 18,
"task_ids": [
"019fb44c-1f92-7461-8d3b-9c0a2e771b55",
"019fb44c-1f93-7c08-a51e-3e7d4b9f2201"
],
"items_queued": 640,
"skipped": {
"accounts": 2,
"no_secret": 1,
"restricted": 0,
"busy": 1,
"non_cs2": 0
}
}task_id is the first task, so a client written against the common shape has something to follow. task_ids is the real list. There is no batch parent above them: they are separate pieces of work on separate accounts, and the dashboard does not invent one either.
The skipped keys depend on the venue:
| Key | Venue | Meaning |
|---|---|---|
accounts |
all | Total accounts skipped, the sum of the rest |
no_secret |
all | No mobile authenticator on file, so listings cannot be confirmed |
restricted |
all | The account's market access is restricted right now |
busy |
all | That account already has a listing batch queued or running |
non_cs2 |
all | Non-CS2 items in the selection, dropped rather than refused. Always 0 for Steam, which takes both games |
not_connected |
csfloat, marketcsgo, skinland, dmarket, skinport |
The account is not connected to that marketplace |
no_proxy |
csfloat, marketcsgo, dmarket |
The account has no proxy, which that venue requires |
currency |
marketcsgo |
The account's wallet currency is not one market.csgo accepts |
If nothing survived, you get 422 nothing_eligible with the same skipped block rather than an accepted response with zero tasks.
If your plan does not include the venue, you get 403 plan_limit_reached. The entitlement is allowed_marketplaces, which GET /api/v1/me reports up front.
Send items
/api/v1/inventory/sendSend a selection as trade offers.
Requires inventory.write and an Idempotency-Key header.
Takes the selection contract plus a destination. Only tradable assets in the main inventory that are not reserved by a marketplace listing are eligible: a trade offer carrying the rest would be rejected by Steam anyway. An asset already committed to an outbound trade is also skipped, whether the send task is still queued or the offer is sitting unaccepted on Steam's side, so the same item can never ride in two offers. It becomes eligible again if the offer is declined, canceled, or expires.
| Field | Type | Description |
|---|---|---|
destination_type |
string | own (another of your accounts), external (a trade URL), or routing (spread over rules) |
destination_account_id |
uuid | The receiving account. Required when destination_type is own, and it must be yours |
trade_url |
string | A Steam trade offer URL carrying partner and token. Required when destination_type is external. Anything else is refused, not merely checked for being a URL |
rules_source |
string | template (default) or custom. Required when destination_type is routing |
routing_template_id |
uuid | A saved routing template of yours. Required when routing from a template |
routing_rules |
array | Inline rules, at least one. Required when routing with rules_source: custom |
auto_accept |
boolean | Accept the offer on the receiving side. Defaults to true, and only applies to your own accounts: an external partner accepts on their own side |
message |
string | The note on the offer, at most 128 characters, which is Steam's own limit |
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/send" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"filters": { "game": ["cs2"], "tradable": true, "location": "main" },
"destination_type": "own",
"destination_account_id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
"auto_accept": true,
"message": "consolidating"
}'response = requests.post(
"https://dashboard.steamlabs.dev/api/v1/inventory/send",
headers={
"Authorization": f"Bearer {api_key}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"asset_ids": asset_ids,
"destination_type": "external",
"trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=39734289&token=AbCd1234",
},
).json(){
"task_id": "019fb44d-6b21-7a90-84c2-1f0d9e6c4477",
"accounts_affected": 14,
"items_queued": 380,
"items_skipped": 3
}Unlike sell, this one has a batch parent: task_id is it, and accounts_affected is the number of sending accounts underneath. items_skipped counts the part of the selection that was not eligible (untradable, stored, listed, or already in a pending trade); when the whole selection is skipped you get 422 nothing_eligible instead.
Inline routing_rules follow the routing builder's own shape, and are validated key by key exactly as a saved template is. Each rule is an object of optional conditions (games, price_min, price_max, item_ids, categories, origins), targets (target_account_ids, target_tag_ids, target_group_ids, target_trade_urls), a required action (route or skip), a strategy (round_robin, random, fill_value, top_up_value), and optional target_value and items_limit_per_target. Rules are first-match-wins, and an item matching no rule is left where it is.
At most 50 rules per request, and at most 1000 entries in any one of item_ids, target_account_ids, target_tag_ids, target_group_ids or target_trade_urls. A rule whose action is route needs a strategy and at least one destination in any target field. fill_value and top_up_value also need a target_value. Every account, tag, and group target must belong to you.
Tag targets are resolved to their member accounts when the plan runs, so a rule aimed at a tag picks up accounts tagged after the rule was written. See Trading for the full rule reference.
Two failures are specific to this endpoint:
422 routing_plan_emptywhen the rules route none of the selected items anywhere. Nothing is created, so there is no empty batch to find and cancel.422 invalid_destinationwhen the destination has no usable trade URL. Refresh that account's trade details and try again.
Store items
/api/v1/inventory/storeDeposit a selection into CS2 storage units.
Requires inventory.write and an Idempotency-Key header.
Takes the selection contract plus one unit per account. A storage unit only ever holds its own account's items, so a selection spanning five accounts is five deposits and there is no single destination to name.
| Field | Type | Description |
|---|---|---|
units |
object | Steam account id to storage unit row id. At least one entry |
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/store" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"asset_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5"],
"units": { "019fb42e-9a7e-728d-b960-8b4c2162898c": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07" }
}'{
"task_id": "019fb44e-2d55-7c11-91a7-6b3e0a4d8812",
"accounts_affected": 3,
"task_ids": [
"019fb44e-2d55-7c11-91a7-6b3e0a4d8812",
"019fb44e-2d56-7f02-b8de-4a2c1e97f5b0",
"019fb44e-2d57-70a4-8e19-2c9b7d3f6641"
],
"items_queued": 812,
"skipped": {
"no_unit": 40,
"non_cs2": 12,
"not_depositable": 5,
"unit_busy": 0,
"clamped": 60
}
}| Key | Meaning |
|---|---|
no_unit |
Items on an account you named no unit for, or whose unit does not belong to that account |
non_cs2 |
Non-CS2 items. Only the CS2 Game Coordinator moves items into a unit |
not_depositable |
Items already inside a unit, and the units themselves |
unit_busy |
Items bound for a unit that already has a move queued or running |
clamped |
Items that did not fit in the room the unit had left |
clamped is not a refusal. Those items were fine, the container was full: a unit holds 1,000 items and the overflow is cut here rather than disappearing silently at the Game Coordinator.
A unit runs one move at a time, in either direction. The Game Coordinator handles a single move per container, and two overlapping ones also clamp against the same contained-item count (that figure only changes when the unit syncs back), so a second deposit could overflow a container the first was already filling. A unit with a move in flight is therefore skipped and reported under unit_busy rather than queued behind it. The rest of the batch still goes: one busy container does not fail the other accounts. Retry those items once the move you can see in task_ids has finished.
Unit ownership is not validated up front. A unit id belonging to another account resolves to nothing and its items come back under no_unit, because the constraint that matters is that the unit belongs to the account whose items are going in, and a validation rule cannot check that.
Use items
/api/v1/inventory/useConsume a selection in place.
Requires inventory.write and an Idempotency-Key header.
Takes the selection contract and nothing else. There is no option to give: what an asset becomes is decided by the item itself.
Two things are usable, and the selection is narrowed to them before anything runs:
- TF2 backpack expanders and the other usable TF2 store items.
- CS2 armory passes.
Items inside a storage unit are excluded. Nothing can be used where it sits in a container.
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/use" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"filters": {"game": ["cs2"], "search": "Armory Pass"}}'{
"task_id": "019fb44f-9e07-7b6d-a3f8-5d1c8b02e4a9",
"accounts_affected": 22,
"task_ids": ["019fb44f-9e07-7b6d-a3f8-5d1c8b02e4a9"],
"items_queued": 22,
"skipped": { "ineligible": 0 },
"plan_limited": []
}items_queued counts only what a task was really created for, and task_ids always names one id per account counted in accounts_affected. An account that became ineligible between being selected and being queued produces no task, and its items are reported under skipped.ineligible instead. If every account falls out that way you get 422 nothing_eligible carrying the same block, never an accepted response naming a task that does not exist.
The task types behind the two halves are use_tf2_items and activate_armory_passes. Both appear in your plan's allowed_task_types.
Trade up items
/api/v1/inventory/trade-upStage a selection as trade-up contracts.
Requires inventory.write and an Idempotency-Key header.
Takes the selection contract and nothing else. The planner groups each account's eligible CS2 inputs by tier and StatTrak and forms as many ten-item contracts as they yield. Leftovers are not consumed, so there is no knob to turn.
curl -X POST "https://dashboard.steamlabs.dev/api/v1/inventory/trade-up" \
-H "Authorization: Bearer $STEAMLABS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"filters": {"game": ["cs2"], "accounts": ["019fb42e-9a7e-728d-b960-8b4c2162898c"]}}'{
"task_id": "019fb450-3c48-71d9-b077-8e5a2f10c9b3",
"accounts_affected": 6,
"skipped": { "ineligible_accounts": 1 }
}Non-CS2 assets in the selection are ignored. If nothing forms a complete contract you get 422 nothing_eligible. If your plan does not include the trade_up_contract task type you get 403 plan_limit_reached.
Staged inputs come back on the listing with reserved_marketplace: "trade_up", and the sell, send and store planners skip them from then on. For the contracts themselves, their scans and their outcomes, see Trade-ups.
One item
/api/v1/inventory/{id}One asset, with everything the details view shows.
Requires inventory.read.
{id} is the asset row id, not Steam's asset_id. Returns a single flat row, bare, with no envelope.
curl "https://dashboard.steamlabs.dev/api/v1/inventory/019fb42e-9a61-70d2-818a-f6a56593f3a5" \
-H "Authorization: Bearer $STEAMLABS_API_KEY"An id belonging to someone else returns 404, the same as an id that does not exist. Telling the two apart would confirm the row exists.
Refreshing the data on this page
Items land here after an account completes an inventory sync; nothing on this page reads Steam live. To bring accounts current from code, queue the inventory-only refresh with POST /api/v1/accounts/refresh-inventory (it takes an account selection, not an item selection, which is why it lives with the account actions). Scheduled automatic refreshes, including their per-account exclusions, are configured through PATCH /api/v1/settings/inventory-refresh.
Errors
Beyond the universal codes, this group returns:
| Code | Status | Meaning |
|---|---|---|
nothing_eligible |
422 |
Nothing in the selection could be acted on. Sell, store and use add a skipped breakdown |
routing_plan_empty |
422 |
Send with routing rules that route none of the selected items anywhere |
invalid_destination |
422 |
Send to a destination with no usable trade URL |
move_already_running |
409 |
A deposit or withdraw is already queued or running for that storage unit |
bulk_limit_exceeded |
422 |
More than 1,000 asset_ids. Adds max |
maintenance_mode |
503 |
Platform maintenance pauses every creating endpoint in this group. Adds reason. Transient, see Errors |
403 plan_limit_reached comes from seven places here, each naming a different entitlement:
| Endpoint | Entitlement |
|---|---|
POST /api/v1/inventory/sell |
allowed_marketplaces does not include the venue |
POST /api/v1/inventory/send |
allows_trade_sending is off (routing additionally needs the distribute_items task type) |
POST /api/v1/inventory/store |
allowed_task_types does not include store_items |
POST /api/v1/inventory/use |
allowed_task_types covers neither use_tf2_items nor activate_armory_passes |
POST /api/v1/inventory/trade-up |
allowed_task_types does not include trade_up_contract |
POST /api/v1/inventory/storage-units/{id}/rename |
allowed_task_types does not include rename_storage_unit |
POST /api/v1/inventory/storage-units/{id}/withdraw |
allowed_task_types does not include withdraw_items |
Depositing and withdrawing spend the same entitlements here as the equivalent bulk tasks, so the surgical endpoints and the task queue can never disagree about what your plan covers.