# Trading

> Read and answer Steam trade offers, clear mobile confirmations, and manage saved routing templates.


Everything the **Trades** page and an account's **Trade Offers** panel do, over HTTP: read the offer history the way the table reads it, accept, decline and cancel, work through Steam mobile confirmations, and manage the saved routing rulesets that distribution runs apply. Two scopes cover the domain: `trading.read` for the listings, `trading.write` for anything that answers an offer or a confirmation, and for template edits.

> [!IMPORTANT]
> Nothing here talks to Steam while you wait. Every write queues a worker task and answers `202` with a `task_id`, and Steam allows one trade task and one confirmation task per account at a time. A second one is refused with `409` rather than silently dropped, so read the [Errors](#errors) section before you write a retry loop.

## List trade offers

The trades table, as a query. Every tab, filter and search box on the page is a parameter here.

```endpoint
method: GET
path: /api/v1/trade-offers
description: Your trade offers, paginated and filterable.
auth: bearer
```

Requires `trading.read`.

| Parameter | Type | Description |
| --- | --- | --- |
| `tab` | string | `all` (default), `open` (still awaiting someone's action), `protected` (accepted CS2 trades still in trade protection) |
| `account_id` | uuid | One of your Steam accounts. Matches **either** side, so you get what it sent and what it was sent |
| `partner_account_id` | uuid | Only offers whose partner is this account of yours |
| `flow` | string | `incoming`, `outgoing` (both external only), or `internal` for transfers between two of your own accounts |
| `state` | integer | One Steam offer state. See the table below |
| `game` | array | `cs2`, `tf2`, `steam`. Repeat it (`game[]=`) to match any of several |
| `search` | string | Matches the Steam offer id, the external partner's Steam64 id, or either account's username |
| `include_mirrors` | boolean | `true` returns both rows of an internal transfer instead of collapsing them |
| `sort` | string | `state_updated_at` (default), `sent_at`, `expires_at` |
| `order` | string | `asc`, `desc` (default) |
| `per_page` | integer | Page size. See [Pagination and filtering](/docs/api/en/concepts/pagination-and-filtering) |

An unknown value is a `422` rather than an ignored parameter, so `tab=oepn` fails instead of quietly returning everything.

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/trade-offers?tab=open&flow=incoming&per_page=1" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```php tab=PHP
$offers = Http::withToken($apiKey)
    ->get('https://dashboard.steamlabs.dev/api/v1/trade-offers', [
        'tab' => 'open',
        'flow' => 'incoming',
    ])
    ->json('data');
```

```javascript tab=Node
const response = await fetch(
    'https://dashboard.steamlabs.dev/api/v1/trade-offers?tab=open&flow=incoming',
    { headers: { Authorization: `Bearer ${apiKey}` } },
);

const { data } = await response.json();
```

```python tab=Python
import requests

offers = requests.get(
    "https://dashboard.steamlabs.dev/api/v1/trade-offers",
    params={"tab": "open", "flow": "incoming"},
    headers={"Authorization": f"Bearer {api_key}"},
).json()["data"]
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "offer_id": "9211449039",
            "trade_id": null,
            "task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
            "direction": "received",
            "flow": "incoming",
            "state": 2,
            "state_label": "Active",
            "is_open": true,
            "is_awaiting_settlement": false,
            "game": "cs2",
            "message": "swap?",
            "steam_account": {
                "id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
                "username": "farm_017",
                "persona_name": "Ada",
                "steam64_id": "76561198000000017"
            },
            "partner_steam_account": null,
            "partner_steam64_id": "76561199202970980",
            "give_count": 0,
            "receive_count": 2,
            "items_to_give": [],
            "items_to_receive": [
                {
                    "asset_id": "15744253393",
                    "appid": 730,
                    "context_id": 2,
                    "class_id": "3946324",
                    "instance_id": "11040671",
                    "market_hash_name": "AK-47 | Slate (Field-Tested)",
                    "name": "AK-47 | Slate (Field-Tested)",
                    "icon_url": "https://community.cloudflare.steamstatic.com/economy/image/…",
                    "amount": 1,
                    "est_usd": 372
                }
            ],
            "estimated_give_cents": null,
            "estimated_receive_cents": 4310,
            "is_delayed_settlement": false,
            "settlement_at": null,
            "escrow_end_at": null,
            "sent_at": "2026-07-30T09:14:22+00:00",
            "state_updated_at": "2026-07-30T14:02:11+00:00",
            "expires_at": "2026-08-13T09:14:22+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 1, "total": 1, "last_page": 1 }
}
```

### States

`state` is Steam's own `ETradeOfferState` integer. `state_label` is the same value in the dashboard's words.

| Value | Label | Open |
| --- | --- | --- |
| `1` | Invalid | no |
| `2` | Active | yes |
| `3` | Accepted | no |
| `4` | Countered | no |
| `5` | Expired | no |
| `6` | Canceled | no |
| `7` | Declined | no |
| `8` | Invalid Items | no |
| `9` | Needs Confirmation | yes |
| `10` | Canceled via Mobile | no |
| `11` | In Escrow | yes |

`is_open` is that last column. An open offer can still change through someone's action; a closed one only ever changes when a fresh fetch finds Steam disagrees.

`tab=protected` is narrower than `state=3`. It is the accepted CS2 trades whose items have left the sender, have not reached the receiver, and are still reversible: `is_delayed_settlement` true, state Accepted, and `settlement_at` in the future. `is_awaiting_settlement` says the same thing per row.

> [!NOTE]
> A transfer between two of your own accounts is stored twice, once per side, sharing one Steam offer id. The listing collapses each pair to its sender row so one trade reads as one row, exactly as the table does. Pass `include_mirrors=true` when you want both perspectives, for example to reconcile item movement per account.

> [!WARNING]
> `est_usd` inside `items_to_give` and `items_to_receive` is Steam's own estimate in **cents**, despite the name. It is the raw value the worker snapshots and it is often `null` outside CS2. The derived `estimated_give_cents` and `estimated_receive_cents` are the sums, and are `null` when no item carried an estimate.

## Get one trade offer

```endpoint
method: GET
path: /api/v1/trade-offers/{id}
description: One trade offer, with both item lists in full.
auth: bearer
```

Requires `trading.read`.

Same object as a listing row. `{id}` is the SteamLabs uuid, not the Steam offer id, and it names one perspective: this endpoint never collapses an internal transfer, so a mirror uuid returns the mirror.

```bash
curl https://dashboard.steamlabs.dev/api/v1/trade-offers/019fb42e-9a61-70d2-818a-f6a56593f3a5 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

## Accept an offer

Queues an accept on the account that received the offer.

```endpoint
method: POST
path: /api/v1/trade-offers/{id}/accept
description: Accept one incoming offer.
auth: bearer
```

Requires `trading.write`.

This route carries the `idempotent` middleware, so an `Idempotency-Key` header is **required**. Accepting moves real items, and a retry after a timeout must replay rather than accept twice. See [Bulk operations](/docs/api/en/concepts/bulk-operations).

No body.

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/trade-offers/019fb42e-9a61-70d2-818a-f6a56593f3a5/accept \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```php tab=PHP
$result = Http::withToken($apiKey)
    ->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
    ->post("https://dashboard.steamlabs.dev/api/v1/trade-offers/{$offerId}/accept")
    ->json();
```

```javascript tab=Node
const response = await fetch(
    `https://dashboard.steamlabs.dev/api/v1/trade-offers/${offerId}/accept`,
    {
        method: 'POST',
        headers: {
            Authorization: `Bearer ${apiKey}`,
            'Idempotency-Key': crypto.randomUUID(),
        },
    },
);

const result = await response.json();
```

```python tab=Python
import requests, uuid

result = requests.post(
    f"https://dashboard.steamlabs.dev/api/v1/trade-offers/{offer_id}/accept",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
).json()
```

`202 Accepted`, with the worker task to follow and the Steam offer id it will answer:

```json
{
    "task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
    "accounts_affected": 1,
    "offer_id": "9211449039"
}
```

Accepting resolves the **receiving** side first, so you can call this on a collapsed internal-transfer row and the task lands on the account that can actually answer.

> [!WARNING]
> An offer is answerable only while the receiving side is `state: 2` (Active). `is_open` is also true for Needs Confirmation and In Escrow, so gating your client on `is_open` alone will earn you a `422 offer_not_actionable`. An offer you only sent to an external partner has no receiving side here and is refused the same way.

## Decline an offer

The same call, the other answer.

```endpoint
method: POST
path: /api/v1/trade-offers/{id}/decline
description: Decline one incoming offer.
auth: bearer
```

Requires `trading.write`. An `Idempotency-Key` header is **required**.

No body. Same `202` shape, same receiving-side resolution, and the same `422 offer_not_actionable` when the offer is not an active offer awaiting one of your accounts.

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/trade-offers/019fb42e-9a61-70d2-818a-f6a56593f3a5/decline \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

## Cancel an offer

Withdraws an offer one of your accounts sent.

```endpoint
method: POST
path: /api/v1/trade-offers/{id}/cancel
description: Cancel an open offer this account sent.
auth: bearer
```

Requires `trading.write`. An `Idempotency-Key` header is **required**.

No body. Only the sending side can withdraw an offer, and only while it is still open, so the row you name must have `direction: "sent"` and `is_open: true`. Anything else is `422 offer_not_cancellable`, carrying the state it actually has:

```json
{
    "message": "Only an offer this account sent, and that is still open, can be cancelled.",
    "code": "offer_not_cancellable",
    "state": 7
}
```

A success is the usual `202` with `task_id`, `accounts_affected` and `offer_id`.

## Refresh an account's offers

Asks Steam what this account's offers look like right now.

```endpoint
method: POST
path: /api/v1/accounts/{account}/trade-offers/refresh
description: Queue a fresh fetch of one account's trade offers.
auth: bearer
```

Requires `trading.write`.

No `Idempotency-Key` needed. This only reads from Steam, and the single-flight guard already collapses a burst of calls into one task.

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/trade-offers/refresh \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
    "accounts_affected": 1
}
```

The offers land asynchronously. Poll the task, then re-read `GET /api/v1/trade-offers`.

## Answer an account's offers in bulk

The panel's **Accept all** and **Decline all**, plus everything in between.

```endpoint
method: POST
path: /api/v1/accounts/{account}/trade-offers/respond
description: Accept or decline several of one account's incoming offers, or all of them.
auth: bearer
```

Requires `trading.write`. An `Idempotency-Key` header is **required**.

| Field | Type | Description |
| --- | --- | --- |
| **`action`** | string | `accept` or `decline` |
| `offer_ids` | array | The **Steam** offer ids to answer, as strings of up to 32 characters. Omit the field to answer every incoming offer on the account |

> [!CAUTION]
> `offer_ids` holds Steam offer ids (the `offer_id` field), never SteamLabs uuids: this list is handed to the worker, which talks to Steam. Omitting the field means "every incoming offer on this account", which is a deliberate fan-out. Send an explicit list whenever you mean a list, and note that an empty array is not the same as omitting the field.

Steam answers offers per account session, so N offers on one account become one worker task carrying N ids, never N tasks. That is why this is an account sub-resource rather than a flat bulk endpoint. Duplicate ids are collapsed. The list is capped at 1,000 ids (`422 bulk_limit_exceeded`, carrying `max`); if you need more than that, omit the field.

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/trade-offers/respond \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"action": "accept", "offer_ids": ["9211449039", "9211449102"]}'
```

```php tab=PHP
$result = Http::withToken($apiKey)
    ->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
    ->post("https://dashboard.steamlabs.dev/api/v1/accounts/{$accountId}/trade-offers/respond", [
        'action' => 'accept',
        'offer_ids' => ['9211449039', '9211449102'],
    ])
    ->json();
```

```python tab=Python
import requests, uuid

result = requests.post(
    f"https://dashboard.steamlabs.dev/api/v1/accounts/{account_id}/trade-offers/respond",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"action": "accept", "offer_ids": ["9211449039", "9211449102"]},
).json()
```

```json
{
    "task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
    "accounts_affected": 1,
    "offers_requested": 2
}
```

`offers_requested` is `null` when you omitted `offer_ids`, which is how you can tell a fan-out from a list in your own logs.

## List an account's confirmations

Steam mobile confirmations waiting on one account.

```endpoint
method: GET
path: /api/v1/accounts/{account}/confirmations
description: The latest confirmation snapshot for one account.
auth: bearer
```

Requires `trading.read`.

Takes `page` and `per_page` only.

```bash
curl https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/confirmations \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "14958203481",
            "nonce": "6821994733170518043",
            "type": 2,
            "type_name": "Trade Offer",
            "creator_id": "9211449039",
            "headline": "Sent a trade offer",
            "summary": ["To: TradeBot_04"],
            "icon": "https://community.cloudflare.steamstatic.com/economy/image/…",
            "creation_time": 1785412342,
            "accept_label": "Confirm",
            "cancel_label": "Cancel"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 },
    "has_identity_secret": true,
    "fetched_at": "2026-07-30T14:02:11+00:00",
    "pending_task_id": null,
    "last_error": null
}
```

| Field | Type | Description |
| --- | --- | --- |
| `has_identity_secret` | boolean | Whether this account can answer confirmations at all |
| `fetched_at` | string | When the snapshot was taken, or `null` if none ever was |
| `pending_task_id` | uuid | The confirmation task currently holding the account, if any |
| `last_error` | string | The error from the last confirmation task, when that task failed |

> [!IMPORTANT]
> There is no durable confirmations table. Steam does not offer one, so what you read is a **snapshot** from the last completed fetch, and it goes stale the moment someone confirms something on their phone. Always read `fetched_at` beside the rows: an empty `data` means "nothing was waiting when we last looked", not "nothing is waiting".

`creation_time` is Steam's raw Unix timestamp in seconds, not an ISO 8601 string like the rest of the API. Reading is allowed on an account with no identity secret: you get an empty list and `has_identity_secret: false`, which is the useful answer.

## Refresh an account's confirmations

```endpoint
method: POST
path: /api/v1/accounts/{account}/confirmations/refresh
description: Queue a fresh confirmation snapshot for one account.
auth: bearer
```

Requires `trading.write`.

No `Idempotency-Key` needed, and no body. Answers `202` with `task_id` and `accounts_affected`. Poll the task, then re-read the listing.

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/confirmations/refresh \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

An account with no identity secret cannot fetch either, and is refused with `422 identity_secret_missing`. Add the secret through [Steam accounts](/docs/api/en/endpoints/accounts) first.

## Answer an account's confirmations

```endpoint
method: POST
path: /api/v1/accounts/{account}/confirmations/respond
description: Confirm or cancel some or all of an account's pending confirmations.
auth: bearer
```

Requires `trading.write`. An `Idempotency-Key` header is **required**.

| Field | Type | Description |
| --- | --- | --- |
| **`accept`** | boolean | `true` confirms, `false` cancels |
| `confirmation_ids` | array | The confirmation ids to answer, as strings of up to 64 characters. Omit the field to answer every confirmation in the latest snapshot |

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/accounts/019fb42e-9a7e-728d-b960-8b4c2162898c/confirmations/respond \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"accept": true, "confirmation_ids": ["14958203481"]}'
```

```javascript tab=Node
const response = await fetch(
    `https://dashboard.steamlabs.dev/api/v1/accounts/${accountId}/confirmations/respond`,
    {
        method: 'POST',
        headers: {
            Authorization: `Bearer ${apiKey}`,
            'Idempotency-Key': crypto.randomUUID(),
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({ accept: true, confirmation_ids: ['14958203481'] }),
    },
);
```

```json
{
    "task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2",
    "accounts_affected": 1,
    "confirmations_requested": 1
}
```

Ids are matched against the latest snapshot before anything is queued, so an id that has since vanished is dropped rather than failing the batch. If none of your ids survive that match you get `422 no_matching_confirmations` with `known`, the number of confirmations the snapshot does hold. Refresh and try again.

`confirmations_requested` is `null` when you omitted `confirmation_ids`.

## List routing templates

A routing template is a named, ordered ruleset you reuse when distributing items or sending an inventory selection (`routing_template_id` on `POST /api/v1/inventory/send`).

```endpoint
method: GET
path: /api/v1/trade-routing-templates
description: Your saved routing templates, with their full rulesets.
auth: bearer
```

Requires `trading.read`.

| Parameter | Type | Description |
| --- | --- | --- |
| `search` | string | Matches the template name |
| `sort` | string | `name` (default), `created_at`, `updated_at` |
| `order` | string | `asc` (default), `desc` |
| `per_page` | integer | Page size |

```bash
curl "https://dashboard.steamlabs.dev/api/v1/trade-routing-templates?search=vault" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb432-77c1-71bd-9a04-4f0e2c9a8d31",
            "name": "Knives to the vault",
            "rules_count": 3,
            "rules": [
                {
                    "action": "skip",
                    "games": [],
                    "price_min": null,
                    "price_max": 0.03,
                    "item_ids": [],
                    "categories": [],
                    "origins": [],
                    "target_account_ids": [],
                    "target_tag_ids": [],
                    "target_trade_urls": [],
                    "strategy": "round_robin",
                    "target_value": null,
                    "items_limit_per_target": null
                }
            ],
            "created_at": "2026-07-12T10:20:00+00:00",
            "updated_at": "2026-07-30T14:02:11+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

Rules always come back in canonical shape: every key present, conditions you left out materialized as empty lists or `null`. Read them with that in mind rather than checking whether a key exists.

## Create a routing template

The richest body in the API. It is the panel's rules builder, persisted.

```endpoint
method: POST
path: /api/v1/trade-routing-templates
description: Save a named routing ruleset.
auth: bearer
```

Requires `trading.write`.

| Field | Type | Description |
| --- | --- | --- |
| **`name`** | string | Up to 80 characters, unique among your own templates |
| **`rules`** | array | 1 to 50 rule objects, in evaluation order |

### How rules are evaluated

Rules run top to bottom, first match wins. Each item is tested against every rule's conditions in order, and the first rule that matches decides what happens to it: routed onto that rule's targets, or skipped. An item matching no rule stays where it is. A rule with no conditions matches everything, so put your narrow rules first and your catch-all last.

### A rule

Conditions are ANDed, and each one left out or empty means "any".

| Field | Type | Description |
| --- | --- | --- |
| **`action`** | string | `route` (send matched items to the targets) or `skip` (leave them alone, and stop later rules seeing them) |
| `games` | array | `cs2`, `tf2`, `steam`. The item's game must be one of these |
| `price_min` | number | Minimum Steam market price, in **US dollars**. Unpriced items count as `$0` |
| `price_max` | number | Maximum price, in US dollars. Must be at least `price_min` |
| `item_ids` | array | Catalog item uuids. The item must be one of them. Up to 1,000 |
| `categories` | array | Coarse item types: `weapon`, `knife`, `gloves`, `container`, `sticker`, `graffiti`, `charm`, `patch`, `agent`, `music_kit`, `collectible`, `tool`, `key`, `pass`, `gift`, `trading_card`, `booster_pack`, `emoticon` |
| `origins` | array | CS2 item origins as integers, for example `0` (timed drop), `2` (purchased), `3` (traded), `8` (found in crate) |
| `strategy` | string | How matched items spread over the targets. Required when `action` is `route` |
| `target_value` | number | US dollars per target. Required for `fill_value` and `top_up_value` |
| `items_limit_per_target` | integer | At most this many items per target. Omit for no limit |
| `target_account_ids` | array | Your own Steam account uuids to route onto. Up to 1,000 |
| `target_tag_ids` | array | Account tag uuids. Every account carrying any of them becomes a target. Up to 1,000 |
| `target_trade_urls` | array | External Steam trade URLs. Up to 1,000, each up to 255 characters |

A `route` rule needs a strategy and at least one destination, in `target_account_ids`, `target_tag_ids` or `target_trade_urls`, in any combination. A `skip` rule needs none of them, and its target and strategy fields are ignored.

> [!NOTE]
> A tag target is resolved to its member accounts each time the ruleset runs, not when you save it. The rule follows the group as accounts are tagged and untagged, which is what makes it different from listing the same accounts in `target_account_ids`.

### Strategies

| Value | What it does |
| --- | --- |
| `round_robin` | Spread matched items evenly across the targets |
| `random` | Each item goes to a random target that still has room |
| `fill_value` | Pack priciest first, up to `target_value` per target |
| `top_up_value` | Like `fill_value`, but counts what an account target already holds toward `target_value` |

### Worked example

Skip the near-worthless items, park knives and gloves on the vault account, spread everything else over two accounts and one external trade URL.

```bash tab=curl
curl -X POST https://dashboard.steamlabs.dev/api/v1/trade-routing-templates \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d @template.json
```

```php tab=PHP
$template = Http::withToken($apiKey)
    ->post('https://dashboard.steamlabs.dev/api/v1/trade-routing-templates', [
        'name' => 'Knives to the vault',
        'rules' => $rules,
    ])
    ->json();
```

```python tab=Python
import requests

template = requests.post(
    "https://dashboard.steamlabs.dev/api/v1/trade-routing-templates",
    headers={"Authorization": f"Bearer {api_key}"},
    json={"name": "Knives to the vault", "rules": rules},
).json()
```

```json
{
    "name": "Knives to the vault",
    "rules": [
        {
            "action": "skip",
            "price_max": 0.03
        },
        {
            "action": "route",
            "games": ["cs2"],
            "categories": ["knife", "gloves"],
            "strategy": "top_up_value",
            "target_value": 2500,
            "items_limit_per_target": 40,
            "target_account_ids": ["019fb42e-9a7e-728d-b960-8b4c2162898c"]
        },
        {
            "action": "route",
            "games": ["cs2"],
            "price_min": 1.5,
            "origins": [0, 8],
            "strategy": "round_robin",
            "target_account_ids": [
                "019fb42e-9c10-7233-8f7a-5b2d90c14a77",
                "019fb42e-9c22-70ab-b3c4-118e6f2d0e59"
            ],
            "target_trade_urls": [
                "https://steamcommunity.com/tradeoffer/new/?partner=39734289&token=AbCd1234"
            ]
        }
    ]
}
```

`201 Created` with the saved template, rules normalized:

```json
{
    "id": "019fb432-77c1-71bd-9a04-4f0e2c9a8d31",
    "name": "Knives to the vault",
    "rules_count": 3,
    "rules": [
        {
            "action": "skip",
            "games": [],
            "price_min": null,
            "price_max": 0.03,
            "item_ids": [],
            "categories": [],
            "origins": [],
            "target_account_ids": [],
            "target_tag_ids": [],
            "target_trade_urls": [],
            "strategy": "round_robin",
            "target_value": null,
            "items_limit_per_target": null
        },
        {
            "action": "route",
            "games": ["cs2"],
            "price_min": null,
            "price_max": null,
            "item_ids": [],
            "categories": ["knife", "gloves"],
            "origins": [],
            "target_account_ids": ["019fb42e-9a7e-728d-b960-8b4c2162898c"],
            "target_tag_ids": [],
            "target_trade_urls": [],
            "strategy": "top_up_value",
            "target_value": 2500,
            "items_limit_per_target": 40
        },
        {
            "action": "route",
            "games": ["cs2"],
            "price_min": 1.5,
            "price_max": null,
            "item_ids": [],
            "categories": [],
            "origins": [0, 8],
            "target_account_ids": [
                "019fb42e-9c10-7233-8f7a-5b2d90c14a77",
                "019fb42e-9c22-70ab-b3c4-118e6f2d0e59"
            ],
            "target_tag_ids": [],
            "target_trade_urls": [
                "https://steamcommunity.com/tradeoffer/new/?partner=39734289&token=AbCd1234"
            ],
            "strategy": "round_robin",
            "target_value": null,
            "items_limit_per_target": null
        }
    ],
    "created_at": "2026-07-30T14:02:11+00:00",
    "updated_at": "2026-07-30T14:02:11+00:00"
}
```

> [!WARNING]
> `price_min`, `price_max` and `target_value` are US dollars with decimals, not the integer `*_cents` the rest of the API uses. `$0.03` is `0.03` here, not `3`.

> [!NOTE]
> A target account can only receive if we know its trade URL. Accounts without one are dropped from the plan when the ruleset actually runs, not when you save it, so save a template and then check the distribution preview before trusting it. Refresh an account's trades to learn its URL.

Validation is strict, because a bad ruleset would otherwise move items to the wrong place:

| Error field | Cause |
| --- | --- |
| `name` | Missing, over 80 characters, or already used by one of your templates |
| `rules` | Empty, over 50 rules, or naming a destination account, account tag or catalog item that does not exist |
| `rules.N.action` | Missing, or not `route` / `skip` |
| `rules.N.strategy` | A `route` rule with no strategy, or an unknown one |
| `rules.N.target_value` | `fill_value` or `top_up_value` with no target value |
| `rules.N.target_account_ids` | A `route` rule with no destination at all, in any of the three target fields |
| `rules.N.price_max` | Lower than `price_min` |
| `rules.N.target_trade_urls.N` | Not a `https://steamcommunity.com/tradeoffer/new/?partner=…&token=…` URL |

An account belonging to someone else fails exactly like an id that never existed, so a template cannot be used to probe for other people's accounts.

## Get one routing template

```endpoint
method: GET
path: /api/v1/trade-routing-templates/{id}
description: One routing template, with its full ruleset.
auth: bearer
```

Requires `trading.read`.

```bash
curl https://dashboard.steamlabs.dev/api/v1/trade-routing-templates/019fb432-77c1-71bd-9a04-4f0e2c9a8d31 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

## Replace a routing template

```endpoint
method: PUT
path: /api/v1/trade-routing-templates/{id}
description: Replace a template's name and its whole ruleset. PATCH does the same.
auth: bearer
```

Requires `trading.write`.

Same body as create, same validation, and `name` and `rules` are both required. This is a **full replacement**, not a patch: the rules are an ordered first-match-wins list, so "change rule 3" has no meaning without the rules above it. Read the template, edit the array, send the whole thing back.

```bash
curl -X PATCH https://dashboard.steamlabs.dev/api/v1/trade-routing-templates/019fb432-77c1-71bd-9a04-4f0e2c9a8d31 \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Knives to the vault", "rules": [{"action": "skip", "price_max": 0.05}]}'
```

Answers `200` with the saved template. Sending a template back under its own name is fine; the uniqueness check ignores the record being edited.

## Delete a routing template

```endpoint
method: DELETE
path: /api/v1/trade-routing-templates/{id}
description: Delete a routing template. Returns 204.
auth: bearer
```

Requires `trading.write`.

Work already queued is untouched. Applying a template copies its rules into the task, so a running distribution keeps the ruleset it started with.

## Errors

On top of the [universal codes](/docs/api/en/concepts/errors), this group returns:

| Code | Status | Meaning |
| --- | --- | --- |
| `offer_not_actionable` | `422` | The offer is not an active offer awaiting one of your accounts. Adds `state` |
| `offer_not_cancellable` | `422` | Not an offer you sent, or no longer open. Adds `state` |
| `trade_task_pending` | `409` | A trade task is already queued or running for that account. Adds `pending_task_id` |
| `identity_secret_missing` | `422` | That account has no identity secret, so it cannot answer confirmations |
| `confirmation_task_pending` | `409` | A confirmation task is already queued or running for that account. Adds `pending_task_id` |
| `no_matching_confirmations` | `422` | None of your ids are in the latest snapshot. Adds `known` |
| `maintenance_mode` | `503` | Platform maintenance pauses sending offers. Answering incoming offers, confirmations and cancels keep working. Adds `reason` |

The two `_pending` codes are the one-task-per-account guard, and they carry the task holding the account:

```json
{
    "message": "A trade task is already queued or running for this account.",
    "code": "trade_task_pending",
    "pending_task_id": "019fb440-3f01-72c6-a1de-1c5ad0f7b9e2"
}
```

Poll that task until it finishes, then retry. Do not spin on the endpoint: it will keep saying the same thing until the worker is done with the account.

Two plan entitlements gate this group, and `GET /api/v1/me` reports both up front. `allows_incoming_trades` covers fetching and answering incoming offers (the refresh, respond, accept and decline endpoints); `allows_confirmations` covers fetching and answering mobile confirmations. When your plan lacks one, those endpoints answer `403 plan_limit_reached` with the upgrade path attached. Cancelling an offer you already sent is never plan-gated: withdrawing your own trade always works.
