Skip to content
SteamLabs API

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:

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.

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.

GET/api/v1/inventory

Your items across every account, paginated and filterable.

API key required

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

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.

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

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

GET/api/v1/inventory/storage-units

Your CS2 storage units, fullest first.

API key required

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

One storage unit

GET/api/v1/inventory/storage-units/{id}

One storage unit, with its known contents summary.

API key required

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

GET/api/v1/inventory/storage-units/{id}/contents

The items inside one storage unit.

API key required

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

POST/api/v1/inventory/storage-units/{id}/rename

Queue a rename for one storage unit.

API key required

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
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"}'
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

POST/api/v1/inventory/storage-units/{id}/withdraw

Pull items back out of one storage unit.

API key required

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

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

Sell items

POST/api/v1/inventory/sell

List a selection on one selling venue.

API key required

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

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

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.

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

POST/api/v1/inventory/send

Send a selection as trade offers.

API key required

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.

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
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"
      }'
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 for the full rule reference.

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

POST/api/v1/inventory/store

Deposit a selection into CS2 storage units.

API key required

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

POST/api/v1/inventory/use

Consume a selection in place.

API key required

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.

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

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

POST/api/v1/inventory/trade-up

Stage a selection as trade-up contracts.

API key required

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.

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

One item

GET/api/v1/inventory/{id}

One asset, with everything the details view shows.

API key required

Requires inventory.read.

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

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

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.