---
sidebar_position: 2.5
title: API Authentication
description: How requests to the Lium platform API are authenticated — the X-API-Key header, where the CLI and SDK read the key from and in what order, how to verify a key, and what the error responses look like.
tags: [api, agents]
---

> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lium.io/llms.txt
> Use this file to discover all available pages before exploring further.

# API Authentication

Calls to `https://lium.io/api/...` that touch your account are authenticated with an **API key** sent in the `X-API-Key` request header, except the account operations that need a signed-in session ([which ones](./api#authentication)). This page is the reference for that mechanism: the header, where the key comes from, how to check that a key works before you rent anything, and how failures are reported. For creating and rotating keys see [API Keys](/pod-users/api-keys); for a first walkthrough see the [Developer Quickstart](./quickstart).

## The header

```bash
curl https://lium.io/api/pods -H "X-API-Key: $LIUM_API_KEY"
```

- Header name: `X-API-Key` (header names are case-insensitive; the CLI and SDK send `X-API-KEY`).
- Value: the key as the Access page's copy button gives it (the table shows the first 10 and last 5 characters and keeps the full key; copy it again any time, except a key holding `billing`: save that one when you create it). No `Bearer` prefix, no encoding.
- One header per request; there is no session or cookie to keep.
- The key identifies the account, and everything it rents is billed to the account. A key created without scopes holds `read`, `rent` and `manage`: it reaches pods, templates, volumes and SSH keys, but not the routes that move money, which need a key holding the `billing` scope alone (TAO and TMC Pay top-ups still take a default key for now; the Coinbase charge needs a `billing` key and is retired, answering `410`). Scopes are chosen when the key is created — see [API Keys](/pod-users/api-keys#scopes).

The OpenAPI document at [`https://lium.io/api/openapi.json`](https://lium.io/api/openapi.json) declares the session-token scheme the lium.io web app uses, `JwtAccessBearer` (`type: http`, `scheme: bearer`), and the `X-API-Key` scheme. An operation that accepts a key lists both; one that lists only `JwtAccessBearer` needs a signed-in session and answers `401` to a key alone. [API reference → Authentication](./api#authentication) says which operations are which and the scope each key-accepting group needs. If the document you fetched has no `apiKey` scheme in `components.securitySchemes`, the generated security handler cannot send a key, so add the `X-API-Key` header yourself.

## Where the key comes from

The CLI and the Python SDK read the same key from the same places. When both are set, the environment variable wins:

| Order | Source | Set by |
|------:|--------|--------|
| 1 | `LIUM_API_KEY` environment variable | you (`export LIUM_API_KEY=...`) |
| 2 | `~/.lium/config.ini`, `[api]` section, `api_key` | `lium init`, `lium signup`, `lium config set api.api_key` |

Consequences worth knowing:

- The environment variable **overrides** the config file silently. If `lium balance` shows one account and a script shows another, the script most likely has `LIUM_API_KEY` set to a different key. `lium config get api.api_key` prints the first 8 and last 4 characters of the key the CLI resolves (environment first, then the file) — enough to tell two keys apart.
- With no key anywhere, the SDK raises `ValueError("No API key found. Set LIUM_API_KEY or ~/.lium/config.ini")`; the CLI exits 2 with `No API key configured` and tells you to run `lium init`, which opens a browser or, with `lium init --no-browser`, prints an approval URL and session id (see [AI Agents](./agents#authentication)).
- The SDK also accepts a key directly: `Lium(Config(api_key="..."))`.

The base URL can be overridden the same way (`LIUM_BASE_URL`, default `https://lium.io/api`), which matters only for staging environments.

## Verify a key before you rent

Not every endpoint requires authentication. `GET /executors` (the marketplace) and `GET /templates` answer `200` with **any or no** key, so `lium ls` and `lium templates` succeeding tells you nothing about the key. Use an endpoint that is tied to the account:

```bash
# REST
curl -sS https://lium.io/api/users/me -H "X-API-Key: $LIUM_API_KEY"
# → 200 with the account record (id, email, balance, …) or 401 (see below)

# CLI — the same call, reduced to the balance
lium balance --json
# → {"balance_usd": 12.34}     exit 0
# → exit 3 with the error text when the key is rejected
```

```python
from lium.sdk import Lium, LiumAuthError

try:
    balance = Lium().balance()          # GET /users/me
except LiumAuthError as exc:
    raise SystemExit(f"API key rejected: {exc}")
```

Check the balance as well as the key: renting needs a balance above about 15 minutes of the node's hourly price, and a lower balance is refused with `403` even though the key itself is valid.

## Error responses

Authentication failures return the platform's standard JSON error envelope with an HTTP status code that says which kind of failure it is. The body carries an `error` object with a stable code, the message, what to do next and the request id, plus the flat fields older clients read; the same id is in the `X-Request-Id` response header:

```json
{
  "success": false,
  "error": {
    "code": "unauthorized",
    "message": "API key not found",
    "hint": "Send a valid key in X-API-Key (lium.io/api-keys) or sign in again; `lium config get api.api_key` shows the key the CLI uses.",
    "request_id": "2510eff403c14e4e9ff4bf744bb05df6"
  },
  "message": "API key not found",
  "status_code": 401,
  "error_id": null,
  "request_id": "2510eff403c14e4e9ff4bf744bb05df6",
  "timestamp": "2026-09-08T22:04:34.701777",
  "api_context": null
}
```

An agent branches on `error.code`, shows `error.hint`, and quotes `error.request_id` when it asks for help.

| Status | `message` | Meaning | What to do |
|-------:|-----------|---------|------------|
| `401` | `API key missing from headers` | No `X-API-Key` header on an authenticated endpoint | Send the header; check the key was actually exported into the process that made the call |
| `401` | `API key not found` | The header was present but the key is unknown, deleted or mistyped | Compare the key's prefix with **Access → API Keys** on lium.io; create a new key if it was deleted |
| `403` | varies, includes the amount needed | The key is valid but the account may not do this — most often an insufficient balance for the rent (`POST /executors/{id}/rent`) or an unverified account | Top up (`lium topup`) or complete verification; the key does not need to change |
| `429` | (rate limit text) | Too many requests from this key | Back off and retry |

Authentication is checked before the resource is looked up: an unknown key against an unknown pod id returns `401 API key not found`, not `404`, so fix the key before reading anything into a "not found".

The CLI maps these to exit codes so scripts can branch without parsing text: authentication and other API refusals exit `3` (`EXIT_API_ERROR`), permission problems exit `6`, missing configuration exits `2`. The SDK raises `LiumAuthError` for `401`, `LiumPermissionError` for `403`, `LiumNotFoundError` for `404` and `LiumRateLimitError` for `429`; all of them subclass `LiumError`.

Endpoints that do not require a key never return `401`, which is why they make poor health checks for the key (see above).

## Key handling rules of thumb

- Keep the key out of pod filesystems, Docker images and templates; pass it as an environment variable at run time. See [Pod security](/pod-users/security).
- One key per integration, so a leaked key can be revoked on its own. See [API Keys](/pod-users/api-keys#key-hygiene).
- Log the key's **prefix**, never the key, when you record which credential a job used.

## Say which client you are

Every rental records the client that made it (`rental_history.source`), read from the `X-Source` request header: `ui`, `cli`, `sdk` or `mcp`, matched case-insensitively; any other value is ignored.

- The web app sends `ui`.
- The Python SDK sends `sdk` on every request to the platform API (`Lium()`'s default), so a client built on the SDK is recorded as `sdk` without doing anything.
- `lium up` builds `Lium(source="cli")`, so CLI rentals are recorded as `cli`.
- `mcp` is for the MCP server; it counts an agent's rental apart from the SDK the server wraps. The server does not send it yet (asked on lium-mcp#5).

A request that carries an API key and no recognized `X-Source` is recorded as `programmatic`, which is where a client of your own lands today; there is no value for it yet. A request with neither an API key nor a recognized header counts as `ui`.

## Related

- [API Keys](/pod-users/api-keys) — create, rotate and delete keys in the dashboard
- [Developer Quickstart](./quickstart) — first authenticated request
- [API](./api) — every endpoint, payload and response (live OpenAPI 3.1 spec)
- [AI Agents](./agents) — getting a key with no human in the loop (`lium signup`, `lium init --no-browser`)
