# Steam accounts

> List, filter, create and act on your Steam accounts from code, one at a time or a hundred thousand at once.


Everything the **Accounts** page does, over HTTP: add accounts, filter the fleet the way the table filters it, and run any of its bulk actions against a selection.

Two scopes cover the domain. `accounts.read` for the listings and the read-only sub-resources, `accounts.write` for everything that changes an account or queues work for one. Tags share them, because a tag is just a label on an account.

## Credentials are write-only

No response ever contains a password, shared secret, identity secret or session token. Not masked, not truncated, not the last four characters.

You already have the credential (you sent it), so returning it buys you nothing, while a leaked read-only key would otherwise hand over a working set of Steam logins. What you get instead is whether one exists:

```json
{
    "steam_guard": { "enabled": true, "two_factor": true, "has_shared_secret": true, "has_identity_secret": false },
    "session": { "online": true, "has_stored_session": true, "login_state": null }
}
```

Send a new value to change a secret, or `null` to clear it. Omitting a field leaves it alone.

## List accounts

```endpoint
method: GET
path: /api/v1/accounts
description: Your Steam accounts, paginated and filterable.
auth: bearer
```

```bash tab=curl
curl "https://dashboard.steamlabs.dev/api/v1/accounts?ban_status[]=vac&per_page=1" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "username": "farm_017",
            "persona_name": "Ada",
            "steam64_id": "76561198000000017",
            "steam_partner_id": "39734289",
            "avatar_url": "https://avatars.steamstatic.com/…_full.jpg",
            "summary": null,
            "country": "NL",
            "store_country": "NL",
            "email": "ada@example.net",
            "email_verified": true,
            "profile_configured": true,
            "profile_needs_setup": false,
            "trade_url": "https://steamcommunity.com/tradeoffer/new/?partner=39734289&token=AbCd1234",
            "steam_guard": { "enabled": true, "two_factor": true, "has_shared_secret": true, "has_identity_secret": true },
            "session": {
                "online": true,
                "has_stored_session": true,
                "stored_session_expires_at": "2026-08-27T09:15:00+00:00",
                "login_state": null,
                "login_state_at": null
            },
            "wallet": { "balance_cents": 1234, "currency": "EUR" },
            "market": {
                "eligibility": "eligible",
                "allowed": true,
                "unlocks_at": null,
                "restriction_reason": null,
                "checked_at": "2026-07-30T14:02:11+00:00"
            },
            "bans": {
                "banned": false,
                "vac": false,
                "community": false,
                "game": false,
                "economy": false,
                "vac_count": 0,
                "game_count": 0,
                "days_since_last_ban": null,
                "checked_at": "2026-07-30T14:02:11+00:00"
            },
            "inventory": { "item_count": 412, "synced_item_count": 412, "value_cents": 18355, "refreshed_at": "2026-07-30T13:00:00+00:00" },
            "cs2": {
                "level": 21,
                "xp": 4820,
                "armory_balance": 300,
                "armory_pass_count": 1,
                "armory_unactivated_pass_count": 0,
                "armory_progress_percentage": 42.5,
                "weekly_drops": null,
                "service_medal_claimable": false,
                "service_medal_claimed": true,
                "refreshed_at": "2026-07-30T12:00:00+00:00"
            },
            "tf2": { "premium": false, "additional_backpack_slots": null, "trade_ban_expires_at": null, "refreshed_at": null },
            "boost": { "minutes_total": 12400, "state": "boosting", "plan_id": "019fb431-…" },
            "proxies": { "pinned_count": 1, "group_count": 0 },
            "tags": [ { "id": "019fb432-…", "name": "prime", "color": "#3b82f6" } ],
            "billing_address": { "first_name": "Ada", "last_name": "Lovelace", "address": "…", "city": "Amsterdam", "country": "NL", "postal_code": "1011AB" },
            "billing_address_is_custom": false,
            "privacy_settings": { "profile": 3, "inventory": 3 },
            "details_refreshed_at": "2026-07-30T14:02:11+00:00",
            "ownership_refreshed_at": "2026-07-29T08:00:00+00:00",
            "created_at": "2026-07-01T09:15:00+00:00",
            "updated_at": "2026-07-30T14:02:11+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 1, "total": 1, "last_page": 1 }
}
```

`session.online` is `null` rather than `false` when no worker is reporting. That means "we cannot see", not "offline", and it is worth branching on: absence from the session registry only means offline while the registry is live.

### Filters

These are the accounts table's own filters, by the same names.

| Parameter | Values |
| --- | --- |
| `search` | Username, persona name, or Steam64 id |
| `ban_status` | `community`, `vac`, `game`, `trade`. Repeat it (`ban_status[]=`) to match any of several |
| `login_state` | `disabled`, `locked`, `suspended`, `banned`, `deleted`, `not_found`. Repeatable |
| `marketplace_access` | `steam`, `csfloat`, `marketcsgo`: accounts that can sell there right now. Repeatable |
| `market_unlocking_soon` | `true` for restrictions that lift within a week |
| `steam_market_ineligible` | `true` for accounts that cannot sell on the Community Market |
| `profile_not_set_up` | `true` for accounts confirmed never to have saved a Community profile |
| `details_never_refreshed` | `true` for accounts no refresh has ever run against |
| `boost` | `boosting`, `on_plan`, `not_on_plan` |
| `session` | `online`, `offline` |
| `tags` | Tag ids. Repeatable, matching any of them |
| `sort` | `created_at` (default), `username`, `details_refreshed_at`, `inventory_refreshed_at`, `wallet_balance`, `cs_level`, `item_count`, `items_count`, `login_state` |
| `direction` | `asc`, `desc` |

`details_never_refreshed=true` is the one to run after an import. Until a refresh lands there is no Steam64 id, no wallet, no profile state and no privacy settings, so most of the rest of the API has nothing to work with.

## Read, create, update, delete

```endpoint
method: GET
path: /api/v1/accounts/{id}
description: One account.
auth: bearer
```

```endpoint
method: POST
path: /api/v1/accounts
description: Add one account.
auth: bearer
```

```endpoint
method: PATCH
path: /api/v1/accounts/{id}
description: Edit credentials, trade URL, or billing address.
auth: bearer
```

```endpoint
method: DELETE
path: /api/v1/accounts/{id}
description: Delete one account.
auth: bearer
```

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/accounts" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username":"farm_018","password":"…","shared_secret":"…","identity_secret":"…"}'
```

Usernames are unique per account holder, not globally: two customers can farm the same public alias.

`PATCH` accepts `trade_url` as a whole Steam trade URL and stores only its token. A URL carrying a different `partner=` is refused, because it belongs to another account and would send this one's items to a stranger.

`billing_address` follows the panel: send the fields to set it (and mark it custom, so refreshes stop overwriting it), or `null` to clear it back to auto-generated.

Creating goes through your plan's account cap. Over it, you get `403` with `plan_limit_reached` and the upgrade path:

```json
{
    "message": "Your plan covers 25 Steam accounts.",
    "code": "plan_limit_reached",
    "plan": { "heading": "Account limit reached", "upgrade_url": "https://dashboard.steamlabs.dev/subscription", "max_steam_accounts": 25, "remaining": 0 }
}
```

### Add many at once

```endpoint
method: POST
path: /api/v1/accounts/bulk
description: Add up to 1,000 accounts in one call.
auth: bearer
```

```json
{
    "accounts": [
        { "username": "farm_019", "password": "…" },
        { "username": "farm_020", "password": "…", "shared_secret": "…" }
    ]
}
```

All or nothing. A duplicate anywhere in the payload, or against an account you already have, fails the whole batch before anything is written, so you never have to work out which half landed. The answer is `201` with `created` and the full records.

## Acting on many accounts

Every action below takes a **selection**, in one of two shapes.

```json
{ "account_ids": ["019fb42e-…", "019fb42f-…"] }
```

An explicit list, capped at 1,000 ids. It is applied immediately and the response tells you what happened. Ids you do not own are simply not in the selection; they are never refused individually, since refusing them differently from unknown ids would confirm they exist.

```json
{ "filters": { "details_never_refreshed": true, "tags": ["019fb432-…"] } }
```

The same filter object the listing accepts. It is resolved on the server and the ids are never materialized, so "every account with no proxy" stays a filter definition no matter how many accounts match. Filter selections always run asynchronously and answer `202`.

Send one or the other, never both, and never neither: a request with no selection would otherwise mean "everything I own" by accident, which is the most expensive mistake this API can make. Over the id cap you get `422`:

```json
{ "message": "A bulk request may carry at most 1000 account ids…", "code": "bulk_limit_exceeded", "max": 1000 }
```

> [!IMPORTANT]
> A filter key this endpoint does not recognise is **refused**, not ignored. Send one and you get a `422` naming it:
>
> ```json
> { "message": "The given data was invalid.", "code": "validation_failed", "errors": { "filters": ["Unknown filter key: guard. This endpoint accepts search, ban_status, …"] } }
> ```
>
> This matters because the accounts vocabulary above and the shorter one [task selections](/docs/api/en/endpoints/tasks) use are genuinely different, and only `search`, `session` and `tags` are spelled the same in both. Copying a working `filters` object from `POST /api/v1/tasks` to an endpoint here is the easy mistake, so it is refused rather than silently resolved to a wider selection than you asked for.
>
> The same `422` answers a filters object whose keys are all recognised but all empty (an empty `search`, an empty `tags` list), since that constrains nothing either.

There is no "everything" filter selection here: `{"filters": {}}` counts as a request with no selection and is refused alongside sending nothing at all. To act on every account you own, name a filter that matches them all. Run any selection through `POST /api/v1/tasks/preview` first if you want the count before you commit.

### Queue work on the fleet

| Endpoint | What it queues |
| --- | --- |
| `POST /api/v1/accounts/refresh-details` | A full details refresh (Steam64 id, wallet, profile, privacy, bans) |
| `POST /api/v1/accounts/refresh-trade-url` | Re-scrapes and overwrites the stored trade URL token |
| `POST /api/v1/accounts/login` | A bare login, so a batch starts against warm sessions |
| `POST /api/v1/accounts/sign-out-everywhere` | Deauthorizes every Steam session, ours included |
| `POST /api/v1/accounts/check-bans` | A ban check (skips accounts with no Steam64 id yet) |
| `POST /api/v1/accounts/set-profile` | Sets the Community profile across the selection |
| `POST /api/v1/accounts/change-profile-privacy` | Flips privacy settings across the selection |

All seven require an `Idempotency-Key` header: they cost fleet capacity and several of them change something on Steam, so a retry after a timeout must replay rather than repeat.

All seven refuse with `503 maintenance_mode` during platform maintenance, like every other endpoint that queues new work. See [Errors](/docs/api/en/concepts/errors).

```bash tab=curl
curl -X POST "https://dashboard.steamlabs.dev/api/v1/accounts/refresh-details" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"filters":{"details_never_refreshed":true}}'
```

```json
{ "task_id": "019fb440-…", "accounts_affected": 8412, "accounts_skipped": 0, "queued": true }
```

`task_id` is the batch parent. Poll it through the tasks API to follow the run. `accounts_skipped` counts accounts left out as not eligible, most often because they already had a task of that type pending (which is what stops a repeated call from double-queueing anyone). When anything was skipped, `accounts_skipped_reasons` splits the count by reason, using the same slugs as [task creation](/docs/api/en/endpoints/tasks): `task_in_flight` here, plus `never_refreshed` on `check-bans` for accounts with no Steam64 id yet.

`set-profile` takes `mode` (`manual`, `profile`, `group`) plus the matching fields: `persona_name`, `summary`, `country` for manual, `profile_id` for a library profile, `profile_group_id` and `allow_reuse` for a group. Avatars come from library profiles; there is no upload here.

`change-profile-privacy` takes any of `profile`, `owned_games`, `inventory`, `inventory_gifts`, `playtime`, `friends_list` (`1` private, `2` friends only, `3` public) and `comment_permission` (`0` friends, `1` public, `2` private). Omitted settings are left untouched, so one call can flip a single setting across the whole fleet. Sending none of them is refused.

### Edit many accounts

These change our own records rather than anything on Steam, so no task is created. With `account_ids` they apply immediately and answer `200`; with `filters` they are queued and answer `202`.

| Endpoint | Body | Effect |
| --- | --- | --- |
| `POST /api/v1/accounts/assign-proxies` | `proxy_ids` | Replaces the pinned proxies. An empty list clears them |
| `POST /api/v1/accounts/distribute-proxies` | `proxy_ids` | One proxy per account, round-robin |
| `POST /api/v1/accounts/assign-proxy-groups` | `proxy_group_ids` | Replaces the pinned groups |
| `POST /api/v1/accounts/clear-proxies` | None | Drops both, returning the accounts to your pool |
| `POST /api/v1/accounts/add-tags` | `tag_ids` | Adds tags without disturbing existing ones |
| `POST /api/v1/accounts/remove-tags` | `tag_ids` | Removes only those tags |
| `POST /api/v1/accounts/clear-stored-session` | None | Forgets our saved login locally (nothing is revoked on Steam) |
| `POST /api/v1/accounts/generate-billing-address` | `country_source` | Generates a billing address for market buy orders |
| `DELETE /api/v1/accounts/bulk` | None | Deletes the selection |

```json
{ "accounts_affected": 240, "queued": false }
```

`country_source` picks which signal the country comes from: `auto` (default), `store_country`, `wallet_currency` or `login_country`. Accounts whose chosen source is empty are skipped rather than given a default-country address, because a wrong country fails at Steam's checkout instead of here.

`clear-stored-session` is the local half of the pair. `sign-out-everywhere` is the one that talks to Steam and revokes every device.

## Games, packages and ban history

```endpoint
method: GET
path: /api/v1/accounts/{id}/games
description: The account's Steam library, most played first.
auth: bearer
```

```endpoint
method: GET
path: /api/v1/accounts/{id}/packages
description: The account's Steam licenses and the apps they grant.
auth: bearer
```

```endpoint
method: GET
path: /api/v1/accounts/{id}/bans
description: Every ban we have detected on the account, newest first.
auth: bearer
```

These are separate endpoints rather than fields on the account because each is unbounded: a well-stocked account owns hundreds of games. `games` takes `search`, `sort` (`playtime_forever_minutes`, `playtime_two_weeks_minutes`, `last_played_at`, `name`, `app_id`) and `direction`.

The account's *current* ban state is on the record itself, under `bans`. The sub-resource is the timeline of detections.

## Tags

```endpoint
method: GET
path: /api/v1/account-tags
description: Your tags, with how many accounts carry each.
auth: bearer
```

```endpoint
method: POST
path: /api/v1/account-tags
description: Create a tag.
auth: bearer
```

```endpoint
method: PATCH
path: /api/v1/account-tags/{id}
description: Rename or recolor a tag.
auth: bearer
```

```endpoint
method: DELETE
path: /api/v1/account-tags/{id}
description: Delete a tag. Accounts keep everything except the label.
auth: bearer
```

Names are unique per account holder, ignoring case, so `Prime` and `prime` cannot both exist and become two badges nobody can tell apart. `color` is an optional six-digit hex; leave it out and the tag takes a colour derived from its own name, so the same word always lands on the same badge.

Attaching tags to accounts is not done here. It is a bulk operation on a selection of accounts, so it lives with the other account edits: `add-tags` and `remove-tags` above.
