# Errors

> One error shape, one stable code per failure, and what to do about each.


Every error returns the same JSON shape:

```json
{
    "message": "This API key is missing the required scope: accounts.write.",
    "code": "missing_scope"
}
```

Branch on `code`, never on `message`. The code is a contract and will not change. The message is written for humans and gets reworded whenever we can word it better.

Some errors add fields alongside those two. They are always additive, so a client reading only `code` keeps working.

## Status codes

| Status | When |
| --- | --- |
| `200` | Success |
| `201` | Created |
| `202` | Accepted. Queued work, see [Bulk operations](/docs/api/en/concepts/bulk-operations) |
| `204` | Success, nothing to return |
| `400` | The request itself is malformed |
| `401` | Missing or bad key |
| `403` | Your key or your plan does not allow this |
| `404` | Not found, or not yours |
| `409` | Conflicts with the current state |
| `422` | Validation failed |
| `429` | Rate limited |
| `500` | Our fault |
| `503` | A shared resource was momentarily unavailable. Retry shortly |

> [!NOTE]
> `404` also covers "exists, but belongs to someone else". We do not distinguish, because confirming that an ID exists on another account would leak that it exists at all.

## Validation errors

A `422` adds an `errors` object keyed by field, in Laravel's standard shape:

```json
{
    "message": "The given data was invalid.",
    "code": "validation_failed",
    "errors": {
        "username": ["The username field is required."],
        "proxy_id": ["The selected proxy id is invalid."]
    }
}
```

## Plan limits

When your subscription plan does not cover what you asked for, you get a `403` with the plan that would:

```json
{
    "message": "Your plan does not include the CSFloat marketplace.",
    "code": "plan_limit_reached",
    "plan": {
        "heading": "CSFloat is not in your plan",
        "upgrade_url": "https://dashboard.steamlabs.dev/subscription"
    }
}
```

This is the one error worth handling specially, because it is not transient and retrying never fixes it. Surface `plan.upgrade_url` to whoever is running your integration.

The Steam account cap and the proxy cap use the same code rather than one of their own, and add the numbers so your client can say how far over it is (`max_steam_accounts` on the account cap, `max_proxies` on the proxy cap):

```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
    }
}
```

`GET /api/v1/me` tells you your limits up front, so a well-behaved client rarely sees this at all.

## Maintenance

While SteamLabs is under platform maintenance, endpoints that create new work (tasks, listings, trade sends) refuse with a `503`:

```json
{
    "message": "This action is paused while SteamLabs is under maintenance. Work already running is unaffected; try again once maintenance ends.",
    "code": "maintenance_mode",
    "reason": "Scheduled worker upgrades, back within the hour"
}
```

Unlike `plan_limit_reached`, this is transient: nothing about your request or plan is wrong, and the same call succeeds once maintenance ends. `reason` is the note the team wrote when enabling it, and can be `null`. There is no `Retry-After` header because the end time is not known.

Reads, cancels and deletes keep working throughout, and so do deliveries, settlements and confirmations for sales already made. `GET /api/v1/me` reports maintenance up front in its `maintenance` block, so a client can check before firing.

## Every code

### Authentication

| Code | Status | Meaning |
| --- | --- | --- |
| `missing_api_key` | `401` | No `Authorization` header |
| `invalid_api_key` | `401` | Unknown, expired, or revoked key |
| `account_not_entitled` | `403` | Your account cannot use the API |
| `missing_scope` | `403` | Key lacks the permission. Adds `required_scope` |
| `rate_limit_exceeded` | `429` | Adds `retry_after` in seconds |

### Requests

| Code | Status | Meaning |
| --- | --- | --- |
| `validation_failed` | `422` | Adds `errors` keyed by field |
| `not_found` | `404` | No such record, or not yours |

### Conflicts

There is no generic `conflict` code. A `409` always names what is in the way, so your client can tell "wait for a task to finish" apart from "you already have one of these".

| Code | Status | Meaning |
| --- | --- | --- |
| `account_busy` | `409` | The account already has marketplace or trade-up work queued or running |
| `trade_task_pending` | `409` | A trade task is already queued or running for that account. Adds `pending_task_id` |
| `confirmation_task_pending` | `409` | A mobile confirmation task is already queued or running for that account |
| `move_already_running` | `409` | A move is already running for that storage unit |
| `inputs_unavailable` | `409` | Another contract reserved the items while yours was being staged |
| `generation_in_progress` | `409` | An AI profile generation batch is still running |
| `integration_already_exists` | `409` | You already have a notification destination of that type. Update it instead |
| `integration_disabled` | `409` | The destination is turned off, so it cannot be tested |
| `nothing_to_check` | `409` | Every proxy in the selection was already being checked |

The first six clear on their own: poll the work that holds the resource, then try again. The last three describe state you have to change yourself, so retrying alone never helps. `idempotency_key_in_flight` is the one remaining `409`, and it is in the idempotency table below.

Each endpoint's page lists the conflicts that endpoint can return.

### Plan and limits

| Code | Status | Meaning |
| --- | --- | --- |
| `plan_limit_reached` | `403` | Adds `plan` with the upgrade path. Also covers the Steam account and proxy caps |
| `bulk_limit_exceeded` | `422` | Too many IDs in one call. Adds `max` |

### Availability

| Code | Status | Meaning |
| --- | --- | --- |
| `maintenance_mode` | `503` | Platform maintenance pauses new work. Adds `reason`. Transient, retry after maintenance ends |

### Idempotency

| Code | Status | Meaning |
| --- | --- | --- |
| `idempotency_key_required` | `400` | This endpoint needs an `Idempotency-Key` |
| `idempotency_key_invalid` | `400` | The key is longer than 255 characters |
| `idempotency_key_in_flight` | `409` | The same key is still being processed. Retry shortly |

## Retrying

| Status | Retry? |
| --- | --- |
| `429` | Yes, after `Retry-After` |
| `500`, `502`, `503`, `504` | Yes, with backoff |
| `409 idempotency_key_in_flight` | Yes, after a second or two |
| `400`, `401`, `403`, `404`, `422` | No. Fix the request |

> [!WARNING]
> A timeout is not a failure. Your request may have succeeded with the response lost on the way back. On anything that costs money or moves items, send an `Idempotency-Key` so a retry replays the original result instead of doing the work twice. See [Bulk operations](/docs/api/en/concepts/bulk-operations).
