# 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.

> [!WARNING]
> This is the largest table in the product. A busy account holds hundreds of thousands of assets, so an unfiltered walk costs hundreds of requests against your [rate limit](/docs/api/en/concepts/rate-limits) and a lot of database time on our side. Filter server side, ask for `per_page=200`, and use `mode=grouped` when you want totals rather than rows.

## 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**:

```json
{ "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:

```json
{ "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`:

```json
{ "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.

> [!WARNING]
> The filter cap is silent. A filter matching 4,000 assets acts on 1,000 of them and nothing in the response says so. Narrow the filter (one account, one game, one search term) and send several calls, or list the ids yourself and send them in batches of 1,000.

Both shapes queue worker tasks and both answer `202 Accepted`. See [Bulk operations](/docs/api/en/concepts/bulk-operations) for the wider contract.

### Every write needs an Idempotency-Key

> [!IMPORTANT]
> All seven writes on this page require an `Idempotency-Key` header. Not one of them is free: each either moves real items or puts them up for sale, and a client that times out cannot tell "it never arrived" from "it worked and the reply was lost".
>
> Without the header you get `400 idempotency_key_required`. Retry with the same key and you get the original response back with `Idempotent-Replay: true`. Retry while the first call is still running and you get `409 idempotency_key_in_flight`. See [Bulk operations](/docs/api/en/concepts/bulk-operations).

## List inventory

One endpoint, three modes over the same filtered set.

```endpoint
method: GET
path: /api/v1/inventory
description: Your items across every account, paginated and filterable.
auth: bearer
```

Requires `inventory.read`.

### 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` |

> [!WARNING]
> The three modes return three different row shapes. Pick the wrong one and you get a body your parser will not recognise. `mode=units` is the same response as `GET /api/v1/inventory/storage-units`, which is the more discoverable way to ask for it.

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 |
| `venue` | array | `steam`, `csfloat`, `marketcsgo`. 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 |
| `search` | string | Matches the item's market hash name. Split on spaces, every word must appear, in any order |
| `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.

> [!NOTE]
> `sort=float` drops items that have no float value, from the rows and from `meta.total`. That is deliberate: a float-sorted page reporting the whole inventory's count would promise pages that come back empty.

```bash tab=curl
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"
```

```python tab=Python
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()
```

```javascript tab=Node
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:

```json
{
    "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 }
}
```

A `grouped` row carries no asset id and no account, because a stack spans every account you own:

```json
{
    "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`) 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

```endpoint
method: GET
path: /api/v1/inventory/storage-units
description: Your CS2 storage units, fullest first.
auth: bearer
```

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`) mean nothing for a container and are ignored here, exactly as the dashboard hides those controls on its units tab.

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/inventory/storage-units?sort=fill&per_page=1" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "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.

> [!NOTE]
> `ready_for_deposits` is false for an unnamed unit. The Game Coordinator refuses to move items into one, so name it (see the rename endpoint) before you deposit.

## One storage unit

```endpoint
method: GET
path: /api/v1/inventory/storage-units/{id}
description: One storage unit, with its known contents summary.
auth: bearer
```

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

```endpoint
method: GET
path: /api/v1/inventory/storage-units/{id}/contents
description: The items inside one storage unit.
auth: bearer
```

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

```endpoint
method: POST
path: /api/v1/inventory/storage-units/{id}/rename
description: Queue a rename for one storage unit.
auth: bearer
```

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 |

```bash tab=curl
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"}'
```

```php tab=PHP
$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();
```

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

```endpoint
method: POST
path: /api/v1/inventory/storage-units/{id}/withdraw
description: Pull items back out of one storage unit.
auth: bearer
```

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.

```bash tab=curl
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"}'
```

```json
{
    "task_id": "019fb44b-8ac3-70f5-b2d1-0a6e3f5c81aa",
    "accounts_affected": 1,
    "items_queued": 240
}
```

> [!WARNING]
> A unit takes one move at a time. If a deposit or withdraw is already queued or running against it, you get `409 move_already_running`. Wait for that task to finish rather than retrying immediately: a second overlapping move would either be dropped or corrupt the first.

No plan can refuse a withdraw. Getting your own items back out of a container is not a paywall.

## Sell items

```endpoint
method: POST
path: /api/v1/inventory/sell
description: List a selection on one selling venue.
auth: bearer
```

Requires `inventory.write` and an `Idempotency-Key` header.

Takes the [selection contract](#selecting-items-to-act-on) 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`, or `marketcsgo` |
| `mode` | string | Pricing mode. Defaults to `undercut`. Validated per venue, see below |
| `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 |
| `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. Required when `mode` is `manual` |
| `manual_currency` | string | Steam only. The wallet currency `manual_price_cents` is quoted in, for example `PLN` |
| `manual_convert` | boolean | Steam only. Re-price `manual_price_cents` into each account's own wallet currency instead of skipping the accounts that hold another one. Default `false` |
| `undercut_cents` | integer | Steps below the current lowest ask, in cents. Only means anything on market.csgo |

Steam wallets each carry their own currency, so `manual_price_cents` 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.

`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.

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](/docs/api/en/endpoints/tasks#market).

| Venue | Modes |
| --- | --- |
| `steam` | `undercut`, `reference_percent`, `buy_order`, `manual` |
| `csfloat` | `undercut`, `reference_percent`, `manual` |
| `marketcsgo` | `undercut`, `reference_percent`, `top_bid`, `manual` |

A mode the venue cannot honour is a `422`. Only Steam prices against a buy order, and only market.csgo takes the top bid.

```bash tab=curl
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
      }'
```

```python tab=Python
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()
```

```javascript tab=Node
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:

```json
{
    "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.

> [!NOTE]
> `accounts_affected` counts every account the selection spanned, including the ones that were skipped. `task_ids` is the count of what was actually queued. If the two disagree, read `skipped`.

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` | The account is not connected to that marketplace |
| `no_proxy` | `csfloat`, `marketcsgo` | 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

```endpoint
method: POST
path: /api/v1/inventory/send
description: Send a selection as trade offers.
auth: bearer
```

Requires `inventory.write` and an `Idempotency-Key` header.

Takes the [selection contract](#selecting-items-to-act-on) 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.

| 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 |

```bash tab=curl
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"
      }'
```

```python tab=Python
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()
```

```json
{
    "task_id": "019fb44d-6b21-7a90-84c2-1f0d9e6c4477",
    "accounts_affected": 14,
    "items_queued": 380
}
```

Unlike sell, this one has a batch parent: `task_id` is it, and `accounts_affected` is the number of sending accounts underneath.

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_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` or `target_trade_urls`. A rule whose `action` is `route` needs a `strategy` and at least one destination in any of the three target fields; `fill_value` and `top_up_value` additionally need a `target_value`. Every entry in `target_trade_urls` has to be a real Steam trade offer URL (`https://steamcommunity.com/tradeoffer/new/?partner=...&token=...`), and every `target_account_ids` and `target_tag_ids` entry has to name an account or account tag you own. Anything else is a `422` naming the rule index that failed.

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](/docs/api/en/endpoints/trading) for the full rule reference.

> [!TIP]
> Save the rules as a routing template in the dashboard and send `routing_template_id` instead. A template is validated once when you save it, so a long rule set does not have to travel with every send.

Two failures are specific to this endpoint:

- `422 routing_plan_empty` when the rules route none of the selected items anywhere. Nothing is created, so there is no empty batch to find and cancel.
- `422 invalid_destination` when the destination has no usable trade URL. Refresh that account's trade details and try again.

## Store items

```endpoint
method: POST
path: /api/v1/inventory/store
description: Deposit a selection into CS2 storage units.
auth: bearer
```

Requires `inventory.write` and an `Idempotency-Key` header.

Takes the [selection contract](#selecting-items-to-act-on) 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 |

```bash tab=curl
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" }
      }'
```

```json
{
    "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

```endpoint
method: POST
path: /api/v1/inventory/use
description: Consume a selection in place.
auth: bearer
```

Requires `inventory.write` and an `Idempotency-Key` header.

Takes the [selection contract](#selecting-items-to-act-on) 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.

```bash tab=curl
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"}}'
```

```json
{
    "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.

> [!NOTE]
> One call, two independent plan entitlements. A plan can cover TF2 store items and not armory passes. When one half is locked and the other runs, you get `202` with the refusal in `plan_limited` as `{ "heading": …, "body": … }`, so you can tell your user what it cost them. Only when the plan refuses everything does the call fail with `403 plan_limit_reached`.

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

```endpoint
method: POST
path: /api/v1/inventory/trade-up
description: Stage a selection as trade-up contracts.
auth: bearer
```

Requires `inventory.write` and an `Idempotency-Key` header.

Takes the [selection contract](#selecting-items-to-act-on) 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.

```bash tab=curl
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"]}}'
```

```json
{
    "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](/docs/api/en/endpoints/trade-ups).

## One item

```endpoint
method: GET
path: /api/v1/inventory/{id}
description: One asset, with everything the details view shows.
auth: bearer
```

Requires `inventory.read`.

`{id}` is the asset row id, not Steam's `asset_id`. Returns a single `flat` row, bare, with no envelope.

```bash tab=curl
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.

> [!NOTE]
> `float`, `wear`, `paint_seed`, `paint_index`, `origin`, `stickers` and `keychains` are Game Coordinator data. They stay `null` (or empty) until an inventory refresh has run against the account while it was not in game.

## Errors

Beyond the [universal codes](/docs/api/en/concepts/errors), 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](/docs/api/en/concepts/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.
