# Connected services

> Connect marketplaces, manage the Steam Web API key pool, and read the state of every third-party link from code.


Everything the **Marketplaces** tab and the **Web API keys** page do, over HTTP: connect a selling venue for an account, arm or disarm selling, tune how often SteamLabs polls it, and keep the Steam Web API key pool topped up.

One pair of scopes covers both: `integrations.read` for the state, `integrations.write` for everything that changes it.

## Credentials only go in

No response on this page ever contains a stored credential. Not the marketplace API key, not a Steam Web API key, not on the write that just stored it.

That is deliberate, and it is a stricter rule than the panel's. In a browser you are one person at one keyboard; an API key gets pasted into scripts, shared with a contractor and committed by accident, and a Steam Web API key exfiltrated through a read-only token is a live Valve credential bound to a real Steam account. What you get instead is **state**: whether a key is stored, whether the venue has accepted it, whether selling is armed, and what the venue last reported. Steam Web API keys come back as `masked_key`, showing the last four characters so you can tell two rows apart.

Send a new key to replace one. There is no way to read one back.

## Marketplace connections

A connection links one of your Steam accounts to one selling venue. Two venues are connectable: `csfloat` and `marketcsgo`. Steam is not, and never will be: its "connection" is the Steam account's own session, so there is nothing to paste, verify or revoke.

Connections are state only. Listings, sales, buy orders and deliveries live under `/api/v1/market/{venue}` behind the `market.*` scopes, so a reporting integration never needs a token that can disconnect a marketplace.

### List connections

```endpoint
method: GET
path: /api/v1/integrations/marketplaces
description: Every marketplace connection you have, connected or not.
auth: bearer
```

| Filter | Values |
| --- | --- |
| `marketplace` | `csfloat`, `marketcsgo` |
| `steam_account_id` | One of your Steam account IDs |
| `connected` | `true` keeps rows that hold a key |
| `enabled` | `true` keeps rows where selling is armed |
| `sort` | `venue` (default), `newest`, `oldest`, `last_synced` |

```bash
curl "https://dashboard.steamlabs.dev/api/v1/integrations/marketplaces?marketplace=csfloat" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY"
```

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "marketplace": "csfloat",
            "connected": true,
            "verified": true,
            "enabled": true,
            "auto_deliver_external": true,
            "auto_credit_settlements": false,
            "account_label": "farm_014",
            "external_user_id": "76561198000000000",
            "balance_cents": 48210,
            "pending_balance_cents": 1200,
            "currency": "USD",
            "fee_bps": 200,
            "kyc_status": "verified",
            "steam_account": { "id": "019fb42e-9a7e-728d-b960-8b4c2162898c", "username": "farm_014" },
            "proxy": { "id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44", "protocol": "http", "host": "res-eu-01.example.net", "port": 8080, "type": "static" },
            "poller_settings": { "hot_seconds": 30, "warm_seconds": 120, "cold_seconds": 900, "hot_cooldown_seconds": 600 },
            "linked_at": "2026-07-01T09:15:00+00:00",
            "last_synced_at": "2026-07-30T14:02:11+00:00",
            "poll_health": {
                "healthy": true,
                "consecutive_failures": 0,
                "last_error_kind": null,
                "last_error": null,
                "last_error_at": null
            },
            "created_at": "2026-07-01T09:12:00+00:00",
            "updated_at": "2026-07-30T14:02:11+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

Three fields say three different things and they fail separately:

- `connected`: a key is stored.
- `verified`: the venue has accepted that key at least once. Selling cannot be armed before this is `true`.
- `enabled`: selling is armed.

Rows with `connected: false` are kept rather than deleted. Disconnecting drops the key and leaves the row, carrying the last balance and fee the venue reported, so reconnecting later picks up where you left off.

`account_label` is the venue's own name for the account. It is never a credential.

### Knowing a connection is broken

`poll_health` is the field to alert on. `last_synced_at` cannot tell you what you want to know: it stops advancing when a connection breaks, which looks exactly like an account nobody is trading on.

| Field | Meaning |
| --- | --- |
| `healthy` | `false` once enough polls have failed in a row to count as an outage rather than a blip |
| `consecutive_failures` | Failed polls since the last good one, reset to `0` by the next successful sync |
| `last_error_kind` | `proxy`, `auth`, `rate_limited`, `network`, `http` or `unknown` |
| `last_error` | The raw failure text, truncated. For humans, not for branching |
| `last_error_at` | When the most recent failure happened |

Branch on `last_error_kind`, not on `last_error`. Two of them mean something is wrong that only you can fix:

- `proxy`: the dedicated proxy pinned to this connection refused the tunnel. Out of bandwidth and expired plans both land here. The venue never saw the request.
- `auth`: the venue rejected the API key. It needs replacing.

The rest (`rate_limited`, `network`, `http`) clear on their own and are not worth waking anyone for.

> [!WARNING]
> A broken connection does not take your listings down. They stay live on the venue and can still sell, but SteamLabs cannot see those sales while polling is down, and a sale it cannot see is one it cannot deliver before the deadline. Treat `healthy: false` on an account with open listings as urgent.

### One connection

```endpoint
method: GET
path: /api/v1/integrations/marketplaces/{id}
description: One connection, plus its readiness checklist and poller bounds.
auth: bearer
```

Same shape as a list row, with two extras. `readiness` is the same checklist the panel draws beside the connection card, every condition evaluated independently so you can tell a user exactly what is still missing:

```json
{
    "readiness": {
        "key_present": true,
        "enabled": true,
        "identity_secret": true,
        "dedicated_proxy": true,
        "proxy": true
    },
    "poller_limits": {
        "minimums": { "hot_seconds": 30, "warm_seconds": 120, "cold_seconds": 900, "hot_cooldown_seconds": 600 },
        "maximums": {}
    }
}
```

`poller_limits` are the bounds your cadences are clamped to, so you can build a form without discovering them by being clamped.

### Connect a venue

```endpoint
method: POST
path: /api/v1/integrations/marketplaces/{venue}/connect
description: Store an API key for one Steam account and queue its verification.
auth: bearer
```

The venue is in the path, matching `/api/v1/market/{venue}`, so there is no default venue to fall into.

```bash
curl -X POST "https://dashboard.steamlabs.dev/api/v1/integrations/marketplaces/csfloat/connect" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "steam_account_id": "019fb42e-9a7e-728d-b960-8b4c2162898c",
    "api_key": "your-csfloat-api-key",
    "proxy_id": "019fb430-1c22-73a4-9f0e-2b7c5d1e8a44"
  }'
```

`201` when this armed a connection that was not armed before, `200` when it replaced a working key. Both return the connection.

> [!WARNING]
> Connecting always leaves `enabled: false`. A pasted key has never been checked, so selling stays off until the queued verification pass succeeds. Poll the connection (or `/api/v1/tasks`) and enable it once `verified` is `true`.

A dedicated **static** proxy is required, and it is the one thing you cannot skip. Both venues expect every request for an account to come from one address, and a rotating proxy changes IP per request, which is what gets an account rate limited. Pass `proxy_id` on the first connect; a reconnect keeps the pinned one if you omit it. See [Proxies](/docs/api/en/endpoints/proxies) for creating one.

This endpoint requires an `Idempotency-Key`: it stores a credential and queues work, so a retry after a timeout must replay rather than repeat.

| Refusal | Meaning |
| --- | --- |
| `422 proxy_required` | No dedicated proxy pinned and none supplied |
| `422 proxy_static_only` | The proxy is not a static one |
| `422 key_required` | No key in the body |
| `403 plan_limit_reached` | Your plan does not sell on this venue; the response carries the upgrade path |
| `404 unknown_venue` | No such marketplace |
| `404 venue_unsupported` | A real venue with nothing to connect (Steam) |

### Change settings

```endpoint
method: PATCH
path: /api/v1/integrations/marketplaces/{id}
description: Selling switch, automation toggles, dedicated proxy and poller cadences.
auth: bearer
```

Everything the panel spreads over three toggles, a proxy picker and an advanced settings form, in one call. Every field is optional and only the ones you send are touched, so flipping one toggle can never clear settings you did not mention.

| Field | Meaning |
| --- | --- |
| `enabled` | Arm or disarm selling on this venue |
| `auto_deliver_external` | Accept and deliver sales from listings made outside SteamLabs |
| `auto_credit_settlements` | Credit a sale once it clears its trade-protection hold (CSFloat) |
| `proxy_id` | Repin the dedicated static proxy |
| `poller_settings` | How often SteamLabs polls this account, in seconds |

```bash
curl -X PATCH "https://dashboard.steamlabs.dev/api/v1/integrations/marketplaces/019fb42e-9a61-70d2-818a-f6a56593f3a5" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true, "poller_settings": { "hot_seconds": 45 } }'
```

Arming selling on a key that has never verified answers `422 verify_before_enabling`. Switching selling **off** is never refused, whatever state the row is in.

Cadences are merged over what you already have and clamped to the venue's bounds rather than rejected: you may only slow polling down, and market.csgo's `selling_seconds` also has a ceiling, because that tick doubles as the presence ping and the venue's window is 180 seconds. The response returns the effective settings, so you can always see what you actually got.

### Sync a connection

```endpoint
method: POST
path: /api/v1/integrations/marketplaces/{id}/sync
description: Re-check the key and refresh the balance, label, fee and KYC snapshot.
auth: bearer
```

The panel's **Sync now**. Answers `202` with the queued task:

```json
{ "task_id": "019fb431-2c88-71ea-b0a3-8ce2f5d19b07", "accounts_affected": 1 }
```

No `Idempotency-Key` needed: this creates no listing, moves no item and spends nothing, and running it twice gives you the same snapshot. A connection with no key answers `422 not_connected`.

### Disconnect

```endpoint
method: DELETE
path: /api/v1/integrations/marketplaces/{id}
description: Drop the stored key and stop selling.
auth: bearer
```

Answers `204`. Listings, sales and history are kept: they describe things that really happened on a real marketplace. The connection row survives too, so reconnecting later is one call.

## Steam Web API keys

The pool behind every Steam Web API read SteamLabs makes for you. Keys arrive two ways: an account's details refresh mints one (`source: auto`), or you paste one in (`source: manual`).

### List keys

```endpoint
method: GET
path: /api/v1/integrations/steam-web-api-keys
description: Your key pool, paginated and filterable.
auth: bearer
```

| Filter | Values |
| --- | --- |
| `status` | `active`, `cooldown`, `disabled` |
| `source` | `auto`, `manual` |
| `steam_account_id` | The account whose session minted the key |
| `active` | `true` or `false`, the raw `is_active` flag |
| `sort` | `newest` (default), `oldest`, `last_used`, `requests_today` |

```json
{
    "data": [
        {
            "id": "019fb42e-9a61-70d2-818a-f6a56593f3a5",
            "masked_key": "********6789",
            "source": "auto",
            "is_active": true,
            "status": "active",
            "steam_account": { "id": "019fb42e-9a7e-728d-b960-8b4c2162898c", "username": "farm_014" },
            "steam64_id": "76561198000000000",
            "domain": null,
            "requests_today": 412,
            "usage_date": "2026-07-30",
            "cooldown_until": null,
            "last_used_at": "2026-07-30T15:41:52+00:00",
            "last_error": null,
            "disabled_reason": null,
            "created_at": "2026-07-01T09:15:00+00:00",
            "updated_at": "2026-07-30T15:41:52+00:00"
        }
    ],
    "meta": { "page": 1, "per_page": 50, "total": 1, "last_page": 1 }
}
```

`status` is derived, exactly as the panel's badge is: `disabled` (switched off, or revoked) beats `cooldown` (benched until `cooldown_until` after a rate limit) beats `active`. Filtering on it filters in SQL, so the totals mean what they say.

### Add a key

```endpoint
method: POST
path: /api/v1/integrations/steam-web-api-keys
description: Paste a Steam Web API key into the pool.
auth: bearer
```

```bash
curl -X POST "https://dashboard.steamlabs.dev/api/v1/integrations/steam-web-api-keys" \
  -H "Authorization: Bearer $STEAMLABS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "ABCDEF0123456789ABCDEF0123456789" }'
```

Answers `201` with the key row, masked. `source` is stamped `manual`: it records where a key came from, so you cannot claim one was minted by a session that never ran. Keys are unique platform-wide, because a key is a Valve credential bound to one Steam account and two owners sharing one would share a quota.

### Enable or disable a key

```endpoint
method: PATCH
path: /api/v1/integrations/steam-web-api-keys/{id}
description: Switch one key on or off.
auth: bearer
```

`is_active` is the only writable field. Everything else on the row is the pool's own notes on a key's health.

Re-enabling gives the key a clean slate: the cooldown, the last error and the disable reason are cleared. Without that, a key you switched on would still be skipped, which reads as the toggle not working.

### Delete keys

```endpoint
method: DELETE
path: /api/v1/integrations/steam-web-api-keys/{id}
description: Remove one key from the pool.
auth: bearer
```

```endpoint
method: POST
path: /api/v1/integrations/steam-web-api-keys/delete
description: Remove several keys at once.
auth: bearer
```

The single delete answers `204`. The bulk form takes `key_ids` and answers with what actually went:

```json
{ "keys_deleted": 12 }
```

IDs that are not yours are simply not matched, so a partly-foreign list is not an error. Explicit IDs only, capped at 1,000; over that you get `422 bulk_limit_exceeded` with the ceiling in `max`. There is no filter mode here, because a key pool is a handful of rows you can list in one page, and a mistyped filter should not be able to empty it.

### Pool settings

```endpoint
method: GET
path: /api/v1/integrations/steam-web-api-keys/settings
description: The pool target, auto-create switch and current counts.
auth: bearer
```

```endpoint
method: PUT
path: /api/v1/integrations/steam-web-api-keys/settings
description: Set the pool target and auto-create switch.
auth: bearer
```

```json
{
    "steam_web_api_key_pool_target": 10,
    "steam_web_api_key_auto_create": true,
    "keys_total": 7,
    "keys_active": 6
}
```

These two settings live on your account rather than on any key, which is why they are a sub-resource of the collection. `steam_web_api_key_pool_target` is how many keys the pool aims to hold (0 stops topping it up; the keys already in it keep working), and `steam_web_api_key_auto_create` decides whether details refreshes may mint new ones to get there. The counts are returned with them because a target of ten says nothing without knowing the pool holds three.

A `PUT` rather than a `PATCH`: there are two fields, they are always shown together, and both are required so a read-then-write cannot half-apply.

## Not on the API

**Linking a FarmLabs account, and its sync runs.** The link is an OAuth-style browser handshake whose entire security model is the interactive consent step, and a sync run is a staged, reviewed import with an admin-shaped surface behind it. Neither survives being reduced to a bearer-token call, so both stay in the panel.
