# Tasks

> Queue any of the 33 task types across your fleet, then follow, retry, or cancel the work.


Tasks are what the platform actually does. Everything else (accounts, proxies, profiles, integrations) exists so that a task can run. One endpoint creates them, the rest let you watch, retry, and stop them.

Two scopes cover the domain. `tasks.read` for the catalogue, the listings and a single task, `tasks.write` for creating, retrying, cancelling and deleting. `POST /api/v1/tasks/preview` also needs `tasks.write` even though it writes nothing, so a read-only key cannot size up a selection.

Start at [the task catalogue](#discover-what-you-can-create). It tells you the 33 types you may create, the exact config each one accepts, and which of them your plan covers. A client that guesses instead will cache the wrong shapes and find out through `422`.

> [!IMPORTANT]
> `POST /api/v1/tasks` requires an `Idempotency-Key` header. So do the two retry endpoints. Creating tasks spends wallet balance, lists real items and burns single-use codes, so a retry after a timeout must replay rather than repeat. See [Bulk operations](/docs/api/en/concepts/bulk-operations).

## Selecting accounts

Creating tasks and previewing a selection both take a **selection**: which Steam accounts the work targets. There are two shapes, and you send exactly one.

```json
{ "account_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5", "019fb42e-9a7e-728d-b960-8b4c2162898c"] }
```

An explicit list, capped at 1,000 ids. Over the cap you get `422 bulk_limit_exceeded` with the cap in `max`.

```json
{ "filters": { "guard": "mobile", "wallet": "has" }, "exclude_account_ids": ["019fb42e-9a7e-728d-b960-8b4c2162898c"] }
```

A definition resolved on the server. The ids are never materialized, so "every account with a mobile authenticator" is the same size on the wire whether it names four accounts or forty thousand. A filter selection always runs asynchronously and always answers `202`, even when it happened to resolve small enough to run inline.

Sending both is refused, and so is sending neither: a request with no selection would otherwise mean "every account I own" by accident.

### Filter keys

| Key | Values |
| --- | --- |
| `search` | Username, persona name, or Steam64 id |
| `guard` | `mobile`, `email`, `none` |
| `wallet` | `has`, `none` |
| `details` | `refreshed`, `never` |
| `market` | `eligible`, `restricted`, `unknown` |
| `csfloat` | `ready` |
| `inventory` | `synced`, `never` |
| `session` | `online`, `offline` |
| `tags` | Array of tag ids. Matches an account carrying any of them |
| `capability` | Array of `sell_steam`, `sell_csfloat`, `sell_marketcsgo`, `place_buy_orders`. Matches any of them |
| `wallet_currency` | A wallet currency code, for example `EUR` |
| `wallet_min`, `wallet_max` | Numeric balance bounds |
| `exclude_account_ids` | Ids to drop from the match, capped at 1,000. Only meaningful with `filters` |

`wallet_min` and `wallet_max` compare natively when `wallet_currency` is also set. Without it, balances are normalized to USD, because a bare number would otherwise compare 15 PLN against 15 EUR. An account whose balance has never been reported matches neither bound.

`session` is read from the live worker registry rather than a column. While no worker is reporting, the filter is ignored rather than matching nothing.

> [!WARNING]
> This is a shorter vocabulary than the one `GET /api/v1/accounts` and the account bulk endpoints use. Keys the accounts list accepts but a task selection does not (`ban_status`, `login_state`, `marketplace_access`, `boost`, `market_unlocking_soon`, `steam_market_ineligible`, `profile_not_set_up`, `details_never_refreshed`) are dropped here without an error. The account bulk endpoints themselves refuse an unrecognised key with `422 filters_not_understood`; this surface is the one that drops.
>
> Dropping them changes what you asked for in two different ways. A `filters` object made **only** of unrecognised keys is dropped entirely, so the request names nobody and you get `422 no_eligible_accounts`. A mixed object keeps the keys it understood and quietly ignores the rest, so `{"guard": "mobile", "details_never_refreshed": true}` queues work for **every** mobile-authenticator account, not just the never-refreshed ones.
>
> Run any filter through `POST /api/v1/tasks/preview` before you create anything. The count tells you immediately whether the server understood what you meant.

Only `search`, `session` and `tags` are spelled the same in both vocabularies. For the account-side filters, use `GET /api/v1/accounts` to get the ids and pass them as `account_ids`.

## Task lifecycle

A task moves through six statuses.

| Status | Meaning |
| --- | --- |
| `status:pending` | Created but held back, waiting for fleet capacity. Large batches start here |
| `status:queued` | Handed to the worker fleet, not picked up yet |
| `status:running` | A worker has it |
| `status:completed` | Finished, and it did what it was asked |
| `status:failed` | Finished with an error. `error` says what went wrong |
| `status:cancelled` | Stopped before it finished |

The last three are final. Nothing leaves them.

### Parents and children

How many accounts are eligible decides the shape of what you get back.

- **One eligible account**: a single standalone task, bound to that account. `201` with the task record.
- **Many eligible accounts**: a **batch parent** with one child task per account. The parent has no `steam_account_id` of its own and carries the rolled-up counters in `children`.
- **More than 500 eligible accounts, or any filter selection**: the same batch parent, but built by a queued job. `202` with just the parent's id.

"Eligible" excludes accounts that already have a pending, queued or running task of the same type, which is what stops a repeated call from double-queueing anyone. It also excludes accounts a type cannot work on: `check_bans` skips accounts with no `steam64_id`, `assign_currency` skips accounts that already have a wallet, `place_buy_order` skips accounts that cannot fund the order. Those show up as `accounts_skipped`.

When anything was skipped, the response also carries `accounts_skipped_reasons`: an object of reason slug to account count. An account failing several checks is counted once, against the first check it failed, so the counts sum to `accounts_skipped` (any remainder a reason could not be pinned on is simply not listed). The slugs are stable:

| Slug | The skipped accounts |
| --- | --- |
| `task_in_flight` | Already have a pending, queued or running task of this type |
| `nothing_to_list` | No inventory items matching the sell config (`sell_items`, any marketplace) |
| `no_authenticator` | No mobile authenticator, so listings cannot be confirmed (`sell_items` on Steam) |
| `market_restricted` | Known Community Market restriction (`sell_items` on Steam) |
| `not_ready_csfloat` | Not connected and enabled for CSFloat, or no mobile authenticator |
| `not_ready_marketcsgo` | Not connected and enabled for Market.CSGO, no dedicated proxy, or no mobile authenticator |
| `no_matching_units` | No storage units (or none matching the name) with anything to move (storage types) |
| `insufficient_funds` | Cannot fund `price x quantity` on top of open orders (`place_buy_order`) |
| `cannot_place_buy_orders` | Missing market access, mobile authenticator, or billing address (`place_buy_order`) |
| `unsupported_wallet_currency` | Wallet currency paysafecard does not support (`add_funds_paysafecard` in tier mode) |
| `never_refreshed` | No `steam64_id` on record yet (`check_bans`) |
| `wallet_already_assigned` | Already have a wallet currency (`assign_currency`) |

The field is absent when nothing was skipped, and on the few pipelines that do not attribute their skips (distribution, boosting, trade-ups).

### Following a fan-out

```bash
curl "https://dashboard.steamlabs.dev/api/v1/tasks/019fb431-2c88-71ea-b0a3-8ce2f5d19b07" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

Poll the parent. It sits at `status:queued` while its children are still being written, flips to `status:running` once they all exist, and finishes when the last child does. `children.total` grows as the batch is built, so treat `completed + failed + cancelled == total` as done rather than watching `total` alone.

For per-account detail, use `GET /api/v1/tasks/{id}/children`. Do not page the whole child set just to count outcomes: the parent's counters already have them.

## Discover what you can create

Every creatable type, its config schema, and whether your plan covers it.

```endpoint
method: GET
path: /api/v1/task-types
description: The task catalogue: 33 types, their config shapes, and your plan's access to each.
auth: bearer
```

Requires `tasks.read`.

| Parameter | Type | Description |
| --- | --- | --- |
| `page` | integer | Page number, default `1` |
| `per_page` | integer | Rows per page, default `50`, clamped to `200`. All 33 types fit on the first page |

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/task-types" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```python tab=Python
import requests

types = requests.get(
    "https://dashboard.steamlabs.dev/api/v1/task-types",
    headers={"Authorization": f"Bearer {api_key}"},
).json()["data"]

creatable = {t["type"]: t["config_schema"] for t in types if t["allowed"]}
```

```javascript tab=Node
const response = await fetch('https://dashboard.steamlabs.dev/api/v1/task-types', {
    headers: { Authorization: `Bearer ${apiKey}` },
});

const { data } = await response.json();
const creatable = data.filter((type) => type.allowed);
```

```json
{
    "data": [
        {
            "type": "claim_weekly_drops",
            "label": "Claim Weekly Drops",
            "description": "Claim each account's two weekly CS2 drops, picked automatically by highest price or from your selection.",
            "category": "cs2",
            "requires_steam_account": true,
            "allowed": true,
            "blocked_by": null,
            "plan": null,
            "config_schema": {
                "strategy": {
                    "type": "string",
                    "required": true,
                    "enum": ["highest_price", "manual"],
                    "default": "highest_price"
                },
                "ignore_graffitis": { "type": "boolean", "required": false, "default": false },
                "claim_item_ids": {
                    "type": "array",
                    "required": false,
                    "max": 2,
                    "default": [],
                    "items": { "type": "string", "required": false, "max": 64 }
                }
            }
        },
        {
            "type": "trade_up_contract",
            "label": "Trade-Up Contract",
            "description": "Craft 10 same-rarity skins into 1 skin of the next rarity, picked automatically by expected value or by hand.",
            "category": "cs2",
            "requires_steam_account": true,
            "allowed": false,
            "blocked_by": "plan",
            "plan": {
                "heading": "Not included in your plan",
                "badge": "Available on Pro or higher",
                "upgrade_url": "https://dashboard.steamlabs.dev/subscription"
            },
            "config_schema": { "…": {} }
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 33, "last_page": 1 }
}
```

Locked types are listed, not hidden, exactly as the panel greys them instead of dropping them. `allowed` is `false` and `plan` carries the same block a `403 plan_limit_reached` would return, so an integration can tell "you have not bought this" apart from "this does not exist".

`blocked_by` says why a locked entry is locked: `plan` (buy more plan) or `maintenance` (wait it out). During [platform maintenance](/docs/api/en/concepts/errors) every entry reports `allowed: false` with `blocked_by: "maintenance"`, and the `plan` block's `upgrade_url` is `null` because no purchase lifts it.

`category` is one of `account`, `profile`, `trading`, `inventory`, `market`, `cs2`, `tf2`. `requires_steam_account` is `false` for the four orchestration types (`distribute_items`, `store_items`, `withdraw_items`, `sell_items`), whose parents group per-account children rather than reaching a worker themselves.

### Reading a config schema

Each field in `config_schema` is described with the same descriptor the server validates against, so the two cannot drift.

| Key | Meaning |
| --- | --- |
| `type` | `string`, `integer`, `number`, `boolean`, `array`, or `object` |
| `required` | Always required |
| `required_if` | `{ "field": "mode", "values": ["manual"] }`: required when that sibling matches |
| `enum` | The allowed values |
| `min`, `max` | A numeric bound for numbers, a length for strings, an item count for arrays |
| `format` | An extra format rule, `uuid` or `url` |
| `default` | The panel's default, published so you can mirror it |
| `items` | The element shape of an array. For arrays of objects, `items.properties` holds the per-object fields |

Fields that are not required accept `null`, which means the same as leaving the key out.

## List tasks

```endpoint
method: GET
path: /api/v1/tasks
description: Your tasks, newest first, paginated and filterable.
auth: bearer
```

Requires `tasks.read`.

| Parameter | Type | Description |
| --- | --- | --- |
| `status` | string | One status: `pending`, `queued`, `running`, `completed`, `failed`, `cancelled` |
| `type` | string | One task type value, for example `sell_items` |
| `parent` | uuid | Only children of this batch parent |
| `top_level` | boolean | `true` for parents and standalone tasks only, hiding batch children |
| `account_id` | uuid | Only tasks bound to this Steam account |
| `search` | string | Username, persona name or Steam64 id. Matches the task's own account, or for a batch parent any of its children's |
| `created_after` | date | Tasks created at or after this timestamp |
| `created_before` | date | Tasks created at or before this timestamp |
| `sort` | string | `created_at` (default), `started_at`, `finished_at` |
| `direction` | string | `asc`, `desc` (default) |
| `page`, `per_page` | integer | Default 50 per page, clamped to 200 |

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/tasks?top_level=true&status=running&per_page=1" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```php tab=PHP
$tasks = Http::withToken($apiKey)
    ->get('https://dashboard.steamlabs.dev/api/v1/tasks', [
        'top_level' => true,
        'status' => 'running',
    ])
    ->json('data');
```

```json
{
    "data": [
        {
            "id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
            "type": "refresh_details",
            "status": "running",
            "is_batch_parent": true,
            "is_cancelling": false,
            "parent_id": null,
            "steam_account_id": null,
            "steam_account": null,
            "children": { "total": 4182, "completed": 3907, "failed": 12, "cancelled": 0 },
            "error": null,
            "worker_id": null,
            "cancel_requested_at": null,
            "dispatch_after": null,
            "started_at": "2026-07-30T14:02:11+00:00",
            "finished_at": null,
            "created_at": "2026-07-30T14:01:58+00:00",
            "updated_at": "2026-07-30T14:19:40+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 1, "total": 1, "last_page": 1 }
}
```

`task_data` and `logs` are deliberately absent from list rows. A storage batch's payload carries thousands of asset ids and a finished task's log is unbounded, so a 200-row page would be megabytes nobody asked for. Both are on the single-task response.

`top_level=true` is the filter you want for a dashboard. Without it, a 100,000-account batch puts 100,000 children in your list.

## Preview a selection

Resolve a selection and count it, without creating anything.

```endpoint
method: POST
path: /api/v1/tasks/preview
description: How many accounts a selection resolves to. Writes nothing.
auth: bearer
```

Requires `tasks.write`. No `Idempotency-Key`: it writes nothing, so a retry costs one `COUNT(*)`.

Takes a [selection](#selecting-accounts) and nothing else. No type, no config.

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks/preview" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": {"guard": "mobile", "market": "eligible"}}'
```

```python tab=Python
affected = requests.post(
    "https://dashboard.steamlabs.dev/api/v1/tasks/preview",
    headers={"Authorization": f"Bearer {api_key}"},
    json={"filters": {"guard": "mobile", "market": "eligible"}},
).json()["accounts_affected"]

if affected > 500:
    raise SystemExit(f"refusing to queue {affected} accounts")
```

```json
{ "accounts_affected": 4182 }
```

> [!TIP]
> Preview every filter before you queue it, and put a ceiling on the count in your own code. A filter that quietly matches your whole fleet is much easier to notice here than after 80,000 tasks exist.

The count is the selection, not the eligible set. `POST /api/v1/tasks` will usually create fewer tasks than this, because accounts that already have a task of the same type queued are skipped.

## Cancel many tasks

```endpoint
method: POST
path: /api/v1/tasks/cancel
description: Ask a list of tasks to stop.
auth: bearer
```

Requires `tasks.write`.

| Field | Type | Description |
| --- | --- | --- |
| **`task_ids`** | array | Task ids, at least one, capped at 1,000 |

Explicit ids only. There is no filter mode here on purpose: these act on task rows you have just listed and can see, and "cancel everything queued" is a blast radius you should have to spell out.

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks/cancel" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"task_ids": ["019fb431-2c88-71ea-b0a3-8ce2f5d19b07", "019fb431-3d0f-7002-9a41-51c8b6f7e2aa"]}'
```

```json
{ "cancelled": 1, "skipped": 1 }
```

`skipped` covers everything that did not move: tasks that finished between your listing and this call, tasks already cancelling, children of a live batch (cancel the parent instead), and ids that are not yours.

## Create tasks

The endpoint everything else on this page exists to support.

```endpoint
method: POST
path: /api/v1/tasks
description: Create tasks of one type across a selection of accounts.
auth: bearer
```

Requires `tasks.write`. **Requires an `Idempotency-Key` header.** See [Bulk operations](/docs/api/en/concepts/bulk-operations).

| Field | Type | Description |
| --- | --- | --- |
| **`type`** | string | One of the 33 values from `GET /api/v1/task-types`. Anything else is refused |
| `config` | object | The type's own options. Shape depends on `type`, see [Config by task type](#config-by-task-type) |
| `account_ids` | array | Explicit account ids, capped at 1,000 |
| `filters` | object | A [filter selection](#selecting-accounts) instead of ids |
| `exclude_account_ids` | array | Ids to drop from a filter selection, capped at 1,000 |

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "type": "sell_items",
        "config": { "marketplace": "steam", "mode": "undercut", "undercut_cents": 1, "categories": ["container"], "max_items": 50 },
        "filters": { "market": "eligible", "inventory": "synced" }
      }'
```

```php tab=PHP
$response = Http::withToken($apiKey)
    ->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
    ->post('https://dashboard.steamlabs.dev/api/v1/tasks', [
        'type' => 'sell_items',
        'config' => [
            'marketplace' => 'steam',
            'mode' => 'undercut',
            'undercut_cents' => 1,
            'categories' => ['container'],
            'max_items' => 50,
        ],
        'filters' => ['market' => 'eligible', 'inventory' => 'synced'],
    ]);

$taskId = $response->json('task_id') ?? $response->json('id');
```

```javascript tab=Node
const response = await fetch('https://dashboard.steamlabs.dev/api/v1/tasks', {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': crypto.randomUUID(),
    },
    body: JSON.stringify({
        type: 'refresh_details',
        account_ids: ['019fb42e-9a61-70d2-818a-f6a56593f3a5'],
    }),
});
```

```python tab=Python
import uuid, requests

response = requests.post(
    "https://dashboard.steamlabs.dev/api/v1/tasks",
    headers={
        "Authorization": f"Bearer {api_key}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "type": "claim_weekly_drops",
        "config": {"strategy": "highest_price", "ignore_graffitis": True},
        "filters": {"guard": "mobile"},
    },
)
```

### What you get back

**`201 Created`**, when explicit ids resolved to 500 or fewer eligible accounts. The task record (a standalone task, or the batch parent) plus two counters:

```json
{
    "id": "019fb440-7a10-7c33-bd51-2f0c9e4471d2",
    "type": "refresh_details",
    "status": "queued",
    "is_batch_parent": false,
    "is_cancelling": false,
    "parent_id": null,
    "steam_account_id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
    "steam_account": {
        "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
        "username": "farm_017",
        "persona_name": "Ada",
        "steam64_id": "76561198000000017"
    },
    "children": { "total": 0, "completed": 0, "failed": 0, "cancelled": 0 },
    "error": null,
    "worker_id": null,
    "cancel_requested_at": null,
    "dispatch_after": null,
    "started_at": null,
    "finished_at": null,
    "created_at": "2026-07-30T14:02:11+00:00",
    "updated_at": "2026-07-30T14:02:11+00:00",
    "tasks_created": 1,
    "accounts_skipped": 0
}
```

**`202 Accepted`**, for every filter selection and for any batch over 500 eligible accounts. The parent exists, its children are being written by a queued job:

```json
{
    "task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
    "accounts_affected": 4182,
    "accounts_skipped": 311,
    "accounts_skipped_reasons": { "task_in_flight": 292, "never_refreshed": 19 }
}
```

**`200 OK`**, only for a trade-up dry run (`config.trade_up_dry_run: true`). Guardrails were evaluated, nothing was created and nothing was consumed:

```json
{
    "dry_run": { "contracts": 42, "slots": 378, "cost": 118.44, "ev": 131.02, "scanned": 100, "total": 4182 }
}
```

The dry run scans at most the first 100 accounts of the selection, so `scanned` and `total` differ on anything large.

> [!NOTE]
> Config keys the type does not understand are dropped, not refused, so a client built against a newer build is not broken by an older server. Nothing unvalidated ever reaches the worker. If a field seems to have no effect, check its spelling against `config_schema`.

### When nothing is created

| Status | Code | Why |
| --- | --- | --- |
| `422` | `no_eligible_accounts` | Every account in the selection already has a task of this type queued, or none met the type's requirements. Adds `accounts_skipped`, plus `accounts_skipped_reasons` saying which requirement each account failed |
| `422` | `nothing_to_plan` | The selection was fine, but the planner matched no items. Only for `distribute_items`, `sell_items` and the storage types |
| `422` | `proxies_required` | You own no proxies and hold more accounts than the shared pool covers, so these logins would be refused anyway |
| `403` | `plan_limit_reached` | Your plan's `allowed_task_types` does not cover this type, or its `allowed_marketplaces` does not cover the venue |
| `503` | `maintenance_mode` | Platform maintenance pauses new tasks. Adds `reason`. Transient, see [Errors](/docs/api/en/concepts/errors) |

The two `422`s are different answers and worth telling apart. `no_eligible_accounts` means "pick different accounts"; `nothing_to_plan` means "the accounts were fine, their inventories were not".

`plan_limit_reached` is not transient, and retrying never fixes it. `GET /api/v1/task-types` tells you which types are locked before you ask, and `GET /api/v1/me` reports `allowed_task_types` and `allowed_marketplaces` outright. The marketplace half bites `sell_items` (per its `config.marketplace`) and `place_buy_order` (always Steam).

## Config by task type

`config` is validated against the `type` you sent. Every type below is creatable; the shapes here are the same descriptors `GET /api/v1/task-types` publishes.

Throughout: `min` and `max` are a numeric bound on numbers, a character length on strings, and an item count on arrays. Fields not marked required may be omitted or sent as `null`, which mean the same thing.

### Item conditions

Four types narrow which items they act on with the same shared block: `sell_items`, `distribute_items` (also inside each routing rule), `store_items` and `withdraw_items`.

| Field | Type | Description |
| --- | --- | --- |
| `games` | array | `cs2`, `tf2`, `steam`. Absent on the storage types, whose units only ever hold CS2 items |
| `price_min` | number | Lowest per-item price to include |
| `price_max` | number | Highest per-item price to include |
| `item_ids` | array | Specific item ids, strings up to 191 characters |
| `categories` | array | `weapon`, `knife`, `gloves`, `container`, `sticker`, `graffiti`, `charm`, `patch`, `agent`, `music_kit`, `collectible`, `tool`, `key`, `pass`, `gift`, `trading_card`, `booster_pack`, `emoticon` |
| `origins` | array | Steam item origin ids, integers |

Leave the whole block out and the type acts on everything it can. That is fine for a storage deposit and expensive for a sale, which is why `sell_items` demands an explicit acknowledgement (see below).

### No configuration

These take no `config` at all. Send `{}` or leave the key out.

| Type | What it does |
| --- | --- |
| `refresh_details` | Logs in and refreshes Steam details, wallet and inventory |
| `login` | Signs in and leaves the session open, so later tasks skip the login |
| `sign_out_everywhere` | Deauthorizes every Steam session on every device, ours included |
| `check_bans` | Reads VAC, community, game and trade ban state. No login needed. Accounts with no Steam64 id are skipped |
| `fetch_trade_url` | Re-reads the trade URL token and overwrites the stored one |
| `cancel_market_listings` | Cancels every active Steam market listing on the account |
| `sync_market_listings` | Pulls current listings, buy orders and wallet balance |
| `sync_market_history` | Pulls sales, purchases and cancelled listings |
| `cancel_buy_orders` | Cancels every open market buy order |
| `fetch_armory_progress` | Launches CS2 and reads pass stars, XP, credits, medal state and rank |
| `fetch_weekly_drops` | Launches CS2 and reads claimable weekly drops |
| `clear_armory_passes` | Removes completed armory passes to free pass slots |
| `claim_service_medal` | Claims the year's service medal where the rank allows it |

### Account and wallet

**`request_free_licenses`** registers the free licence for the games you name.

| Field | Type | Description |
| --- | --- | --- |
| **`app_ids`** | array | Steam app ids, 1 to 200 of them, each a positive integer |

**`redeem_wallet_code`** redeems Steam wallet codes, one code per account.

| Field | Type | Description |
| --- | --- | --- |
| **`wallet_codes`** | array | 1 to 10,000 codes, each 3 to 64 characters |

The batch is capped at the number of codes you sent: 40 codes across a 600-account selection creates 40 tasks and reports the rest as skipped. Codes are single use, which is the reason this endpoint carries an `Idempotency-Key`.

**`add_funds_paysafecard`** starts a wallet top-up and returns a payment link to finish off-site.

| Field | Type | Description |
| --- | --- | --- |
| **`mode`** | string | `tier` (default) or `exact` |
| `tier` | string | `tier_5`, `tier_10`, `tier_15`, `tier_25` (default), `tier_50`, `tier_100`. Required when `mode` is `tier` |
| `amount` | integer | 1 to 1,000,000, in the account's own wallet currency. Required when `mode` is `exact`. Default `5` |

A tier maps to the nearest denomination in each account's currency, so accounts in a currency with no denomination table are skipped rather than dispatched amountless.

**`buy_package`** buys a Steam store package with the account's wallet balance.

| Field | Type | Description |
| --- | --- | --- |
| **`package`** | string | `cs2_prime` (default) or `custom` |
| `custom_package` | string | Required when `package` is `custom`. A numeric package id, or a `https://store.steampowered.com/app/…` or `/sub/…` URL. Max 255 characters |

**`assign_currency`** gives a walletless account a wallet currency, so market and checkout work can run.

| Field | Type | Description |
| --- | --- | --- |
| **`currency`** | string | A wallet currency code, `USD` by default |

Only accounts with no wallet currency are eligible: Steam refuses to open a second wallet. A billing address for the currency's country is generated and stored on each account as part of the task.

### Profile

**`set_profile`** writes the profile, typed in once or drawn per account from your profile library.

| Field | Type | Description |
| --- | --- | --- |
| **`mode`** | string | `manual` (default), `profile`, or `group` |
| `persona_name` | string | 2 to 32 characters. Required when `mode` is `manual` |
| `avatar` | string | Path of an already-stored avatar on the public disk. There is no binary upload on this endpoint |
| `summary` | string | Profile description, up to 8,000 characters |
| `country` | string | Two-letter country code |
| `profile_id` | uuid | A library profile. Required when `mode` is `profile` |
| `profile_group_id` | uuid | A profile group. Required when `mode` is `group` |
| `allow_reuse` | boolean | Default `false`. With reuse off, the batch is capped at the number of unused profiles in the pool |

**`change_profile_privacy`** flips Steam visibility settings. Every field is optional and an omitted field is left unchanged, so one call can change a single setting across the fleet.

| Field | Type | Description |
| --- | --- | --- |
| `profile` | integer | `1` private, `2` friends only, `3` public |
| `owned_games` | integer | `1`, `2`, `3` |
| `inventory` | integer | `1`, `2`, `3` |
| `friends_list` | integer | `1`, `2`, `3` |
| `inventory_gifts` | integer | `1` private or `3` public. No friends-only step |
| `playtime` | integer | `1` private or `3` public |
| `comment_permission` | integer | `0` friends, `1` public, `2` private |

Send at least one. A config with none would read every account's settings and write them back unchanged, so it is refused with an error on `config.profile`.

### Inventory and storage

**`store_items`** deposits matching CS2 items into the account's storage units.

| Field | Type | Description |
| --- | --- | --- |
| **`store_mode`** | string | `all` (default) stores everything matching, `keep` leaves some in the inventory |
| `keep_count` | integer | How many matching items stay out. Required when `store_mode` is `keep` |
| **`unit_mode`** | string | `auto` (default) picks units with space, `named` targets units by name |
| `unit_name` | string | Up to 20 characters. Required when `unit_mode` is `named` |
| `max_items` | integer | Cap on items moved per account |
| item conditions | | The shared block above, without `games` |

**`withdraw_items`** moves matching items back out of storage.

| Field | Type | Description |
| --- | --- | --- |
| **`unit_mode`** | string | `all` (default) or `named` |
| `unit_name` | string | Up to 20 characters. Required when `unit_mode` is `named` |
| `max_items` | integer | Cap on items moved per account |
| item conditions | | The shared block above, without `games` |

**`rename_storage_unit`** renames storage units across accounts.

| Field | Type | Description |
| --- | --- | --- |
| **`unit_mode`** | string | `all` (default) or `named` |
| `unit_name` | string | Which units to rename. Required when `unit_mode` is `named` |
| **`new_name`** | string | The new name, up to 20 characters |

**`distribute_items`** sends items to one destination, or spreads them with routing rules.

| Field | Type | Description |
| --- | --- | --- |
| **`mode`** | string | `simple` (default) or `advanced` |
| `destination_type` | string | `own` (default) or `external`. Required when `mode` is `simple` |
| `destination_account_id` | uuid | The receiving account. Required when `destination_type` is `own` |
| `trade_url` | string | A full `https://steamcommunity.com/tradeoffer/new/?partner=…&token=…` URL, up to 500 characters. Required when `destination_type` is `external` |
| `rules` | array | Routing rules, at least one. Required when `mode` is `advanced` |
| `auto_accept` | boolean | Accept the offer on the receiving side. Default `true` |
| `message` | string | Trade offer message, up to 128 characters |
| item conditions | | The shared block above, with `games` |

Each entry of `rules` is an object:

| Field | Type | Description |
| --- | --- | --- |
| `action` | string | `route` (default) sends the matching items, `skip` leaves them alone |
| `strategy` | string | `round_robin` (default), `random`, `fill_value`, `top_up_value` |
| `target_account_ids` | array | Receiving accounts you own |
| `target_trade_urls` | array | Receiving trade URLs, up to 500 characters each |
| `target_value` | number | The value `fill_value` fills to, or `top_up_value` tops up to |
| `items_limit_per_target` | integer | Cap on items per target |
| item conditions | | The shared block above, with `games`, scoped to this rule |

### Market

**`sell_items`** lists matching marketable items, priced live per item.

| Field | Type | Description |
| --- | --- | --- |
| **`marketplace`** | string | `steam` (default), `csfloat`, `marketcsgo`. Decides which pipeline runs and which plan entitlement is checked |
| **`mode`** | string | `undercut` (default), `reference_percent`, `buy_order`, `top_bid`, `manual` |
| `undercut_cents` | integer | How far under the lowest listing to price, at least `1`. Default `1` |
| `percent` | number | Percentage of the reference price, 1 to 500. Required when `mode` is `reference_percent`. Default `100` |
| `floor_percent` | number | Refuse to list below this percentage of the reference price, 1 to 100. Default `70`. Ignored when `mode` is `manual` |
| `manual_price_cents` | integer | The exact buyer-pays price in cents, at least `3`. Required when `mode` is `manual` |
| `manual_currency` | string | The wallet currency `manual_price_cents` is quoted in, for example `PLN`. Default `USD` |
| `manual_convert` | boolean | Convert the price into each account's own wallet currency instead of requiring a match. Default `false` |
| `max_items` | integer | Cap on items listed per account |
| `confirm_sell_all` | boolean | Acknowledges an unbounded sale. Default `false` |
| item conditions | | The shared block above, with `games` |

Rules the sell form enforces visually apply here too:

- `buy_order` pricing is only available on `steam`, and `top_bid` only on `marketcsgo`. The wrong pairing fails on `config.mode`.
- A config with no item condition, no `max_items` and no `confirm_sell_all` would list every marketable item on every selected account. It is refused on `config.confirm_sell_all` until you set that flag to `true`.
- `manual` prices one item, so `config.item_ids` must name exactly one. Anything else is refused on `config.item_ids`.
- `manual_convert` is Steam-only. `csfloat` and `marketcsgo` quote in USD and have no per-account wallet to convert into, so pairing them fails on `config.manual_convert`.

#### Exact pricing across accounts

`mode: "manual"` puts one typed number on every eligible account, which is the only way to list at a price you choose rather than one derived from the order book. Two things make it behave:

`manual_currency` says what the number means. Steam wallets each have their own currency, so `707` is 7.07 zł on a PLN wallet and $7.07 on a USD one. Accounts whose live Steam wallet is not in `manual_currency` are skipped, and their items come back under the task's `skipped` list with reason `wallet_currency_mismatch`.

`manual_convert: true` trades exactness for coverage: nothing is skipped, and each account lists at that value converted into its own wallet currency using the item's own order book as the rate. Items whose rate cannot be derived are skipped with `no_fx_reference`.

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "type": "sell_items",
        "config": {
          "marketplace": "steam",
          "mode": "manual",
          "manual_price_cents": 707,
          "manual_currency": "PLN",
          "manual_convert": false,
          "item_ids": ["<item-id>"]
        },
        "filters": { "market": "eligible", "inventory": "synced" }
      }'
```

```php tab=PHP
$response = Http::withToken($apiKey)
    ->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
    ->post('https://dashboard.steamlabs.dev/api/v1/tasks', [
        'type' => 'sell_items',
        'config' => [
            'marketplace' => 'steam',
            'mode' => 'manual',
            'manual_price_cents' => 707,
            'manual_currency' => 'PLN',
            'manual_convert' => false,
            'item_ids' => ['<item-id>'],
        ],
        'filters' => ['market' => 'eligible', 'inventory' => 'synced'],
    ]);
```

**`place_buy_order`** places the same buy order across accounts.

| Field | Type | Description |
| --- | --- | --- |
| **`game`** | string | `cs2` (default), `tf2`, `steam` |
| **`market_hash_name`** | string | The exact item name, up to 255 characters |
| **`pricing_mode`** | string | `fixed` (default) or `outbid` |
| `price` | number | Unit price, at least `0.03`. Required when `pricing_mode` is `fixed` |
| `max_price` | number | Ceiling when outbidding, at least `0.03`. Required when `pricing_mode` is `outbid` |
| **`quantity`** | integer | 1 to 1,000. Default `1` |

Only accounts that can actually place buy orders are eligible: market eligible, mobile authenticator, billing address on file, and enough wallet balance to fund `price x quantity` on top of their already-open orders (Steam caps open orders at ten times the balance). Everything else is reported as `accounts_skipped`, split by reason in `accounts_skipped_reasons` (`insufficient_funds`, `cannot_place_buy_orders`, `task_in_flight`). In `outbid` mode the price is resolved per wallet currency at creation time.

### CS2 armory and drops

**`buy_store_items`** buys from the in-game CS2 store with the account's wallet balance.

| Field | Type | Description |
| --- | --- | --- |
| **`item`** | string | `armory_pass` (default), `storage_unit`, `name_tag` |
| **`quantity`** | integer | At least `1`, default `1`. Capped per item: 5 armory passes, 10 storage units, 10 name tags |
| `activate_after_purchase` | boolean | Activate armory passes as soon as they are bought. Default `true` |

**`buy_armory_items`** spends armory credits, split by weight.

| Field | Type | Description |
| --- | --- | --- |
| **`items_to_buy`** | array | At least one allocation line |
| `total_balance_percentage` | integer | How much of the credit balance to spend, 1 to 100. Default `100` |

Each line is `{ "item": "…", "percentage": 25 }`, where `item` is one of `fever_case`, `arabesque_collection`, `spy_tech_collection`, `overpass_2024_collection`, `auto_racing_stickers`, `fruits_and_veggies_stickers`, `community_2025_stickers`, `missing_link_charms`, `missing_link_community_charms`, `dr_boom_charms`, `small_arms_charms`, and `percentage` is 1 to 100.

> [!IMPORTANT]
> The percentages must total exactly 100. Anything else is refused on `config.items_to_buy` with the total you actually sent, because a budget split by weight silently under- or over-spends every account's credits otherwise.

**`activate_armory_passes`** activates bought-but-inactive passes.

| Field | Type | Description |
| --- | --- | --- |
| `max_to_activate` | integer | 1 to 1,000. Leave it out to activate every inactive pass |

**`claim_weekly_drops`** claims the account's two weekly CS2 drops.

| Field | Type | Description |
| --- | --- | --- |
| **`strategy`** | string | `highest_price` (default) picks for you, `manual` claims what you name |
| `ignore_graffitis` | boolean | Skip graffiti when picking. Default `false` |
| `claim_item_ids` | array | At most 2 ids, strings up to 64 characters. Steam allows two claims a week |

Run `fetch_weekly_drops` first if you want to review the options before claiming.

**`trade_up_contract`** crafts 10 same-rarity skins into 1 of the next rarity.

| Field | Type | Description |
| --- | --- | --- |
| **`trade_up_mode`** | string | `clear_space` (default) burns cheap inventory, `best_value` chases expected value |
| `trade_up_max_item_price` | number | Most expensive input to consume, at least `0.01`. Required when `trade_up_mode` is `clear_space`. Default `0.10` |
| `trade_up_max_contracts` | integer | Contracts per account, 1 to 100. Default `25` |
| `trade_up_min_ev` | number | Minimum expected value, -100 to 1000. Default `8` |
| `trade_up_max_input_cost` | number | Ceiling on the combined input cost of one contract |
| **`trade_up_rarities`** | array | Input rarities, at least one of `1` consumer, `2` industrial, `3` mil-spec, `4` restricted, `5` classified. Default `[1, 2]` |
| `trade_up_stattrak` | string | `any` (default), `normal`, `stattrak` |
| `trade_up_dry_run` | boolean | Evaluate the guardrails and report a digest without crafting. Default `false` |

Planning has side effects: a real run reserves the input items and writes ledger rows per account. Accounts with no viable contract under your guardrails are skipped. Set `trade_up_dry_run` to `true` first, which answers `200` with the digest and consumes nothing.

### TF2

**`buy_tf2_store_items`** buys from the in-game TF2 store with the account's wallet balance.

| Field | Type | Description |
| --- | --- | --- |
| **`item`** | string | `backpack_expander` (default) |
| **`quantity`** | integer | At least `1`, default `1`, capped at 10 |
| `use_after_purchase` | boolean | Apply the item as soon as it is bought. Default `true` |

**`use_tf2_items`** applies unused items already sitting in the backpack.

| Field | Type | Description |
| --- | --- | --- |
| **`item`** | string | `backpack_expander` (default) |
| `max` | integer | At least `1`. Leave it out to use every matching item |

## Delete many tasks

```endpoint
method: DELETE
path: /api/v1/tasks
description: Delete a list of tasks. Unfinished ones are cancelled first.
auth: bearer
```

Requires `tasks.write`.

| Field | Type | Description |
| --- | --- | --- |
| **`task_ids`** | array | Task ids, at least one, capped at 1,000 |

```bash tab=curl
curl -X DELETE "https://dashboard.steamlabs.dev/api/v1/tasks" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"task_ids": ["019fb431-2c88-71ea-b0a3-8ce2f5d19b07"]}'
```

```json
{ "deleted": 1, "skipped": 0 }
```

Unfinished tasks are flagged cancelled before the rows go, so the fleet drops the work instead of erroring on a missing row. Deleting a batch parent takes its children with it.

`skipped` counts ids that could not be deleted: children of a batch that has not finished (their results still feed the parent's counters), and ids that are not yours.

> [!NOTE]
> This endpoint takes a JSON body on a `DELETE`. Some HTTP clients drop bodies on `DELETE` by default. If you get a validation error saying `task_ids` is required, that is usually why.

## One task

```endpoint
method: GET
path: /api/v1/tasks/{id}
description: One task in full, including the worker payload and its logs.
auth: bearer
```

Requires `tasks.read`.

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/tasks/019fb440-7a10-7c33-bd51-2f0c9e4471d2" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "id": "019fb440-7a10-7c33-bd51-2f0c9e4471d2",
    "type": "claim_weekly_drops",
    "status": "failed",
    "is_batch_parent": false,
    "is_cancelling": false,
    "parent_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
    "steam_account_id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
    "steam_account": {
        "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
        "username": "farm_017",
        "persona_name": "Ada",
        "steam64_id": "76561198000000017"
    },
    "children": { "total": 0, "completed": 0, "failed": 0, "cancelled": 0 },
    "error": "The Game Coordinator session timed out.",
    "worker_id": "worker-eu-14",
    "cancel_requested_at": null,
    "dispatch_after": null,
    "started_at": "2026-07-30T14:02:44+00:00",
    "finished_at": "2026-07-30T14:04:02+00:00",
    "created_at": "2026-07-30T14:02:11+00:00",
    "updated_at": "2026-07-30T14:04:02+00:00",
    "task_data": { "strategy": "highest_price", "ignore_graffitis": true },
    "logs": [
        { "ts": 1785420164, "level": "info", "msg": "Signing in to Steam" },
        { "ts": 1785420191, "level": "info", "msg": "Launching CS2" },
        { "ts": 1785420242, "level": "error", "msg": "GC session timed out after 45s" }
    ],
    "worker_public_ip": "185.22.10.4",
    "proxy": { "id": "019fb42f-1188-70aa-8f2c-3b7de51a9c60", "host": "res-eu-01.example.net", "port": 8080 }
}
```

Three fields appear only here: `task_data` (the payload the worker was handed, already resolved per account), `logs` (the last 200 lines the worker sent back), and `proxy` (which proxy it went through).

A task id that belongs to someone else returns `404`, the same as an id that does not exist. We do not distinguish, because confirming an id exists elsewhere leaks that it exists.

## A batch's children

```endpoint
method: GET
path: /api/v1/tasks/{id}/children
description: A batch parent's per-account child tasks.
auth: bearer
```

Requires `tasks.read`.

| Parameter | Type | Description |
| --- | --- | --- |
| `status` | string | One status value |
| `search` | string | Username, persona name or Steam64 id |
| `sort` | string | `created_at` (default), `started_at`, `finished_at` |
| `direction` | string | `asc`, `desc` (default) |
| `page`, `per_page` | integer | Default 50 per page, clamped to 200 |

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/tasks/019fb431-2c88-71ea-b0a3-8ce2f5d19b07/children?status=failed" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

Rows are the same shape the list endpoint returns. `GET /api/v1/tasks?parent={id}` gives you the identical set with the full filter vocabulary, if you need `type` or a date range as well.

Call this with `status=failed` to see which accounts failed and why. To act on them, use `retry-failed` on the parent rather than looping the children.

## Retry a failed task

```endpoint
method: POST
path: /api/v1/tasks/{id}/retry
description: Re-run one failed account-bound task.
auth: bearer
```

Requires `tasks.write`. **Requires an `Idempotency-Key` header.**

No body. The task's own resolved `task_data` becomes the config, so a random persona name re-rolls and an already-planned trade-up re-dispatches verbatim.

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks/019fb440-7a10-7c33-bd51-2f0c9e4471d2/retry" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

Answers `201` with the new task, exactly as `POST /api/v1/tasks` does for a single account.

The task must be `status:failed`, bound to a Steam account, and not a batch parent. Anything else gets `422 task_not_retryable`. A failed child of a finished batch qualifies; the parent itself does not.

> [!NOTE]
> Retrying is creating, so it answers to the plan you hold now, not the one you held when the task first ran. A type that has since left your plan returns `403 plan_limit_reached`, and platform maintenance refuses retries with `503 maintenance_mode` like any other creation.

## Retry a batch's failures

```endpoint
method: POST
path: /api/v1/tasks/{id}/retry-failed
description: Re-run every failed child of a finished batch.
auth: bearer
```

Requires `tasks.write`. **Requires an `Idempotency-Key` header.**

No body. The failed set travels as a filter, never as ids, so a 100,000-child batch does not have to materialize its failures before it can retry them.

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks/019fb431-2c88-71ea-b0a3-8ce2f5d19b07/retry-failed" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

```json
{
    "task_id": "019fb44a-51e7-7ab8-9f30-6ca7ce31f008",
    "accounts_affected": 12,
    "accounts_skipped": 0
}
```

Always `202` with a **new** batch parent, because you cannot know how many children failed. The original parent is left alone.

The parent must be finished, have at least one failed child, and still carry the selection it was created with. Otherwise you get `422 task_not_retryable`.

## Cancel one task

```endpoint
method: POST
path: /api/v1/tasks/{id}/cancel
description: Ask a task to stop.
auth: bearer
```

Requires `tasks.write`. No body, and no `Idempotency-Key`: cancelling twice changes nothing.

Cancelling is a request, not a transition, which is why this answers `200` with the record rather than `204`:

- a `status:pending` or `status:queued` task flips to `status:cancelled` straight away;
- a `status:running` task only gets `cancel_requested_at` stamped, and the worker decides the final status. It may still finish and report `status:completed`.

Read `status` and `cancel_requested_at` off the response to tell which of the two happened. While the request is outstanding, `is_cancelling` is `true`.

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/tasks/019fb431-2c88-71ea-b0a3-8ce2f5d19b07/cancel" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

Cancelling a batch parent cancels its children. Only top-level unfinished tasks with no cancel already pending can be cancelled; anything else gets `422 task_not_cancellable`.

## Delete one task

```endpoint
method: DELETE
path: /api/v1/tasks/{id}
description: Delete a task. Returns 204.
auth: bearer
```

Requires `tasks.write`.

An unfinished task is flagged cancelled before the row goes, so the fleet drops the work. A batch parent takes its children with it.

A child of an **unfinished** batch cannot be deleted on its own: its result feeds the parent's counters, and removing the row would strand the parent running forever. That is `422 task_not_deletable`. Cancel or delete the parent instead.

## Errors

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

| Status | Code | Meaning |
| --- | --- | --- |
| `422` | `no_eligible_accounts` | Nobody in the selection could take this task type. Adds `accounts_skipped` and, where the pipeline attributes its skips, `accounts_skipped_reasons` |
| `422` | `nothing_to_plan` | The planner matched no items for `distribute_items`, `sell_items` or a storage type |
| `422` | `proxies_required` | You own no proxies and hold more accounts than the shared pool covers |
| `422` | `task_not_retryable` | Not a failed account-bound task, or not a finished batch with failed children and a stored selection |
| `422` | `task_not_cancellable` | Already finished, already cancelling, or a child covered by cancelling its parent |
| `422` | `task_not_deletable` | A child of a batch that has not finished yet |
| `422` | `bulk_limit_exceeded` | More than 1,000 ids in `account_ids`, `exclude_account_ids` or `task_ids`. Adds `max` |
| `403` | `plan_limit_reached` | Your plan's `allowed_task_types` or `allowed_marketplaces` does not cover this. Adds `plan` with the upgrade path |
| `503` | `maintenance_mode` | Platform maintenance pauses creation (`POST /api/v1/tasks`, `retry`, `retry-failed`). Reads, cancels and deletes keep working. Adds `reason` |
| `400` | `idempotency_key_required` | `POST /api/v1/tasks`, `retry` and `retry-failed` need the header |

Everything else (`validation_failed`, `not_found`, `missing_scope`, `rate_limit_exceeded`, the idempotency conflicts) behaves as described on [Errors](/docs/api/en/concepts/errors).

Validation failures on a config report per field under `config.*`, so a bad sell mode reads as `config.mode` rather than a bespoke envelope:

```json
{
    "message": "The given data was invalid.",
    "code": "validation_failed",
    "errors": {
        "config.mode": ["The buy_order pricing mode is only available on the steam marketplace."],
        "config.confirm_sell_all": ["This config has no item condition and no cap, so it would list every marketable item on every selected account. Set confirm_sell_all to true to accept that."]
    }
}
```
