> For the complete documentation index, see [llms.txt](https://docs.layeronecloud.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.layeronecloud.com/platform/client-api.md).

# Client API and request metering

A small public HTTP API that lets a customer deploy, inspect, power-cycle and destroy their own VPS instances from their own code, plus private networks and firewall reads.

A small public HTTP API that lets a customer deploy, inspect, power-cycle and destroy their own VPS instances from their own code, plus private networks and firewall reads. Everything lives in `apps/api`. **Nothing about billing, provisioning, locking or ownership is reimplemented there:** ordering goes through `create_order_intake`, deployment through `queue_provisioning_for_paid_order`, power through `request_virtual_machine_action`, deletion through `request_virtual_machine_destroy`, and private networks through `apps.billing.private_networks` — exactly as the portal does.

| Path                               | What                                                                                                                                               |
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/v1/...`                      | Legacy API (`apps/api/urls.py`). Always acts on the first tenant the key can access.                                                               |
| `/api/v2/...`                      | Current API (`apps/api/v2_urls.py`). Same commands; optional `tenant` selects a workspace the key can access, and omitting it is the first tenant. |
| `/client/developer/`               | Key management, under Organization settings → API. Owner-only create.                                                                              |
| `/client/developer/docs/`          | The same reference, inside the portal                                                                                                              |
| `/docs/api/`                       | The public reference                                                                                                                               |
| `/console/billing/usage/api/docs/` | The same reference, beside the fleet meter                                                                                                         |

All are mounted **ahead of the marketing CMS catch-all**. `/api/v1/` is shared with the status and agent endpoints in `apps.core`; the sub-paths do not overlap and a miss in one urlconf falls through to the other. `/developers/api/` redirects to `/docs/api/`.

**The API authenticates from a header and from nothing else, which is what makes `@csrf_exempt` safe on every endpoint.** `apps/api/auth.py` never reads `request.user` and never sets it; the principal travels as `request.api_key` / `request.api_user` and every service call is passed `actor=` explicitly. CSRF exists because a browser attaches **cookies** to a cross-site request on its own, so a request that draws no authority from cookies cannot be forged that way. **Add a session fallback and any web page can make a logged-in customer's browser destroy their servers.** `SessionIsNotAcceptedTests` pins it.

**A key row never holds the key.** A token is `l1_<key_id>_<secret>`: `key_id` is public, stored in clear, unique and indexed, so authentication is one lookup plus one constant-time comparison. The secret is 32 random bytes and exists in plaintext for exactly one HTTP response. The digest is a **bare SHA-256**, deliberately:

* **not a password hasher** — the secret has 256 bits of entropy, so there is no dictionary to run and no work factor worth buying, while a PBKDF2 round would put \~100 ms of CPU on *every* request, a denial-of-service surface an unauthenticated caller can aim at for free;
* **not `salted_hmac`** (the construction the MFA recovery codes use) — that is keyed by `SECRET_KEY`, and rotating it would silently invalidate every customer's integration.

**The secret is base64url, whose alphabet contains `_`.** Parsing with a plain `split("_")` rejected roughly half of all issued keys, at random, which presents as "the API works sometimes". `parse_api_key` splits **at most twice**; the prefix and key id contain no underscore, so two splits are exact.

**Keys are revoked, never deleted:** what a credential did outlives it. Capped at 25 active per user, may carry an expiry, and revocation sits behind the shared confirm dialog since it is instant and breaks whatever is using it.

**Organization keys are minted by the owner and may cover specific tenants.** `ApiKey.organization` plus `tenant_access` (`all` / `selected`) and `ApiKeyTenantGrant` decide which workspaces a bearer may act on. `ApiKey.billing_account` stores the **first** of those (the organization's original/payer tenant when the key can reach it) so v1 and an unspecified v2 `tenant` stay stable. Changing the browser's tenant **never** changes a bearer key's resource scope. Authentication rechecks reachable tenants, so removing an organization member — or narrowing their leftover key's grants — also stops those keys authenticating. Keys without a reachable tenant remain locked.

**v1 is legacy; v2 is the same commands with an optional tenant.** A v1 call always uses the first tenant the key can access and ignores a named one, so existing integrations keep working. v2 accepts `tenant` as a query parameter, JSON field, or `X-Tenant-Id` header; omit it and the request is the same as v1. `GET /api/v2/tenants` lists the workspaces the key may act on.

**Every rejection is the same 401.** Unknown, malformed, wrong secret, revoked, expired, deactivated user: one message, one code. Distinguishing them would let anyone holding a stolen table of key ids sort it into live and dead.

**Scope is derived from the HTTP method, not from a per-view flag.** `SAFE_METHODS` need only a read key; anything else needs a full one. Deriving it makes it impossible to add a mutating endpoint that forgets to declare itself one, *and* it keeps a read-only key able to GET a collection whose POST it may not call — which a per-endpoint flag cannot express.

**The API is locked until hourly billing is, and a suspended account cannot use it.** `require_unlocked_api_account` (`apps/api/access.py`) runs in `@api_endpoint` after authentication and the per-key budget, before the write-scope check and before usage is counted:

* no billing profile, or status other than `ACTIVE` (suspended **and** closed) → `403 forbidden`;
* `hourly_billing_eligibility(account)` not eligible → `402 payment_required` with the same `reason` checkout uses.

A `401` still means only "bad key". **Neither refusal spends the monthly allowance.** The Developer section stays usable so keys can be minted ahead of unlocking, showing why the API is closed and linking to billing. The account-level check has no selected plan — a historical deposit or an existing live server keeps general access unlocked — but `POST /api/v1/servers` calls `hourly_billing_eligibility(account, plan)` again, because another paid deployment still needs positive credit or an armed saved card.

**Deployment.** A monthly or annual order is not $0.00 upfront, needs a card entered on a Stripe page, and has no headless completion — so the API deploys **hourly**. Two funding paths: positive credit makes the order $0.00 due so it is born PAID and queues immediately; an armed saved card with a balance ≤ 0 produces an order for exactly one month of the plan's credit, which `create_server` charges through `charge_order_with_saved_payment_method` **before** queueing. A decline or unconfirmed payment returns `402 payment_required` with the order identifier (and the payment-attempt identifier when one exists) and **queues no provisioning.**

Checkout's 20-second duplicate-order window does **not** apply to `POST /api/v1/servers`: each create is a new order, and the `201` password is the one already on the row (the credential the worker will hand Proxmox), not a password minted locally.

**One response carries a secret, on purpose.** The `201` from a server creation returns `root_password`. **No serializer ever emits it** — the view puts it there — so no listing endpoint can grow it by accident (`test_no_response_ever_contains_the_root_password`). The same one-time-display rule governs the Developer section, which renders instead of redirecting after the POST that mints a key.

**Ownership is scoped in the query.** `owned_server_or_404` filters on `billing_account_id` rather than fetching and then checking: a filter cannot be reordered into a leak, and there is no branch holding an unowned row. Another account's server is a `404`, indistinguishable from one that does not exist.

**Deletion is admitted through the shared billing service only after provisioning has finished.** A VM in `failed` provisioning state returns `409`, and the client area disables its destroy controls; an operator must retry or resolve provisioning first.

**Private networks** (`/api/v1/networks`, `PATCH`/`DELETE`, `/sync`, and `/servers/<id>/networks` attach/detach) wrap the same functions the portal uses, so CIDR policy, the per-account cap, isolation FORWARD rules and "last NIC on a private-only server" stay one implementation. The server object exposes `private_networks[]`; deploy can still join **one** existing VNet. Another account's network or server is 404; delete while members remain is 409.

**`POST /servers/<id>/actions` with `"action": "shutdown"` is the ACPI halt.** There is no QEMU pause in the API or the portal ([5.5](/platform/vps/lifecycle.md#id-55-vm-lifecycle-actions)).

**Bandwidth is attribution, not a cap.** `GET /api/v1/servers/<id>/bandwidth` and `bandwidth.servers` on `GET /api/v1/account` expose the per-VM rows the poller already writes so an integration can see which box burned the pool. They do not invent a per-server allowance. The device endpoint is GET-only, scoped through `owned_server_or_404`, and returns zeros for the current month when the poller has not written a row yet, so a client can poll a new server without treating "not yet sampled" as 404. History is on that endpoint; the account payload is this month only, so fleet monitoring is one counted request rather than one per server. The account payload exposes `extra_server_allowance_tb` (the **earned** amount), `extra_per_additional_server_gb` (the full-month per-VPS rate), `instance_earned_tb` (an alias of the first), `instance_potential_tb`, `instance_remaining_tb`, `potential_allowance_tb`, `base_allowance_tb`, `tenure_bonus_tb`, `free_allowance_tb`, `allowance_tb` (available **now**) and `reset_at`. **Future potential is not spendable allowance.**

**Rate limits are an abuse brake, not an accounting boundary.** Two independent budgets, because they answer different questions: **120 requests per minute per key** stops a runaway script, and **100 deploys per hour per account** bounds something that spends real money and consumes IP addresses and hypervisor capacity. Counters live in the cache, so under multiple web workers the limit is approximate — the right trade, since the authoritative protections (ownership, billing eligibility, suspension) are all enforced in the database path. Failed authentication is throttled **per source address**, because authentication happens before a per-key budget can exist.

**Monthly request accounting is a different question and a different store.** Every account gets **5,000 free API requests per UTC calendar month**; each request beyond that bills **$1.00 per 100,000** against account credit (`API_INCLUDED_REQUESTS_PER_MONTH`, `API_OVERAGE_UNIT_REQUESTS`, `API_OVERAGE_RATE_PER_UNIT`) — cheap enough that a real integration never notices, expensive enough that a loop hammering `/api/v1/` runs a tab.

* **Counting is a database write, on purpose.** `last_used_at` is throttled to once a minute specifically so a read endpoint does not become a write endpoint; usage is different, because it is the thing we bill, so the count has to survive a Redis flush and a process restart. One `F() + 1` on the current month's row (plus one on the key's row, for attribution) is the cost of making the number real. **The cache is still the right place for the per-minute cap** — mixing the two would make "how many requests this month" a function of which worker you asked.
* **The pool is per account, not per key** (same shape as bandwidth). Two keys share the 5,000; a third party holding a stolen key spends the owner's allowance, which is the correct answer because the credential acts on the account. Per-key rows exist so the Developer tab and the admin client page can answer "which key burned it".
* **What counts:** a request that was authenticated, passed the per-key budget, passed the account-access gate, and was not a read-only key attempting a write — including a view-level `404` or `402`, because the caller used the API. **What does not:** no/bad credential (401), CORS preflight (OPTIONS), per-key 429, locked (402), suspended or closed (403), read-only key on a mutating method (403). Failed authentication is not a customer's request; a rate-limited request already has a brake; a locked or suspended account is not using the API; a read-only 403 did not reach the resource.
* **Metering failures are swallowed and reported.** Losing one tick is cheaper than taking `/api/v1/` down because a usage row could not be written.
* **Charging is out of band, and the high-water mark is the idempotency key.** A ledger entry per request would bury the credit history. `charge_api_overage` runs in billing maintenance on the same tick as bandwidth overage, bills only requests past 5,000 not yet billed, rounds down to the ledger quantum, and keys idempotency on `overage_charged_count` — so a second run charges nothing. **Earlier months are included**, not just the current one: overage accrues right up to the month boundary and anything landing on the last day would otherwise be stranded by the UTC rollover. There is no `UsageRecord` or dummy plan for this (bandwidth needed one because `UsageMeter` is keyed per plan); API overage is an `AccountLedgerEntry` with `metadata["source"] = "api_overage"` and that is the whole audit trail. **Going over does not 402 the request** — the rate limit is the brake, the charge is the bill, and maintenance already suspends an account whose credit goes negative.
* **Quota headers are only on counted responses.** `X-Api-Quota-Limit`, `X-Api-Quota-Remaining`, `X-Api-Quota-Used`. A `401` must not carry them: that would let anyone holding a stolen table of key ids read how busy a live key is. Remaining is clamped at zero rather than going negative.
* The open month follows the live setting; a **closed** month keeps the `included_count` snapshotted on the period row.

**Admin API projects** are a separate surface with their own bearer tokens, used by the network monitor and Aegis integrations. **Authenticated Admin API traffic has no per-token read or write rate limit**, so discovery, inventory and Aegis uploads can continue through telemetry catch-up and scan-result bursts without request-quota 429s. Invalid credentials retain the independent per-IP failed-authentication limit, but **a valid token is checked first** so failures from a shared egress IP cannot block authorized traffic. Token expiry/revocation, project scope, per-feature permissions, ownership validation and payload bounds remain enforced. The console shows project access, token capacity, expiry and saved last-use evidence; rotation keeps one-time masked secret presentation and explicit revocation, and access history is project-scoped and paginated.

**Documentation** lives in `templates/api/_documentation.html` so it cannot drift between surfaces — a template rather than a CMS page because it has to move with the code. `/docs/api/` wraps it on the marketing layout for everyone (integrators read it before they have an account); `/client/developer/docs/` wraps the same body inside the portal; `/console/billing/usage/api/docs/` wraps it again in the console as a Docs tab beside the fleet meter, because an admin-mode session is redirected out of the client page and Support otherwise could not quote the allowance or the error codes. That console copy omits the create-key CTA (the route is under `/client/` and would bounce them). `billing:admin-api-usage` and `billing:admin-api-docs` are **Support-readable (read-only)**: lookup ("why was this account charged $0.40 for API requests"), not a lever that moves money.

***


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.layeronecloud.com/platform/client-api.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
