# Bulk operations

> Act on thousands of accounts in one call, and make retries safe while you do it.


Most write endpoints act on many accounts at once. There are two ways to say which, and one header that makes retrying safe.

## Two ways to select

### Explicit IDs

Send the IDs you want. Capped at **1,000 per request**, handled inline, and you get the result straight back.

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

Over the cap you get `422 bulk_limit_exceeded`, with the cap in `max`. Split the call, or use filters instead.

### Filters

Describe the accounts instead of listing them. The server resolves the selection, so a call that touches 80,000 accounts is the same size as one that touches two.

```json
{
    "type": "refresh_details",
    "filters": {
        "tags": ["019fb432-6d10-70bb-a1c9-4e2f8b7d5a01"],
        "guard": "mobile",
        "details": "never"
    }
}
```

Each endpoint documents the exact filter keys it accepts, and they are not all the same vocabulary. The account bulk writes under `/api/v1/accounts` take the accounts table's keys, the ones `GET /api/v1/accounts` accepts. `POST /api/v1/tasks` and `POST /api/v1/tasks/preview` take the account selector's shorter set, listed on [Tasks](/docs/api/en/endpoints/tasks). Read the page for the endpoint you are calling.

> [!WARNING]
> The account, proxy and hour-boosting bulk writes refuse a `filters` object carrying a key they do not recognise, with `422 filters_not_understood` naming the keys they accept. The task endpoints are the exception: a task selection drops keys it does not recognise, and a mixed object can quietly widen into more accounts than you described, so check the count with `preview` before you commit anything.

> [!IMPORTANT]
> A filter selection that queues work is always processed asynchronously. It returns `202 Accepted` immediately, with the ID of the parent task and the number of accounts matched:
>
> ```json
> {
>     "task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07",
>     "accounts_affected": 4182
> }
> ```
>
> Poll `GET /api/v1/tasks/{id}` to follow it.

Two families of bulk write never queue anything and so never answer `202`. The [proxy](/docs/api/en/endpoints/proxies) and [profile](/docs/api/en/endpoints/profiles) bulk endpoints change our own rows, which one statement covers however large the selection is: both shapes run inline and answer `200` with a count. The same is true of the account edits that touch no worker (tags, proxy pinning, stored sessions), except that those do queue a filter selection, so read each page rather than assuming.

## Check before you commit

`POST /api/v1/tasks/preview` runs a selection without creating anything and tells you how many accounts it matched. It writes nothing, but it still needs `tasks.write`, because the same key that previews is the one that commits.

```endpoint
method: POST
path: /api/v1/tasks/preview
description: Resolve a selection and return the count, without queueing any work.
auth: bearer
```

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/tasks/preview \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": {"tags": ["019fb432-6d10-70bb-a1c9-4e2f8b7d5a01"], "session": "offline"}}'
```

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

Worth doing on anything destructive or expensive. A filter that quietly matches every account you own is easier to spot before the tasks exist than after.

## Idempotency

If a write times out, you cannot tell whether it arrived. Retrying is the only sensible move, and on anything that costs money or moves items, a duplicate is expensive.

Send an `Idempotency-Key`: a unique string you invent for each logical attempt. We remember the response for 24 hours, and a repeat of the same key replays it instead of doing the work again.

```bash
curl -X POST https://dashboard.steamlabs.dev/api/v1/tasks \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3a9c2e-1b44-4d8a-9f01-cc2e5a9b1234" \
  -d '{"type": "sell_items", "account_ids": ["019fb42e-9a61-70d2-818a-f6a56593f3a5"]}'
```

Retry with the same key and you get the original response back, plus a header:

```
Idempotent-Replay: true
```

### Rules

- Generate a **new** key for each logical operation, and reuse it only when retrying that operation. A UUID per attempt is the easy version.
- Keys are scoped to the API key that used them and to the endpoint they were sent to. Two integrations cannot collide, and if you do reuse one value on a different endpoint that call runs normally instead of replaying the first one's response.
- The body is not part of the scope. Sending different data under the same key on the same endpoint replays the original response and changes nothing, so a key really does mean one attempt at one operation.
- Only successful responses are remembered. A `500` you retry will genuinely re-run, which is what you want.
- Keys expire after 24 hours.
- Maximum 255 characters. Surrounding whitespace is trimmed.

### Where it is required

Thirty-seven endpoints require the header. They are the ones that spend money, move items, hold a Steam login, or store a credential.

| Group | Endpoints that require it |
| --- | --- |
| [Steam accounts](/docs/api/en/endpoints/accounts) | `refresh-details`, `refresh-trade-url`, `login`, `sign-out-everywhere`, `check-bans`, `set-profile`, `change-profile-privacy` |
| [Tasks](/docs/api/en/endpoints/tasks) | `POST /v1/tasks`, `POST /v1/tasks/{id}/retry`, `POST /v1/tasks/{id}/retry-failed` |
| [Inventory](/docs/api/en/endpoints/inventory) | All seven writes: `sell`, `send`, `store`, `use`, `trade-up`, and a storage unit's `withdraw` and `rename` |
| [Market](/docs/api/en/endpoints/market) | `POST /v1/market/{venue}/buy-orders` |
| [Trading](/docs/api/en/endpoints/trading) | An offer's `accept`, `decline` and `cancel`, plus an account's `trade-offers/respond` and `confirmations/respond` |
| [Trade-ups](/docs/api/en/endpoints/trade-ups) | `clear-space`, `craft`, `queue-best-value` |
| [Hour boosting](/docs/api/en/endpoints/hour-boosting) | `quick-boost`, `POST /v1/boost/plans/start`, a plan's `start` and `toggle`, an assignment's `start` and `retry` |
| [Profiles](/docs/api/en/endpoints/profiles) | `POST /v1/profiles/generate/ai` |
| [Proxies](/docs/api/en/endpoints/proxies) | `POST /v1/proxies/test`, `POST /v1/proxies/{id}/test` |
| [Connected services](/docs/api/en/endpoints/integrations) | `POST /v1/integrations/marketplaces/{venue}/connect` |
| [Settings](/docs/api/en/endpoints/settings) | `POST /v1/settings/notification-integrations/{id}/test` |

Calling one of those without the header returns `400 idempotency_key_required`.

Six more accept the header and honour it, but do not demand it: `POST /v1/proxies/import`, `POST /v1/profiles`, `POST /v1/profile-groups`, `POST /v1/profiles/bulk/delete`, `POST /v1/profiles/bulk/reset-use-counts` and `POST /v1/profiles/generate/random`. Everywhere else the header is ignored.

If the same key arrives while the first request is still running, the second gets `409 idempotency_key_in_flight`. Wait a moment and retry: that is the mechanism stopping two simultaneous retries from both executing.

## Fan-out and fair use

A filter selection can queue work for every account you own, and nothing stops you doing that repeatedly inside your rate limit. Please do not. The worker fleet is shared, and a script that re-queues a full sweep every minute degrades the platform for everyone including you.

Sensible shapes:

- Queue a sweep, then poll the parent task until it completes before queueing the next.
- Filter to the accounts that actually need the work (`details_never_refreshed`, `login_state`) rather than sweeping everything on a timer.
- Use `preview` in development so you find out a filter matches 80,000 accounts before it queues 80,000 tasks.
