# Trade-ups

> Clear inventory clutter, hunt for value, and build contracts by hand, from code.


Trade-up contracts turn ten items of one grade into one of the next. The API gives you all three ways the dashboard does it: the bulk **clearing** run, the **best-value** scan, and the hand-built **contract**.

Two scopes cover the domain, split by verb rather than by effect. `tradeups.read` covers every `GET`. `tradeups.write` covers every `POST` and `PUT`, including the two that create nothing (`dry-run` and `scan`): a scope is a promise attached to a key that may live for a year, and what a handler does is free to change under it.

> [!WARNING]
> Staging a contract reserves real items and the craft consumes them. `clear-space`, `craft` and `queue-best-value` each require an `Idempotency-Key` header. `dry-run`, `scan` and the settings write do not. See [Bulk operations](/docs/api/en/concepts/bulk-operations).

All money is integer cents, in USD.

## The ledger

Every contract ever staged, with what it cost, the odds it was staged against, and what came out. Rows are kept forever, so this is the record to reconcile against.

```endpoint
method: GET
path: /api/v1/tradeups/contracts
description: Your trade-up contracts, newest first.
auth: bearer
```

| Filter | Values |
| --- | --- |
| `account_id` | One of your Steam account IDs |
| `status` | `pending`, `crafting`, `completed`, `failed`, `reconciled`, `cancelled` |
| `rarity` | `1`-`6` (Consumer through Covert) |
| `is_stattrak` | `true` / `false` |
| `search` | Matches the account username |
| `sort` | `created_at`, `input_cost`, `planned_ev`, `realized_value`, `profit` |
| `direction` | `asc` / `desc` |

```bash
curl "https://dashboard.steamlabs.dev/api/v1/tradeups/contracts?status=completed&sort=profit&direction=desc" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "steam_account": { "id": "019fb42e-9a7e-728d-b960-8b4c2162898c", "username": "farm_014" },
            "status": "completed",
            "rarity": 1,
            "rarity_label": "Consumer",
            "is_stattrak": false,
            "input_count": 10,
            "input_collections": 3,
            "input_cost_cents": 350,
            "planned_ev_cents": 420,
            "realized_value_cents": 505,
            "profit_cents": 155,
            "output": {
                "market_hash_name": "MP9 | Slide (Field-Tested)",
                "paint_wear": 0.2074
            },
            "created_at": "2026-07-30T09:14:22+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

```endpoint
method: GET
path: /api/v1/tradeups/contracts/{id}
description: One contract, with its frozen input snapshot and full outcome odds.
auth: bearer
```

The detail view adds `inputs` (the ten assets consumed, with the price each carried at craft time) and `planned_outcomes` (every possible result with its probability, predicted float and price).

```endpoint
method: GET
path: /api/v1/tradeups/stats
description: Spend, realized value, net and hit rate across your completed contracts.
auth: bearer
```

## Settings

The scan settings the clearing run uses when you do not override them: which accounts, the price rail, how many contracts per account, which tiers, and whether StatTrak items may be burned. Shared with the dashboard, so changing them here changes what the **Trade-Ups** page does.

```endpoint
method: GET
path: /api/v1/tradeups/settings
description: Your saved trade-up scan settings.
auth: bearer
```

```endpoint
method: PUT
path: /api/v1/tradeups/settings
description: Replace your saved trade-up scan settings.
auth: bearer
```

```json
{
    "account_ids": ["019fb42e-9a7e-728d-b960-8b4c2162898c"],
    "max_item_price_cents": 10,
    "max_contracts": 25,
    "rarities": [1, 2],
    "stattrak": "any"
}
```

`stattrak` is `any`, `normal` (never burn StatTrak) or `stattrak` (only StatTrak). `rarities` accepts `1`-`5`; Covert has no clearing contract. A `PUT` replaces the whole object, so anything you leave out goes back to its default.

Instead of `account_ids` you can send a `filters` object, which is resolved server-side. Accepted keys: `search`, `guard`, `wallet`, `details`, `market`, `csfloat`, `inventory`, `session`, `tags`, `wallet_currency`, `wallet_min`, `wallet_max`. A bad **value** for one of those is a `422` naming the key.

> [!WARNING]
> A key that is not on that list is dropped silently, not refused. A `filters` object made only of unrecognised keys is therefore the same as sending no scope at all, and on this `PUT` no scope means **every account you own**. Send only keys from the list, then read `accounts_in_scope` back off the response to see what the server understood before you run anything against it.

## Clearing space

What the current scope would free, without touching anything:

```endpoint
method: GET
path: /api/v1/tradeups/summary
description: How many contracts, slots and dollars the current scope and rails cover.
auth: bearer
```

Every settings field can be sent as a query parameter here to preview a different rail without saving it.

```endpoint
method: POST
path: /api/v1/tradeups/dry-run
description: Plan a clearing run and report the digest, creating nothing.
auth: bearer
```

```endpoint
method: POST
path: /api/v1/tradeups/clear-space
description: Stage every contract the scope yields under the current rails.
auth: bearer
```

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/tradeups/clear-space \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: 7f3a9c2e-1b44-4d8a-9f01-cc2e5a9b1234" \
  -H "Content-Type: application/json" \
  -d '{"rarities": [1, 2], "max_item_price_cents": 10}'
```

```json
{
    "task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
    "accounts_affected": 42,
    "accounts_skipped": 3,
    "async": true
}
```

An empty `rarities` array is refused with `422 empty_rarity_scope` rather than falling back to a default: asking for no tiers must never burn items in tiers you did not name.

If no account in scope holds enough eligible items you get `422 no_contracts_available`. That is a different answer from `403 plan_limit_reached`, which means your plan does not sell trade-ups at all. Both differ from `503 maintenance_mode`, which is transient: contract creation is paused during [platform maintenance](/docs/api/en/concepts/errors) and the same call succeeds once it ends.

## Hunting value

```endpoint
method: POST
path: /api/v1/tradeups/scan
description: The best contract each account in scope could craft, ranked by expected return.
auth: bearer
```

The scan looks at the first 25 accounts of the scope and returns the top ten proposals, with `capped: true` when there were more. It creates nothing and needs no `Idempotency-Key`, but it is a `POST` and so still requires `tradeups.write`.

```json
{
    "plans": [
        {
            "account": { "id": "019fb42e-9a7e-728d-b960-8b4c2162898c", "username": "farm_014" },
            "rarity": 2,
            "rarity_label": "Industrial",
            "input_cost_cents": 210,
            "expected_value_cents": 285,
            "expected_return": 0.357
        }
    ],
    "accounts_scanned": 25,
    "capped": true,
    "account_cap": 25
}
```

Queue one of them:

```endpoint
method: POST
path: /api/v1/tradeups/queue-best-value
description: Stage the best contract one account can craft at one tier.
auth: bearer
```

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

```json
{
    "account_id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
    "rarity": 2
}
```

`rarity` is required and accepts `1` to `5`. `stattrak` is optional and falls back to your saved policy. An account that already has a trade-up task queued or running answers `409 account_busy`, and an account id that is not yours answers `404 account_not_found`.

## Building a contract by hand

The candidate pool is every item on one account that could legally be an input right now: unreserved, tradable, not in a storage unit, and part of a collection with an outcome pool.

```endpoint
method: GET
path: /api/v1/tradeups/candidates
description: One account's trade-up eligible items, cheapest first.
auth: bearer
```

| Parameter | Values |
| --- | --- |
| `account_id` | Required |
| `rarity` | `1`-`6`; omit for every craftable tier |
| `stattrak` | `any` (default), `normal`, `stattrak` |
| `collections` | Collection IDs to keep |
| `float_min`, `float_max` | `0`-`1` |
| `sort` | `price` (default) or `float` |

Then craft:

```endpoint
method: POST
path: /api/v1/tradeups/craft
description: Stage one hand-picked contract from up to ten items.
auth: bearer
```

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

```json
{
    "account_id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
    "item_ids": ["019fb42e-9c10-7233-8f7a-5b2d90c14a77", "019fb42e-9c22-70ab-b3c4-118e6f2d0e59"]
}
```

`item_ids` takes 1 to 10 ids and they must be distinct: the same row twice is one item pretending to be two.

A contract takes exactly ten inputs (five for a Covert knife/glove contract), and **all of them must share one rarity and one StatTrak state**. Break either rule and the answer names which:

| Code | Meaning |
| --- | --- |
| `mixed_rarity` | The items span more than one grade |
| `mixed_stattrak` | Some are StatTrak and some are not |
| `incomplete_contract` | Wrong number of usable inputs; `required`, `usable` and `submitted` say how far off |
| `inputs_not_found` | None of those IDs are on that account |
| `no_next_tier` | That grade has no contract |
| `unknown_skins` | An item is not a recognised skin, so its odds cannot be computed |
| `account_busy` | That account already has a trade-up task queued or running |
| `inputs_unavailable` | Another contract reserved one of those items first; refetch the candidates |

A successful craft returns `202` with the task and the ledger row it created:

```json
{
    "task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
    "accounts_affected": 1,
    "contract_ids": ["019fb431-2c9a-73bb-9f0e-7d51c3a44f18"],
    "input_cost_cents": 342,
    "expected_value_cents": 410
}
```
